Skip to content

Commit 9a3bf20

Browse files
fix(otel): cap metric attributes so series count does not grow with traffic (#35166)
`GenAIMetricRecorder._common_attributes` dumped the whole `hidden_params` object onto every metric datapoint as one label value. That object is per-request by construction: `response_cost`, `litellm_overhead_time_ms`, `cache_key`, `usage_object` and the provider's `additional_headers` rate-limit counters all move on every call. A unique label value is a new time series, and all six GenAI instruments share those attributes, so one request minted up to six series that would never be written to again That is the steady-state behavior of the feature rather than an abuse case, and it is wrong twice over. Hosted backends bill on series count, so recommending metrics be enabled would have meant a bill proportional to traffic. And a histogram whose every datapoint sits in its own series cannot be aggregated, so the dashboards would have looked populated while answering nothing Both paths now cap their attributes at METRIC_ATTRIBUTE_CEILING, which replaces the failure-only allowlist so the two paths cannot drift. The cap runs before the operator's `otel.attributes` filter, so an operator can narrow it and never widen it back to an unbounded label. Client-supplied and per-request metadata (`requester_metadata`, `spend_logs_metadata`, `user_api_key_end_user_id`, `requester_ip_address`) is metric-ineligible and stays on the span, which already carries it and where cardinality is free. `hidden_params` survives as a label but carries only `model_id` and `api_base`, which are bounded by the router's own deployment list and are the part a per-deployment panel reads Four tests fail against the previous behavior, the load-bearing one being that two requests differing only in per-request fields must land in one series rather than two
1 parent 858f3ea commit 9a3bf20

3 files changed

Lines changed: 243 additions & 49 deletions

File tree

litellm/integrations/otel/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,21 @@ lives in [`plumbing/`](./plumbing):
230230
cardinality filter, so an `otel.attributes` list cannot merge the failure series
231231
back into the success series. A proxy-gate rejection (auth / rate limit) records
232232
nothing, for the same reason it gets no span: no upstream call happened.
233+
Both paths cap their attributes at `METRIC_ATTRIBUTE_CEILING` before the
234+
operator's own `otel.attributes` filter runs, so the filter can narrow the set
235+
but never widen it. The ceiling is what keeps series count bounded by the
236+
deployment's own key/team/user/deployment count instead of by its traffic: a
237+
label value that moves per request mints a time series per request, which both
238+
bills per request on a hosted backend and leaves a histogram that cannot be
239+
aggregated. So client-supplied and per-request metadata (`requester_metadata`,
240+
`spend_logs_metadata`, `user_api_key_end_user_id`, `requester_ip_address`) is
241+
metric-ineligible and stays on the span, where cardinality is free, and the
242+
`hidden_params` label carries only `model_id`, the deployment identity a
243+
per-deployment panel joins on. `api_base` is excluded despite naming the same
244+
deployment, because it is a documented per-call parameter and so is caller-chosen
245+
in SDK use. Because the shared validator accepts every span attribute name, a
246+
filter that names a metric-ineligible one logs a warning once when the filter
247+
resolves rather than silently emitting nothing for it.
233248
- [`events.py`](./plumbing/events.py) — GenAI client events. Gated on
234249
`enable_events` (`LITELLM_OTEL_INTEGRATION_ENABLE_EVENTS`), a failed LLM call
235250
records the semconv `gen_ai.client.operation.exception` log event at severity

litellm/integrations/otel/plumbing/metrics.py

Lines changed: 82 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@
1010

1111
from dataclasses import dataclass
1212
from datetime import datetime
13-
from typing import Any, Final, FrozenSet, Mapping, Optional
13+
from typing import Any, Final, FrozenSet, Mapping, Optional, TypeAlias
1414

1515
from opentelemetry.metrics import Histogram, Meter
1616

1717
import litellm
18+
from litellm._logging import verbose_logger
1819
from litellm.integrations.opentelemetry import (
1920
METRIC_METADATA_KEYS,
2021
TOKEN_TYPE_ATTRIBUTE,
@@ -72,20 +73,29 @@ def create_genai_metrics(meter: Meter) -> GenAIMetrics:
7273
)
7374

7475

76+
# A metric datapoint's attributes. Values are the strings the recorder builds, except
77+
# the request model, which is whatever the caller passed and may be absent.
78+
MetricAttributes: TypeAlias = Mapping[str, "str | None"]
79+
7580
ERROR_TYPE_FALLBACK: Final = "_OTHER"
7681

77-
# The attributes a failure datapoint may carry: what operation failed, and whose
78-
# key/team/user it was. Every one is either a fixed enum or an operator-provisioned
79-
# identifier, so the failure series count is bounded by the deployment's own
80-
# key/team/user count. Deliberately excluded is everything the *client* supplies or
81-
# that varies per request -- ``metadata.requester_metadata``,
82-
# ``metadata.spend_logs_metadata``, ``metadata.user_api_key_end_user_id``,
83-
# ``metadata.requester_ip_address`` and the ``hidden_params`` blob (which carries the
84-
# provider's per-request response headers). A failed request costs the caller no
85-
# provider spend, so nothing would rate-limit a caller who minted a fresh series per
86-
# request out of a field they control. ``metadata.user_api_key_user_email`` is left
87-
# out too: it is bounded, but it is PII duplicating the user id already here.
88-
FAILURE_ATTRIBUTE_KEYS: Final[frozenset[str]] = frozenset(
82+
# Every attribute a metric datapoint may carry, on either path. A label value that
83+
# is unique per request is a new time series that will never be written to again, so
84+
# this set is what keeps the series count bounded by the deployment's own
85+
# key/team/user/deployment count rather than by its traffic. Each entry is a fixed
86+
# enum or an operator-provisioned identifier.
87+
#
88+
# Deliberately excluded is everything the *client* supplies or that moves per
89+
# request: ``metadata.requester_metadata`` and ``metadata.spend_logs_metadata`` (both
90+
# free-form from the request body), ``metadata.user_api_key_end_user_id`` (the body's
91+
# ``user`` field), and ``metadata.requester_ip_address``. Those stay on the span,
92+
# where cardinality is free and where they already are.
93+
# ``metadata.user_api_key_user_email`` is left out too: it is bounded, but it is PII
94+
# duplicating the user id already here.
95+
#
96+
# This is a CEILING, applied before the operator's own include/exclude filter, so an
97+
# operator can narrow it but never widen it back to an unbounded attribute.
98+
METRIC_ATTRIBUTE_CEILING: Final[frozenset[str]] = frozenset(
8999
(
90100
"gen_ai.operation.name",
91101
"gen_ai.system",
@@ -97,9 +107,23 @@ def create_genai_metrics(meter: Meter) -> GenAIMetrics:
97107
"metadata.user_api_key_team_alias",
98108
"metadata.user_api_key_org_id",
99109
"metadata.user_api_key_user_id",
110+
"hidden_params",
100111
)
101112
)
102113

114+
# The only ``hidden_params`` field that becomes part of the ``hidden_params`` label.
115+
# The object as a whole is per-request by construction -- ``response_cost``,
116+
# ``litellm_overhead_time_ms``, ``cache_key``, ``usage_object`` and the provider's
117+
# ``additional_headers`` rate-limit counters all move on every call -- so dumping it
118+
# whole made one series per request out of every instrument.
119+
#
120+
# ``model_id`` is the router's own deployment id, so it is bounded by the deployment
121+
# list and is what a per-deployment panel joins on. ``api_base`` is deliberately NOT
122+
# here even though it names the same thing: it is a documented per-call parameter, so
123+
# in SDK use it is chosen by the caller rather than provisioned by the operator, and a
124+
# caller varying it would put the per-request cardinality straight back.
125+
BOUNDED_HIDDEN_PARAM_KEYS: Final[tuple[str, ...]] = ("model_id",)
126+
103127

104128
def resolve_error_type(kwargs: Mapping[str, Any]) -> str:
105129
"""The ``error.type`` value for a failed request.
@@ -147,7 +171,7 @@ def record(
147171
start_time: datetime,
148172
end_time: datetime,
149173
) -> None:
150-
common_attrs = self._filter_attributes(self._common_attributes(kwargs))
174+
common_attrs = self._filter_attributes(self._bounded_attributes(kwargs))
151175
duration_s = (end_time - start_time).total_seconds()
152176

153177
self._metrics.operation_duration.record(duration_s, attributes=common_attrs)
@@ -177,19 +201,18 @@ def record_failure(
177201
zeroes ``response_cost`` on failure. Recording them anyway would put a
178202
fabricated zero into series that dashboards average.
179203
180-
The attribute set is the bounded ``FAILURE_ATTRIBUTE_KEYS`` allowlist, not
181-
the success path's full set: a failure needs no provider spend, so a caller
182-
who can put a unique value into a client-supplied attribute could mint one
183-
histogram series per request for free. The operator's own filter still
184-
applies on top, so it can narrow this further but never widen it.
204+
The attribute set is :data:`METRIC_ATTRIBUTE_CEILING`, the same cap the
205+
success path uses. A failure needs no provider spend, so a caller who can put
206+
a unique value into a client-supplied attribute could mint one histogram
207+
series per request for free; the cap is what makes that impossible on either
208+
path.
185209
186210
``error.type`` is stamped after both filters, exactly like
187211
``gen_ai.token.type``, so an operator's include/exclude list cannot strip
188212
the discriminator and silently merge failures back into the success series.
189213
"""
190-
bounded = {k: v for k, v in self._common_attributes(kwargs).items() if k in FAILURE_ATTRIBUTE_KEYS}
191214
attributes = {
192-
**self._filter_attributes(bounded),
215+
**self._filter_attributes(self._bounded_attributes(kwargs)),
193216
Error.TYPE: resolve_error_type(kwargs),
194217
}
195218
self._metrics.operation_duration.record((end_time - start_time).total_seconds(), attributes=attributes)
@@ -220,11 +243,25 @@ def _common_attributes(self, kwargs: Mapping[str, Any]) -> dict:
220243
common_attrs[f"metadata.{key}"] = str(value)
221244

222245
hidden_params = getattr(std_log, "hidden_params", None) or (std_log or {}).get("hidden_params", {})
223-
if hidden_params:
224-
common_attrs["hidden_params"] = safe_dumps(hidden_params)
246+
bounded_hidden_params = {
247+
key: hidden_params[key]
248+
for key in BOUNDED_HIDDEN_PARAM_KEYS
249+
if isinstance(hidden_params, Mapping) and hidden_params.get(key) is not None
250+
}
251+
if bounded_hidden_params:
252+
common_attrs["hidden_params"] = safe_dumps(bounded_hidden_params)
225253

226254
return common_attrs
227255

256+
def _bounded_attributes(self, kwargs: Mapping[str, Any]) -> MetricAttributes:
257+
"""The datapoint attributes, capped at :data:`METRIC_ATTRIBUTE_CEILING`.
258+
259+
The cap runs BEFORE the operator's include/exclude filter so the filter can
260+
only narrow it. An operator who names an excluded attribute in an include
261+
list gets nothing for it rather than reintroducing an unbounded label.
262+
"""
263+
return {k: v for k, v in self._common_attributes(kwargs).items() if k in METRIC_ATTRIBUTE_CEILING}
264+
228265
def _ensure_filter(self) -> None:
229266
if self._filter_resolved:
230267
return
@@ -241,8 +278,29 @@ def _ensure_filter(self) -> None:
241278
# without reconstructing the recorder.
242279
self._include, self._exclude = _resolve_metric_attribute_filter(attributes)
243280
self._filter_resolved = True
281+
self._warn_about_metric_ineligible_names()
244282

245-
def _filter_attributes(self, attrs: dict) -> dict:
283+
def _warn_about_metric_ineligible_names(self) -> None:
284+
"""Say so when the operator's filter names an attribute the ceiling removes.
285+
286+
The shared validator accepts every span attribute name, so a name that is
287+
legal on a span but metric-ineligible would otherwise be a silent no-op: an
288+
``include_list`` naming it emits nothing for it and an ``exclude_list`` naming
289+
it looks like it worked. Logged once, when the filter resolves, rather than
290+
per request.
291+
"""
292+
named = (self._include or frozenset()) | (self._exclude or frozenset())
293+
ineligible = sorted(named - METRIC_ATTRIBUTE_CEILING - {TOKEN_TYPE_ATTRIBUTE})
294+
if ineligible:
295+
verbose_logger.warning(
296+
"OTel metrics: %s cannot be a metric attribute and is being ignored; it varies "
297+
"per request or is client-supplied, so it would make one time series per request. "
298+
"It is still on the span. Metric attributes are limited to: %s",
299+
", ".join(ineligible),
300+
", ".join(sorted(METRIC_ATTRIBUTE_CEILING)),
301+
)
302+
303+
def _filter_attributes(self, attrs: MetricAttributes) -> MetricAttributes:
246304
self._ensure_filter()
247305
if self._include is not None:
248306
return {k: v for k, v in attrs.items() if k in self._include}

0 commit comments

Comments
 (0)