From 06e10a6b13f888397bf7daffd3975649c2865737 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 1 Jul 2026 21:23:39 +0300 Subject: [PATCH] feat(litestar): default prometheus group_path=True to bound cardinality Litestar's PrometheusConfig defaults group_path=False, so the `path` metric label records the raw URL. Parameterized routes then mint one series per distinct value and grow the registry unbounded, causing memory growth (see litestar-org/litestar#4891). Add LitestarConfig.prometheus_group_path (default True) so the label uses the route template. The instrument merges {"group_path": , **prometheus_additional_params} so the dict still overrides the field without a kwarg collision, keeping the existing group_path-via-additional_params workaround working. FastAPI is unaffected: prometheus_fastapi_instrumentator already labels by route template. Co-Authored-By: Claude Opus 4.8 (1M context) --- architecture/instruments.md | 14 ++++++++ docs/integrations/litestar.md | 18 ++++++++++ .../bootstrappers/litestar_bootstrapper.py | 9 ++++- tests/test_litestar_bootstrap.py | 33 +++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/architecture/instruments.md b/architecture/instruments.md index fd0d6cb..75c46a1 100644 --- a/architecture/instruments.md +++ b/architecture/instruments.md @@ -63,6 +63,20 @@ to be non-frozen, so `BaseInstrument` is non-frozen too. Both caches are reset to `None` inside a `try/finally` during `teardown()`, so a raised shutdown leaves no stale references. +## Prometheus path-label cardinality (Litestar) + +Litestar's `PrometheusConfig` defaults `group_path=False`, so the `path` metric +label holds the raw URL; parameterized routes then mint one series per distinct +value and grow the registry unbounded (memory growth — see +[litestar#4891](https://github.com/litestar-org/litestar/issues/4891)). +`LitestarConfig.prometheus_group_path` defaults to `True` to bind the label to +the route template (`/users/{id}`). `LitestarPrometheusInstrument.bootstrap` +merges `{"group_path": , **prometheus_additional_params}`, so precedence +is `prometheus_additional_params["group_path"]` > `prometheus_group_path` > +Litestar's own default. Set `prometheus_group_path=False` for raw paths. FastAPI +is unaffected: `prometheus_fastapi_instrumentator` already labels by route +template. + ## Cross-instrument integrations **Logging ↔ Sentry.** `logging_instrument.py` renders every structlog line to a diff --git a/docs/integrations/litestar.md b/docs/integrations/litestar.md index 576c559..20cf745 100644 --- a/docs/integrations/litestar.md +++ b/docs/integrations/litestar.md @@ -60,3 +60,21 @@ async def list_items(request: Request) -> list[str]: request.logger.info("listing items") return [] ``` + +## Prometheus + +`prometheus_group_path` defaults to `True`, so the `path` metric label uses the +route template (`/users/{id}`) instead of the raw URL. This bounds metric +cardinality; without it, parameterized routes mint a new series per distinct +value and grow memory unbounded ([litestar#4891](https://github.com/litestar-org/litestar/issues/4891)). + +Set `prometheus_group_path=False` to record raw paths. Anything in +`prometheus_additional_params` (including `group_path`) overrides the default: + +```python +LitestarConfig( + service_name="microservice", + prometheus_group_path=False, # raw paths + prometheus_additional_params={"exclude_unhandled_paths": True}, +) +``` diff --git a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py index 49cebb2..fe265ae 100644 --- a/lite_bootstrap/bootstrappers/litestar_bootstrapper.py +++ b/lite_bootstrap/bootstrappers/litestar_bootstrapper.py @@ -118,6 +118,8 @@ class LitestarConfig( ): application_config: "AppConfig" = dataclasses.field(default_factory=lambda: AppConfig()) # noqa: PLW0108 prometheus_additional_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) + # Bounds path-label cardinality (Litestar defaults False -> raw URLs leak memory). See litestar#4891. + prometheus_group_path: bool = True swagger_extra_params: dict[str, typing.Any] = dataclasses.field(default_factory=dict) @@ -209,9 +211,14 @@ class LitestarPrometheusController(PrometheusController): include_in_schema = self.bootstrap_config.prometheus_metrics_include_in_schema openmetrics_format = True + # Merged so prometheus_additional_params can override group_path without a kwarg collision. + prometheus_params: dict[str, typing.Any] = { + "group_path": self.bootstrap_config.prometheus_group_path, + **self.bootstrap_config.prometheus_additional_params, + } litestar_prometheus_config = PrometheusConfig( app_name=self.bootstrap_config.service_name, - **self.bootstrap_config.prometheus_additional_params, + **prometheus_params, ) self.bootstrap_config.application_config.route_handlers.append(LitestarPrometheusController) diff --git a/tests/test_litestar_bootstrap.py b/tests/test_litestar_bootstrap.py index e39e327..b81cd1d 100644 --- a/tests/test_litestar_bootstrap.py +++ b/tests/test_litestar_bootstrap.py @@ -154,6 +154,39 @@ async def log_handler(request: litestar.Request) -> dict[str, str]: assert response.json() == {"status": "ok"} +def _scrape_prometheus_path_labels(config: LitestarConfig, handler_path: str, request_path: str) -> str: + @litestar.get(handler_path) + async def _handler(user_id: FromPath[int]) -> dict[str, int]: + return {"user_id": user_id} + + config = dataclasses.replace(config, application_config=AppConfig(route_handlers=[_handler])) + application = LitestarBootstrapper(bootstrap_config=config).bootstrap() + with TestClient(app=application) as client: + client.get(request_path) + return client.get(config.prometheus_metrics_path).text + + +def test_litestar_prometheus_group_path_default_uses_route_template(litestar_config: LitestarConfig) -> None: + # Default prometheus_group_path=True keeps the path label bounded to the route template, + # so parameterized routes cannot explode metric cardinality. + metrics = _scrape_prometheus_path_labels(litestar_config, "/gp-default/{user_id:int}", "/gp-default/1") + assert "/gp-default/{user_id}" in metrics + assert "/gp-default/1" not in metrics + + +def test_litestar_prometheus_group_path_false_records_raw_path(litestar_config: LitestarConfig) -> None: + config = dataclasses.replace(litestar_config, prometheus_group_path=False) + metrics = _scrape_prometheus_path_labels(config, "/gp-false/{user_id:int}", "/gp-false/7") + assert "/gp-false/7" in metrics + + +def test_litestar_prometheus_additional_params_override_group_path(litestar_config: LitestarConfig) -> None: + # prometheus_additional_params wins over the prometheus_group_path default, without a kwarg collision. + config = dataclasses.replace(litestar_config, prometheus_additional_params={"group_path": False}) + metrics = _scrape_prometheus_path_labels(config, "/gp-override/{user_id:int}", "/gp-override/9") + assert "/gp-override/9" in metrics + + def test_build_span_name_no_route() -> None: assert build_span_name("GET", "") == "GET"