Skip to content

Commit bac23ab

Browse files
committed
feat: Bucket feature count labels in latency metrics
Signed-off-by: Jingqian Liu <ljqstella@gmail.com>
1 parent 5ad5592 commit bac23ab

4 files changed

Lines changed: 197 additions & 8 deletions

File tree

docs/reference/feature-servers/python-feature-server.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ thread from starting). All categories default to `true` except
368368
| `feast_feature_server_cpu_usage` | Gauge | — | `resource` | Process CPU usage % |
369369
| `feast_feature_server_memory_usage` | Gauge | — | `resource` | Process memory usage % |
370370
| `feast_feature_server_request_total` | Counter | `endpoint`, `status` | `request` | Total requests per endpoint |
371-
| `feast_feature_server_request_latency_seconds` | Histogram | `endpoint`, `feature_count`, `feature_view_count` | `request` | Request latency with p50/p95/p99 support |
371+
| `feast_feature_server_request_latency_seconds` | Histogram | `endpoint`, `feature_count`, `feature_view_count` | `request` | Request latency with p50/p95/p99 support (`feature_count` is bucketed, see below) |
372372
| `feast_online_features_request_total` | Counter | — | `online_features` | Total online feature retrieval requests |
373373
| `feast_online_features_entity_count` | Histogram | — | `online_features` | Entity rows per online feature request |
374374
| `feast_feature_server_online_store_read_duration_seconds` | Histogram | — | `online_features` | Online store read phase duration (sync and async) |
@@ -382,6 +382,28 @@ thread from starting). All categories default to `true` except
382382
| `feast_offline_store_request_latency_seconds` | Histogram | `method` | `offline_features` | Latency of offline store retrieval operations |
383383
| `feast_offline_store_row_count` | Histogram | `method` | `offline_features` | Rows returned by offline store retrieval |
384384

385+
### Feature count bucketing
386+
387+
The `feature_count` label on `feast_feature_server_request_latency_seconds`
388+
is bucketed rather than exact, to keep cardinality bounded for feature
389+
services that vary widely in how many features they request. By default,
390+
counts are grouped into `0`, `1-10`, `11-50`, `51-200`, and `201+`.
391+
392+
Customize the bucket boundaries with `feature_count_bins` in the `metrics`
393+
block:
394+
395+
```yaml
396+
feature_server:
397+
type: local
398+
metrics:
399+
enabled: true
400+
feature_count_bins: [5, 20]
401+
```
402+
403+
This produces the labels `0`, `1-5`, `6-20`, and `21+`. Note that this only
404+
affects the Prometheus label; the `feature_count` field in audit logs (see
405+
below) always reports the exact count.
406+
385407
### Per-ODFV transformation metrics
386408

387409
The `transformation_duration_seconds` and `write_transformation_duration_seconds`

