Skip to content

Commit 9bbdd21

Browse files
committed
feat: Add metadata statistics to registry api
Signed-off-by: ntkathole <nikhilkathole2683@gmail.com>
1 parent 56e6d21 commit 9bbdd21

File tree

2 files changed

+213
-31
lines changed

2 files changed

+213
-31
lines changed

sdk/python/feast/api/registry/rest/metrics.py

Lines changed: 127 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -65,83 +65,155 @@ async def resource_counts(
6565
),
6666
allow_cache: bool = Query(True),
6767
):
68-
def count_resources_for_project(project_name: str):
68+
"""
69+
Resource counts and feature store inventory metadata.
70+
71+
Returns counts per resource type, plus enriched summaries:
72+
feature services (names), feature views (with per-view feature
73+
count and materialization info), project list, and the registry
74+
last-updated timestamp.
75+
"""
76+
77+
def get_registry_last_updated() -> Optional[str]:
6978
try:
70-
entities = grpc_call(
79+
from google.protobuf.empty_pb2 import Empty as EmptyProto
80+
81+
registry_proto = grpc_call(grpc_handler.Proto, EmptyProto())
82+
return registry_proto.get("lastUpdated", None)
83+
except Exception:
84+
return None
85+
86+
def _extract_fv_summary(any_fv: dict, project_name: str) -> Optional[Dict]:
87+
fv = _extract_feature_view_from_any(any_fv)
88+
if not fv:
89+
return None
90+
spec = fv.get("spec", {})
91+
features = spec.get("features", [])
92+
return {
93+
"name": spec.get("name", ""),
94+
"project": project_name,
95+
"type": fv.get("type", "featureView"),
96+
"featureCount": len(features) if isinstance(features, list) else 0,
97+
}
98+
99+
def collect_resources_for_project(project_name: str) -> dict:
100+
entities_list: list = []
101+
try:
102+
entities_resp = grpc_call(
71103
grpc_handler.ListEntities,
72104
RegistryServer_pb2.ListEntitiesRequest(
73105
project=project_name, allow_cache=allow_cache
74106
),
75107
)
108+
entities_list = entities_resp.get("entities", [])
76109
except Exception:
77-
entities = {"entities": []}
110+
pass
111+
112+
data_sources_list: list = []
78113
try:
79-
data_sources = grpc_call(
114+
ds_resp = grpc_call(
80115
grpc_handler.ListDataSources,
81116
RegistryServer_pb2.ListDataSourcesRequest(
82117
project=project_name, allow_cache=allow_cache
83118
),
84119
)
120+
data_sources_list = ds_resp.get("dataSources", [])
85121
except Exception:
86-
data_sources = {"dataSources": []}
122+
pass
123+
124+
saved_datasets_list: list = []
87125
try:
88-
saved_datasets = grpc_call(
126+
sd_resp = grpc_call(
89127
grpc_handler.ListSavedDatasets,
90128
RegistryServer_pb2.ListSavedDatasetsRequest(
91129
project=project_name, allow_cache=allow_cache
92130
),
93131
)
132+
saved_datasets_list = sd_resp.get("savedDatasets", [])
94133
except Exception:
95-
saved_datasets = {"savedDatasets": []}
134+
pass
135+
136+
features_list: list = []
96137
try:
97-
features = grpc_call(
138+
feat_resp = grpc_call(
98139
grpc_handler.ListFeatures,
99140
RegistryServer_pb2.ListFeaturesRequest(
100141
project=project_name, allow_cache=allow_cache
101142
),
102143
)
144+
features_list = feat_resp.get("features", [])
103145
except Exception:
104-
features = {"features": []}
146+
pass
147+
148+
raw_fv_list: list = []
149+
fv_summaries: List[Dict] = []
105150
try:
106-
feature_views = grpc_call(
151+
fv_resp = grpc_call(
107152
grpc_handler.ListAllFeatureViews,
108153
RegistryServer_pb2.ListAllFeatureViewsRequest(
109154
project=project_name, allow_cache=allow_cache
110155
),
111156
)
157+
raw_fv_list = fv_resp.get("featureViews", [])
158+
for any_fv in raw_fv_list:
159+
summary = _extract_fv_summary(any_fv, project_name)
160+
if summary:
161+
fv_summaries.append(summary)
112162
except Exception:
113-
feature_views = {"featureViews": []}
163+
pass
164+
165+
fs_summaries: List[Dict] = []
166+
raw_fs_list: list = []
114167
try:
115-
feature_services = grpc_call(
168+
fs_resp = grpc_call(
116169
grpc_handler.ListFeatureServices,
117170
RegistryServer_pb2.ListFeatureServicesRequest(
118171
project=project_name, allow_cache=allow_cache
119172
),
120173
)
174+
raw_fs_list = fs_resp.get("featureServices", [])
175+
for fs in raw_fs_list:
176+
spec = fs.get("spec", {})
177+
fs_summaries.append(
178+
{"name": spec.get("name", ""), "project": project_name}
179+
)
121180
except Exception:
122-
feature_services = {"featureServices": []}
181+
pass
182+
123183
return {
124-
"entities": len(entities.get("entities", [])),
125-
"dataSources": len(data_sources.get("dataSources", [])),
126-
"savedDatasets": len(saved_datasets.get("savedDatasets", [])),
127-
"features": len(features.get("features", [])),
128-
"featureViews": len(feature_views.get("featureViews", [])),
129-
"featureServices": len(feature_services.get("featureServices", [])),
184+
"counts": {
185+
"entities": len(entities_list),
186+
"dataSources": len(data_sources_list),
187+
"savedDatasets": len(saved_datasets_list),
188+
"features": len(features_list),
189+
"featureViews": len(raw_fv_list),
190+
"featureServices": len(raw_fs_list),
191+
},
192+
"featureServices": fs_summaries,
193+
"featureViews": fv_summaries,
130194
}
131195

196+
registry_last_updated = get_registry_last_updated()
197+
132198
if project:
133-
counts = count_resources_for_project(project)
134-
return {"project": project, "counts": counts}
199+
resources = collect_resources_for_project(project)
200+
return {
201+
"project": project,
202+
"counts": resources["counts"],
203+
"featureServices": resources["featureServices"],
204+
"featureViews": resources["featureViews"],
205+
"projects": [{"name": project}],
206+
"registryLastUpdated": registry_last_updated,
207+
}
135208
else:
136-
# List all projects via gRPC
137209
projects_resp = grpc_call(
138210
grpc_handler.ListProjects,
139211
RegistryServer_pb2.ListProjectsRequest(allow_cache=allow_cache),
140212
)
141-
all_projects = [
142-
p["spec"]["name"] for p in projects_resp.get("projects", [])
143-
]
144-
all_counts = {}
213+
all_projects = projects_resp.get("projects", [])
214+
project_names = [p["spec"]["name"] for p in all_projects if "spec" in p]
215+
216+
all_counts: Dict[str, dict] = {}
145217
total_counts = {
146218
"entities": 0,
147219
"dataSources": 0,
@@ -150,12 +222,37 @@ def count_resources_for_project(project_name: str):
150222
"featureViews": 0,
151223
"featureServices": 0,
152224
}
153-
for project_name in all_projects:
154-
counts = count_resources_for_project(project_name)
155-
all_counts[project_name] = counts
225+
all_fs: List[Dict] = []
226+
all_fv: List[Dict] = []
227+
project_summaries: List[Dict] = []
228+
229+
for pname in project_names:
230+
resources = collect_resources_for_project(pname)
231+
counts = resources["counts"]
232+
all_counts[pname] = counts
156233
for k in total_counts:
157234
total_counts[k] += counts[k]
158-
return {"total": total_counts, "perProject": all_counts}
235+
all_fs.extend(resources["featureServices"])
236+
all_fv.extend(resources["featureViews"])
237+
proj_info = next(
238+
(p for p in all_projects if p.get("spec", {}).get("name") == pname),
239+
{},
240+
)
241+
project_summaries.append(
242+
{
243+
"name": pname,
244+
"description": proj_info.get("spec", {}).get("description", ""),
245+
}
246+
)
247+
248+
return {
249+
"total": total_counts,
250+
"perProject": all_counts,
251+
"featureServices": all_fs,
252+
"featureViews": all_fv,
253+
"projects": project_summaries,
254+
"registryLastUpdated": registry_last_updated,
255+
}
159256

160257
@router.get(
161258
"/metrics/popular_tags", tags=["Metrics"], response_model=PopularTagsResponse

sdk/python/tests/unit/api/test_api_rest_registry.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1503,11 +1503,50 @@ def test_metrics_resource_counts_via_rest(fastapi_test_app):
15031503
assert "featureViews" in counts
15041504
assert "featureServices" in counts
15051505

1506-
# Verify counts are integers
15071506
for key, value in counts.items():
15081507
assert isinstance(value, int)
15091508
assert value >= 0
15101509

1510+
# Verify feature services summaries
1511+
assert "featureServices" in data
1512+
assert isinstance(data["featureServices"], list)
1513+
assert len(data["featureServices"]) == counts["featureServices"]
1514+
for fs in data["featureServices"]:
1515+
assert "name" in fs
1516+
assert "project" in fs
1517+
assert fs["project"] == "demo_project"
1518+
service_names = [fs["name"] for fs in data["featureServices"]]
1519+
assert "user_service" in service_names
1520+
1521+
# Verify feature views summaries with detail
1522+
assert "featureViews" in data
1523+
assert isinstance(data["featureViews"], list)
1524+
assert len(data["featureViews"]) == counts["featureViews"]
1525+
for fv in data["featureViews"]:
1526+
assert "name" in fv
1527+
assert "project" in fv
1528+
assert "type" in fv
1529+
assert "featureCount" in fv
1530+
assert isinstance(fv["featureCount"], int)
1531+
assert fv["featureCount"] >= 0
1532+
assert fv["project"] == "demo_project"
1533+
fv_names = [fv["name"] for fv in data["featureViews"]]
1534+
assert "user_profile" in fv_names
1535+
user_profile_fv = next(
1536+
fv for fv in data["featureViews"] if fv["name"] == "user_profile"
1537+
)
1538+
assert user_profile_fv["featureCount"] == 2
1539+
1540+
# Verify projects list
1541+
assert "projects" in data
1542+
assert isinstance(data["projects"], list)
1543+
assert len(data["projects"]) == 1
1544+
assert data["projects"][0]["name"] == "demo_project"
1545+
1546+
# Verify registry last updated
1547+
assert "registryLastUpdated" in data
1548+
assert data["registryLastUpdated"] is not None
1549+
15111550
# Test without project parameter (should return all projects)
15121551
response = fastapi_test_app.get("/metrics/resource_counts")
15131552
assert response.status_code == 200
@@ -1527,6 +1566,28 @@ def test_metrics_resource_counts_via_rest(fastapi_test_app):
15271566
assert "demo_project" in per_project
15281567
assert isinstance(per_project["demo_project"], dict)
15291568

1569+
# Verify metadata in all-projects mode
1570+
assert "featureServices" in data
1571+
assert isinstance(data["featureServices"], list)
1572+
1573+
assert "featureViews" in data
1574+
assert isinstance(data["featureViews"], list)
1575+
for fv in data["featureViews"]:
1576+
assert "name" in fv
1577+
assert "project" in fv
1578+
assert "type" in fv
1579+
assert "featureCount" in fv
1580+
1581+
assert "projects" in data
1582+
assert isinstance(data["projects"], list)
1583+
assert len(data["projects"]) >= 1
1584+
for proj in data["projects"]:
1585+
assert "name" in proj
1586+
assert "description" in proj
1587+
1588+
assert "registryLastUpdated" in data
1589+
assert data["registryLastUpdated"] is not None
1590+
15301591

15311592
def test_metrics_resource_counts_with_permission_errors(fastapi_test_app):
15321593
"""
@@ -1585,6 +1646,10 @@ def grpc_call_entities_denied(handler_fn, request):
15851646
assert counts["features"] == baseline_counts["features"]
15861647
assert counts["savedDatasets"] == baseline_counts["savedDatasets"]
15871648

1649+
# Permitted metadata summaries should still be populated
1650+
assert len(data["featureViews"]) == baseline_counts["featureViews"]
1651+
assert len(data["featureServices"]) == baseline_counts["featureServices"]
1652+
15881653
# Now test with ALL resource types denied
15891654
def grpc_call_all_denied(handler_fn, request):
15901655
handler_name = getattr(handler_fn, "__name__", "")
@@ -1607,6 +1672,10 @@ def grpc_call_all_denied(handler_fn, request):
16071672
f"Expected 0 for {resource_type} when permission denied, got {count}"
16081673
)
16091674

1675+
# Metadata summaries should be empty when all permissions are denied
1676+
assert data["featureViews"] == []
1677+
assert data["featureServices"] == []
1678+
16101679

16111680
def test_feature_views_all_types_and_resource_counts_match(fastapi_test_app):
16121681
"""
@@ -1931,3 +2000,19 @@ def test_all_endpoints_return_404_for_invalid_objects(fastapi_test_app):
19312000
data = response.json()
19322001
assert data["status_code"] == 404
19332002
assert data["error_type"] == "FeastObjectNotFoundException"
2003+
2004+
2005+
def test_metrics_resource_counts_nonexistent_project(fastapi_test_app):
2006+
"""Test /metrics/resource_counts with a non-existent project returns empty data."""
2007+
response = fastapi_test_app.get(
2008+
"/metrics/resource_counts?project=nonexistent_project"
2009+
)
2010+
assert response.status_code == 200
2011+
data = response.json()
2012+
2013+
counts = data["counts"]
2014+
for value in counts.values():
2015+
assert value == 0
2016+
assert data["featureServices"] == []
2017+
assert data["featureViews"] == []
2018+
assert "registryLastUpdated" in data

0 commit comments

Comments
 (0)