Skip to content

Commit 6631566

Browse files
fix(otel): cap metric attributes so series count does not grow with traffic
`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 6631566

3 files changed

Lines changed: 187 additions & 49 deletions

File tree

litellm/integrations/otel/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,17 @@ 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` and `api_base`, the deployment
243+
identity a per-deployment panel reads.
233244
- [`events.py`](./plumbing/events.py) — GenAI client events. Gated on
234245
`enable_events` (`LITELLM_OTEL_INTEGRATION_ENABLE_EVENTS`), a failed LLM call
235246
records the semconv `gen_ai.client.operation.exception` log event at severity

litellm/integrations/otel/plumbing/metrics.py

Lines changed: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
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

@@ -72,20 +72,29 @@ def create_genai_metrics(meter: Meter) -> GenAIMetrics:
7272
)
7373

7474

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

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(
81+
# Every attribute a metric datapoint may carry, on either path. A label value that
82+
# is unique per request is a new time series that will never be written to again, so
83+
# this set is what keeps the series count bounded by the deployment's own
84+
# key/team/user/deployment count rather than by its traffic. Each entry is a fixed
85+
# enum or an operator-provisioned identifier.
86+
#
87+
# Deliberately excluded is everything the *client* supplies or that moves per
88+
# request: ``metadata.requester_metadata`` and ``metadata.spend_logs_metadata`` (both
89+
# free-form from the request body), ``metadata.user_api_key_end_user_id`` (the body's
90+
# ``user`` field), and ``metadata.requester_ip_address``. Those stay on the span,
91+
# where cardinality is free and where they already are.
92+
# ``metadata.user_api_key_user_email`` is left out too: it is bounded, but it is PII
93+
# duplicating the user id already here.
94+
#
95+
# This is a CEILING, applied before the operator's own include/exclude filter, so an
96+
# operator can narrow it but never widen it back to an unbounded attribute.
97+
METRIC_ATTRIBUTE_CEILING: Final[frozenset[str]] = frozenset(
8998
(
9099
"gen_ai.operation.name",
91100
"gen_ai.system",
@@ -97,9 +106,19 @@ def create_genai_metrics(meter: Meter) -> GenAIMetrics:
97106
"metadata.user_api_key_team_alias",
98107
"metadata.user_api_key_org_id",
99108
"metadata.user_api_key_user_id",
109+
"hidden_params",
100110
)
101111
)
102112

113+
# The only ``hidden_params`` fields that become part of the ``hidden_params`` label.
114+
# The object as a whole is per-request by construction -- ``response_cost``,
115+
# ``litellm_overhead_time_ms``, ``cache_key``, ``usage_object`` and the provider's
116+
# ``additional_headers`` rate-limit counters all move on every call -- so dumping it
117+
# whole made one series per request out of every instrument. These two identify which
118+
# deployment served the call, which is bounded by the router's own deployment list
119+
# and is the part a per-deployment dashboard actually reads.
120+
BOUNDED_HIDDEN_PARAM_KEYS: Final[tuple[str, ...]] = ("model_id", "api_base")
121+
103122

104123
def resolve_error_type(kwargs: Mapping[str, Any]) -> str:
105124
"""The ``error.type`` value for a failed request.
@@ -147,7 +166,7 @@ def record(
147166
start_time: datetime,
148167
end_time: datetime,
149168
) -> None:
150-
common_attrs = self._filter_attributes(self._common_attributes(kwargs))
169+
common_attrs = self._filter_attributes(self._bounded_attributes(kwargs))
151170
duration_s = (end_time - start_time).total_seconds()
152171

153172
self._metrics.operation_duration.record(duration_s, attributes=common_attrs)
@@ -177,19 +196,18 @@ def record_failure(
177196
zeroes ``response_cost`` on failure. Recording them anyway would put a
178197
fabricated zero into series that dashboards average.
179198
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.
199+
The attribute set is :data:`METRIC_ATTRIBUTE_CEILING`, the same cap the
200+
success path uses. A failure needs no provider spend, so a caller who can put
201+
a unique value into a client-supplied attribute could mint one histogram
202+
series per request for free; the cap is what makes that impossible on either
203+
path.
185204
186205
``error.type`` is stamped after both filters, exactly like
187206
``gen_ai.token.type``, so an operator's include/exclude list cannot strip
188207
the discriminator and silently merge failures back into the success series.
189208
"""
190-
bounded = {k: v for k, v in self._common_attributes(kwargs).items() if k in FAILURE_ATTRIBUTE_KEYS}
191209
attributes = {
192-
**self._filter_attributes(bounded),
210+
**self._filter_attributes(self._bounded_attributes(kwargs)),
193211
Error.TYPE: resolve_error_type(kwargs),
194212
}
195213
self._metrics.operation_duration.record((end_time - start_time).total_seconds(), attributes=attributes)
@@ -220,11 +238,25 @@ def _common_attributes(self, kwargs: Mapping[str, Any]) -> dict:
220238
common_attrs[f"metadata.{key}"] = str(value)
221239

222240
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)
241+
bounded_hidden_params = {
242+
key: hidden_params[key]
243+
for key in BOUNDED_HIDDEN_PARAM_KEYS
244+
if isinstance(hidden_params, Mapping) and hidden_params.get(key) is not None
245+
}
246+
if bounded_hidden_params:
247+
common_attrs["hidden_params"] = safe_dumps(bounded_hidden_params)
225248