sdk/python/feast/feature_server.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,21 @@ def _resolve_feature_counts(
238238
return str(feat_count), str(len(fv_names))
239239

240240

241+
def bin_feature_count(count: int, bins: List[int]) -> str:
242+
"""Map a raw feature count to an inclusive range label."""
243+
if count == 0:
244+
return "0"
245+
246+
lower = 1
247+
248+
for upper in bins:
249+
if count <= upper:
250+
return f"{lower}-{upper}"
251+
lower = upper + 1
252+
253+
return f"{lower}+"
254+
255+
241256
def _emit_online_audit(
242257
request: GetOnlineFeaturesRequest,
243258
features: Union[List[str], "feast.FeatureService"],
@@ -607,6 +622,15 @@ async def lifespan(app: FastAPI):
607622

608623
app = FastAPI(lifespan=lifespan)
609624

625+
fs_cfg = getattr(store.config, "feature_server", None)
626+
metrics_cfg = getattr(fs_cfg, "metrics", None)
627+
628+
feature_count_bins = (
629+
getattr(metrics_cfg, "feature_count_bins", [10, 50, 200])
630+
if metrics_cfg is not None
631+
else [10, 50, 200]
632+
)
633+
610634
@app.post(
611635
"/get-online-features",
612636
dependencies=[Depends(inject_user_details)],
@@ -618,7 +642,11 @@ async def get_online_features(request: GetOnlineFeaturesRequest) -> Any:
618642
) as metrics_ctx:
619643
features = await _get_features(request, store)
620644
feat_count, fv_count = _resolve_feature_counts(features)
621-
metrics_ctx.feature_count = feat_count
645+
646+
metrics_ctx.feature_count = bin_feature_count(
647+
int(feat_count),
648+
feature_count_bins,
649+
)
622650
metrics_ctx.feature_view_count = fv_count
623651

624652
entity_count = len(next(iter(request.entities.values()), []))
@@ -1261,6 +1289,7 @@ def start_server(
12611289

12621290
fs_cfg = getattr(store.config, "feature_server", None)
12631291
metrics_cfg = getattr(fs_cfg, "metrics", None)
1292+
12641293
metrics_from_config = getattr(metrics_cfg, "enabled", False)
12651294
metrics_active = metrics or metrics_from_config
12661295
uses_gunicorn = sys.platform != "win32"

sdk/python/feast/infra/feature_servers/base_config.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
# limitations under the License.
1414
from typing import Optional
1515

16-
from pydantic import StrictBool, StrictInt
16+
from pydantic import Field, StrictBool, StrictInt, field_validator
1717

1818
from feast.repo_config import FeastConfigBaseModel
1919

@@ -59,6 +59,24 @@ class MetricsConfig(FeastConfigBaseModel):
5959
(feast_feature_server_request_total,
6060
feast_feature_server_request_latency_seconds)."""
6161

62+
feature_count_bins: list[int] = Field(default_factory=lambda: [10, 50, 200])
63+
"""Upper bounds used to bucket the ``feature_count`` label in request latency metrics.
64+
65+
For example, ``[10, 50, 200]`` produces labels
66+
``1-10``, ``11-50``, ``51-200``, and ``201+``.
67+
"""
68+
69+
@field_validator("feature_count_bins")
70+
@classmethod
71+
def validate_feature_count_bins(cls, bins: list[int]) -> list[int]:
72+
if any(bound <= 0 for bound in bins):
73+
raise ValueError("feature_count_bins must contain only positive integers")
74+
75+
if any(lower >= upper for lower, upper in zip(bins, bins[1:])):
76+
raise ValueError("feature_count_bins must be strictly increasing")
77+
78+
return bins
79+
6280
online_features: StrictBool = True
6381
"""Emit online feature retrieval metrics
6482
(feast_online_features_request_total,

sdk/python/tests/unit/test_metrics.py

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,82 @@ def test_feature_service(self):
741741
assert fv_count == "2"
742742

743743

744+
class TestBinFeatureCount:
745+
@pytest.mark.parametrize(
746+
("count", "expected"),
747+
[
748+
(0, "0"),
749+
(1, "1-10"),
750+
(10, "1-10"),
751+
(11, "11-50"),
752+
(50, "11-50"),
753+
(51, "51-200"),
754+
(200, "51-200"),
755+
(201, "201+"),
756+
],
757+
)
758+
def test_default_boundaries(self, count, expected):
759+
from feast.feature_server import bin_feature_count
760+
761+
assert bin_feature_count(count, [10, 50, 200]) == expected
762+
763+
@pytest.mark.parametrize(
764+
("count", "expected"),
765+
[
766+
(1, "1-5"),
767+
(5, "1-5"),
768+
(6, "6-20"),
769+
(20, "6-20"),
770+
(21, "21+"),
771+
],
772+
)
773+
def test_custom_boundaries(self, count, expected):
774+
775+
from feast.feature_server import bin_feature_count
776+
777+
assert bin_feature_count(count, [5, 20]) == expected
778+
779+
780+
class TestMetricsConfig:
781+
def test_feature_count_bins_default(self):
782+
from feast.infra.feature_servers.base_config import MetricsConfig
783+
784+
config = MetricsConfig()
785+
assert config.feature_count_bins == [10, 50, 200]
786+
787+
def test_feature_count_bins_custom(self):
788+
from feast.infra.feature_servers.base_config import MetricsConfig
789+
790+
config = MetricsConfig(feature_count_bins=[5, 20])
791+
assert config.feature_count_bins == [5, 20]
792+
793+
@pytest.mark.parametrize(
794+
"bins",
795+
[
796+
[0, 10, 50],
797+
[-1, 10, 50],
798+
],
799+
)
800+
def test_feature_count_bins_must_be_positive(self, bins):
801+
from feast.infra.feature_servers.base_config import MetricsConfig
802+
803+
with pytest.raises(ValueError, match="positive"):
804+
MetricsConfig(feature_count_bins=bins)
805+
806+
@pytest.mark.parametrize(
807+
"bins",
808+
[
809+
[50, 10, 200],
810+
[10, 10, 200],
811+
],
812+
)
813+
def test_feature_count_bins_must_be_strictly_increasing(self, bins):
814+
from feast.infra.feature_servers.base_config import MetricsConfig
815+
816+
with pytest.raises(ValueError, match="strictly increasing"):
817+
MetricsConfig(feature_count_bins=bins)
818+
819+
744820
class TestFeatureServerMetricsIntegration:
745821
"""Test that feature server endpoints record metrics."""
746822

@@ -751,6 +827,11 @@ def mock_fs_factory(self):
751827
def builder(**async_support):
752828
provider = FooProvider.with_async_support(**async_support)
753829
fs = MagicMock()
830+
831+
from feast.infra.feature_servers.base_config import MetricsConfig
832+
833+
fs.config.feature_server.metrics = MetricsConfig()
834+
754835
fs._get_provider.return_value = provider
755836
from feast.online_response import OnlineResponse
756837
from feast.protos.feast.serving.ServingService_pb2 import (
@@ -798,20 +879,30 @@ def test_get_online_features_records_metrics(self, mock_fs_factory):
798879
@pytest.mark.parametrize(
799880
"features,expected_feat_count,expected_fv_count",
800881
[
801-
(["fv1:a"], "1", "1"),
802-
(["fv1:a", "fv1:b", "fv2:c"], "3", "2"),
882+
(["fv1:a"], "1-10", "1"),
883+
(["fv1:a", "fv1:b", "fv2:c"], "1-10", "2"),
803884
(
804885
["fv1:a", "fv1:b", "fv2:c", "fv2:d", "fv3:e"],
805-
"5",
886+
"1-10",
806887
"3",
807888
),
889+
(
890+
[f"fv1:f{i}" for i in range(11)],
891+
"11-50",
892+
"1",
893+
),
894+
],
895+
ids=[
896+
"1_feat_1_fv",
897+
"3_feats_2_fvs",
898+
"5_feats_3_fvs",
899+
"11_feats_1_fv",
808900
],
809-
ids=["1_feat_1_fv", "3_feats_2_fvs", "5_feats_3_fvs"],
810901
)
811902
def test_latency_labels_with_varying_request_sizes(
812903
self, mock_fs_factory, features, expected_feat_count, expected_fv_count
813904
):
814-
"""Verify feature_count and feature_view_count labels change with request size."""
905+
"""Verify feature_count is bucketed while feature_view_count remains exact."""
815906
from fastapi.testclient import TestClient
816907

817908
from feast.feature_server import get_app
@@ -837,6 +928,35 @@ def test_latency_labels_with_varying_request_sizes(
837928
after_sum = request_latency.labels(**label_set)._sum.get()
838929
assert after_sum > before_sum
839930

931+
def test_latency_labels_use_custom_feature_count_bins(self, mock_fs_factory):
932+
from fastapi.testclient import TestClient
933+
934+
from feast.feature_server import get_app
935+
from feast.infra.feature_servers.base_config import MetricsConfig
936+
937+
fs = mock_fs_factory(online_read=False)
938+
fs.config.feature_server.metrics = MetricsConfig(feature_count_bins=[2, 4])
939+
940+
client = TestClient(get_app(fs))
941+
942+
label_set = dict(
943+
endpoint="/get-online-features",
944+
feature_count="3-4",
945+
feature_view_count="1",
946+
)
947+
before_sum = request_latency.labels(**label_set)._sum.get()
948+
949+
client.post(
950+
"/get-online-features",
951+
json={
952+
"features": ["fv:a", "fv:b", "fv:c"],
953+
"entities": {"id": [1]},
954+
},
955+
)
956+
957+
after_sum = request_latency.labels(**label_set)._sum.get()
958+
assert after_sum > before_sum
959+
840960
def test_push_records_metrics(self, mock_fs_factory):
841961
from fastapi.testclient import TestClient
842962

0 commit comments

Comments
 (0)