Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions architecture/instruments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": <field>, **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
Expand Down
18 changes: 18 additions & 0 deletions docs/integrations/litestar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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},
)
```
9 changes: 8 additions & 1 deletion lite_bootstrap/bootstrappers/litestar_bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions tests/test_litestar_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down