226249
return common_attrs
227250

251+
def _bounded_attributes(self, kwargs: Mapping[str, Any]) -> MetricAttributes:
252+
"""The datapoint attributes, capped at :data:`METRIC_ATTRIBUTE_CEILING`.
253+
254+
The cap runs BEFORE the operator's include/exclude filter so the filter can
255+
only narrow it. An operator who names an excluded attribute in an include
256+
list gets nothing for it rather than reintroducing an unbounded label.
257+
"""
258+
return {k: v for k, v in self._common_attributes(kwargs).items() if k in METRIC_ATTRIBUTE_CEILING}
259+
228260
def _ensure_filter(self) -> None:
229261
if self._filter_resolved:
230262
return
@@ -242,7 +274,7 @@ def _ensure_filter(self) -> None:
242274
self._include, self._exclude = _resolve_metric_attribute_filter(attributes)
243275
self._filter_resolved = True
244276

245-
def _filter_attributes(self, attrs: dict) -> dict:
277+
def _filter_attributes(self, attrs: MetricAttributes) -> MetricAttributes:
246278
self._ensure_filter()
247279
if self._include is not None:
248280
return {k: v for k, v in attrs.items() if k in self._include}

tests/test_litellm/integrations/otel/test_otel_v2_metrics.py

Lines changed: 120 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"""
2323

2424
import asyncio
25+
import json
2526
from datetime import datetime, timedelta
2627

2728
import pytest
@@ -68,14 +69,13 @@
6869
TOKEN_TYPE = "gen_ai.token.type"
6970
MODEL_KEY = "gen_ai.request.model"
7071

71-
# Each is a member of VALID_METRIC_ATTRIBUTE_NAMES and is stamped on the metric
72-
# by default (proven by the no-filter test below).
73-
HIGH_CARDINALITY_KEYS = (
72+
# Keys inside the ceiling that an operator's filter must still be able to remove.
73+
# Every one is bounded, so it survives the ceiling and only the operator's own
74+
# exclude_list takes it off; that is what makes the filter tests non-vacuous.
75+
FILTERABLE_KEYS = (
7476
"hidden_params",
7577
"metadata.user_api_key_hash",
76-
"metadata.requester_ip_address",
77-
"metadata.requester_metadata",
78-
"metadata.applied_guardrails",
78+
"metadata.user_api_key_team_id",
7979
)
8080

8181
PROMPT_TOKENS = 137
@@ -103,6 +103,7 @@ def _build_call(stream: bool = True):
103103
"standard_logging_object": {
104104
"metadata": {
105105
"user_api_key_hash": "hash-abc123",
106+
"user_api_key_team_id": "team-1",
106107
"requester_ip_address": "10.0.0.7",
107108
"requester_metadata": {"team": "alpha", "tier": "gold"},
108109
"applied_guardrails": ["pii", "toxicity"],
@@ -215,14 +216,14 @@ def test_metrics_off_by_default_records_nothing():
215216

216217

217218
def test_exclude_list_strips_high_cardinality_across_metrics():
218-
"""exclude_list set AFTER construction (the proxy path) removes every
219-
high-cardinality key from more than one metric while the low-cardinality
220-
model attribute survives."""
219+
"""exclude_list set AFTER construction (the proxy path) removes every listed
220+
key from more than one metric while the low-cardinality model attribute
221+
survives."""
221222
metrics = _drive_success(
222223
InMemoryMetricReader(),
223-
callback_settings_attributes={"exclude_list": list(HIGH_CARDINALITY_KEYS)},
224+
callback_settings_attributes={"exclude_list": list(FILTERABLE_KEYS)},
224225
)
225-
excluded = set(HIGH_CARDINALITY_KEYS)
226+
excluded = set(FILTERABLE_KEYS)
226227

227228
for name in (OPERATION_DURATION, TOKEN_USAGE):
228229
points = metrics[name]
@@ -251,18 +252,103 @@ def test_include_list_allows_only_listed_attributes():
251252
assert set(dp.attributes.keys()) - {TOKEN_TYPE} == allowed
252253

253254

254-
def test_no_filter_keeps_high_cardinality_keys():
255-
"""Backward compatibility: without an attributes config every high-cardinality
256-
key the call carries is still stamped, so the filter tests above prove a real
257-
removal rather than a key that was never present."""
255+
def test_no_filter_still_keeps_the_filterable_keys():
256+
"""Without an attributes config every key the filter tests remove is present,
257+
so those tests prove a real removal rather than a key that was never there."""
258258
metrics = _drive_success(InMemoryMetricReader())
259-
expected = set(HIGH_CARDINALITY_KEYS)
259+
expected = set(FILTERABLE_KEYS)
260260

261261
for name in (OPERATION_DURATION, TOKEN_USAGE):
262262
for dp in metrics[name]:
263263
assert expected.issubset(set(dp.attributes.keys()))
264264

265265

266+
def test_two_calls_differing_only_per_request_share_one_series():
267+
"""The whole point of the ceiling: metric cardinality must not grow with traffic.
268+
269+
Every field here moves on every real request -- the response cost, the call id,
270+
the cache key, the provider's remaining-rate-limit headers -- and each one used
271+
to reach the datapoint inside a single ``hidden_params`` label. A unique label
272+
value is a new time series, so each of the six instruments minted one series per
273+
request, which is both a Grafana Cloud bill proportional to traffic and a
274+
histogram that cannot be aggregated. Identical attribute sets is what "one
275+
series" means to the SDK.
276+
"""
277+
reader = InMemoryMetricReader()
278+
logger = _logger(reader, enable_metrics=True)
279+
280+
for index, cost in enumerate((RESPONSE_COST, RESPONSE_COST * 3)):
281+
kwargs, response_obj, start, end = _build_call()
282+
kwargs["response_cost"] = cost
283+
kwargs["standard_logging_object"]["hidden_params"] = {
284+
"model_id": "m-1",
285+
"api_base": "https://api.openai.com/v1",
286+
"litellm_call_id": f"call-{index}",
287+
"cache_key": f"cache-{index}",
288+
"response_cost": cost,
289+
"litellm_overhead_time_ms": 1.5 + index,
290+
"usage_object": {"prompt_tokens": index, "completion_tokens": index},
291+
"additional_headers": {"x_ratelimit_remaining_requests": 100 - index},
292+
}
293+
asyncio.run(logger.async_log_success_event(kwargs, response_obj, start, end))
294+
295+
for name in ALL_METRICS:
296+
attribute_sets = {
297+
tuple(sorted((k, v) for k, v in dp.attributes.items() if k != TOKEN_TYPE))
298+
for dp in _metrics_by_name(reader)[name]
299+
}
300+
assert len(attribute_sets) == 1, f"{name} split into {len(attribute_sets)} series across 2 requests"
301+
302+
303+
def test_hidden_params_label_carries_only_bounded_deployment_fields():
304+
"""``hidden_params`` survives the ceiling, but only as the deployment identity.
305+
306+
``model_id`` and ``api_base`` are bounded by the router's deployment list and are
307+
what a per-deployment dashboard reads. Everything else in the object is
308+
per-request and belongs on the span, which already carries it.
309+
"""
310+
kwargs, response_obj, start, end = _build_call()
311+
kwargs["standard_logging_object"]["hidden_params"] = {
312+
"model_id": "m-1",
313+
"api_base": "https://api.openai.com/v1",
314+
"litellm_call_id": "abc",
315+
"cache_key": "ck-1",
316+
"response_cost": RESPONSE_COST,
317+
}
318+
reader = InMemoryMetricReader()
319+
logger = _logger(reader, enable_metrics=True)
320+
asyncio.run(logger.async_log_success_event(kwargs, response_obj, start, end))
321+
322+
label = _metrics_by_name(reader)[OPERATION_DURATION][0].attributes["hidden_params"]
323+
assert json.loads(label) == {"model_id": "m-1", "api_base": "https://api.openai.com/v1"}
324+
325+
326+
def test_success_attributes_are_capped_at_the_ceiling():
327+
"""The success path carries exactly the ceiling, no client-supplied attributes.
328+
329+
The fixture deliberately sets every excluded key, so this asserts a real removal
330+
rather than keys that were never present.
331+
"""
332+
kwargs, response_obj, start, end = _build_call()
333+
metadata = kwargs["standard_logging_object"]["metadata"]
334+
metadata.update(
335+
{
336+
"spend_logs_metadata": {"cost_center": "abc"},
337+
"user_api_key_end_user_id": "end-user-1",
338+
"user_api_key_user_email": "someone@example.com",
339+
}
340+
)
341+
reader = InMemoryMetricReader()
342+
logger = _logger(reader, enable_metrics=True)
343+
asyncio.run(logger.async_log_success_event(kwargs, response_obj, start, end))
344+
metrics = _metrics_by_name(reader)
345+
346+
for name in ALL_METRICS:
347+
for dp in metrics[name]:
348+
leaked = set(dp.attributes) - set(BOUNDED_KEYS) - {TOKEN_TYPE}
349+
assert not leaked, f"{name} leaked {leaked}"
350+
351+
266352
def test_metrics_reach_operator_configured_global_provider(monkeypatch):
267353
"""Regression: with no meter provider injected, the six gen_ai.client.*
268354
histograms must record through the operator's globally configured
@@ -353,18 +439,18 @@ def test_token_type_rejected_from_either_list(attributes, monkeypatch):
353439
# caller (so a caller could mint a fresh series per request, and a failure costs
354440
# them no provider spend) or varies per request, or is PII duplicating an id that
355441
# is already on the series.
356-
UNBOUNDED_FAILURE_KEYS = (
357-
"hidden_params",
442+
UNBOUNDED_KEYS = (
358443
"metadata.requester_metadata",
359444
"metadata.requester_ip_address",
360445
"metadata.spend_logs_metadata",
361446
"metadata.user_api_key_end_user_id",
362447
"metadata.user_api_key_user_email",
363448
)
364449

365-
# The exact set a failure datapoint may carry: the operation, plus
366-
# operator-provisioned identity.
367-
BOUNDED_FAILURE_KEYS = (
450+
# The exact set a datapoint may carry on either path: the operation, the
451+
# operator-provisioned identity, and the deployment that served it.
452+
BOUNDED_KEYS = (
453+
"hidden_params",
368454
"gen_ai.operation.name",
369455
"gen_ai.system",
370456
"gen_ai.request.model",
@@ -413,7 +499,11 @@ def _build_failure(
413499
"requester_metadata": {"trace": "caller-supplied-unique-value"},
414500
"spend_logs_metadata": {"ticket": "caller-supplied-unique-value"},
415501
},
416-
"hidden_params": {"litellm_call_id": "abc"},
502+
"hidden_params": {
503+
"litellm_call_id": "abc",
504+
"model_id": "m-1",
505+
"api_base": "https://api.openai.com/v1",
506+
},
417507
}
418508
if error_information is not None:
419509
standard_logging_object["error_information"] = error_information
@@ -519,11 +609,16 @@ def test_failure_attributes_are_a_bounded_allowlist():
519609
succeeded = next(dp for dp in points if ERROR_TYPE not in dp.attributes)
520610
failed = next(dp for dp in points if ERROR_TYPE in dp.attributes)
521611

522-
missing = set(UNBOUNDED_FAILURE_KEYS) - set(succeeded.attributes)
612+
supplied = set(kwargs["standard_logging_object"]["metadata"])
613+
missing = {key for key in UNBOUNDED_KEYS if key.removeprefix("metadata.") not in supplied}
523614
assert not missing, f"fixture never carried {missing}, so the exclusion below proves nothing"
524-
leaked = set(UNBOUNDED_FAILURE_KEYS) & set(failed.attributes)
615+
leaked = set(UNBOUNDED_KEYS) & set(failed.attributes)
525616
assert not leaked, f"failure datapoint leaked unbounded attributes: {leaked}"
526-
assert set(failed.attributes) == set(BOUNDED_FAILURE_KEYS) | {ERROR_TYPE}
617+
assert set(failed.attributes) == set(BOUNDED_KEYS) | {ERROR_TYPE}
618+
assert json.loads(failed.attributes["hidden_params"]) == {
619+
"model_id": "m-1",
620+
"api_base": "https://api.openai.com/v1",
621+
}
527622

528623

529624
def test_operator_filter_can_still_narrow_the_failure_allowlist():

0 commit comments

Comments
 (0)