Skip to content

Commit 8bb8628

Browse files
fix(otel): record the GenAI duration metric on failed requests (#35152)
* feat(otel): record the GenAI duration metric on failed requests `_record_metrics` ran only from `async_log_success_event`, so `gen_ai.client.operation.duration` counted only the requests that worked. Latency read off it during an incident was the latency of the surviving traffic, and with no error dimension anywhere there was no way to build a failure-rate panel or a success/failure split per model. A failed call now records the same duration histogram, tagged with the semconv `error.type` (the mapped provider exception's class name, bounded by construction; the message stays on the span). Success attributes are untouched, so an existing query can still isolate the old series with `error_type=""`. The other five instruments describe a completed generation and are skipped rather than filled with a fabricated zero: litellm hands the failure callback no `response_obj`, so there is no usage to split and no completion-token count, and it zeroes `response_cost` on failure. A proxy-gate rejection (auth / rate limit) records nothing, for the same reason it gets no span; no upstream call happened. `error.type` is stamped after the cardinality filter, like `gen_ai.token.type`, so an `otel.attributes` include/exclude list cannot strip the discriminator and silently merge failures into the success series. Resolves LIT-4955 * fix(otel): bound the failure metric's attribute set The failure datapoint reused the success path's full attribute set, which carries client-supplied fields (`metadata.requester_metadata`, `metadata.spend_logs_metadata`, the end-user id taken from the request's `user` field) and per-request ones (the `hidden_params` blob holding the provider's response headers). A failed request needs no provider spend, so nothing rate-limits a caller who puts a unique value in a field they control and mints one histogram series per request. A failure now carries a bounded allowlist: the operation enum, provider, request model, framework, the key/alias/team/org/user identifiers, and `error.type`. Every entry is a fixed enum or an operator-provisioned identifier, so the failure series count is bounded by the deployment's own key, team and user count while the labels still answer which team on which model is failing and how. The user email is left out as PII duplicating the user id already on the series. The operator's `otel.attributes` filter layers on top, so it narrows the allowlist further and never widens it. * 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 a187cb9 commit 8bb8628

4 files changed

Lines changed: 593 additions & 29 deletions

File tree

litellm/integrations/otel/README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,29 @@ lives in [`plumbing/`](./plumbing):
222222
otherwise the operator's globally configured `MeterProvider` is reused so its
223223
readers/exporters receive them alongside the server metrics, and one is built
224224
and registered as the global only when none is set (mirroring how V2 owns trace
225-
export).
225+
export). A **failed** call records `gen_ai.client.operation.duration` too,
226+
carrying the semconv `error.type` (the mapped provider exception's class name),
227+
so the histogram covers the whole traffic and failure-rate panels are buildable;
228+
the other five instruments describe a completed generation and are skipped
229+
rather than filled with a fabricated zero. `error.type` is stamped after the
230+
cardinality filter, so an `otel.attributes` list cannot merge the failure series
231+
back into the success series. A proxy-gate rejection (auth / rate limit) records
232+
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.
226248
- [`events.py`](./plumbing/events.py) — GenAI client events. Gated on
227249
`enable_events` (`LITELLM_OTEL_INTEGRATION_ENABLE_EVENTS`), a failed LLM call
228250
records the semconv `gen_ai.client.operation.exception` log event at severity

litellm/integrations/otel/logger.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -281,13 +281,29 @@ async def async_log_success_event(self, kwargs, response_obj, start_time, end_ti
281281
self._record_metrics(kwargs, response_obj, start_time, end_time)
282282

283283
def _record_metrics(self, kwargs, response_obj, start_time, end_time) -> None:
284-
"""Record the GenAI metrics for a successful LLM call. Best-effort: a
285-
recording failure (e.g. a malformed payload) must never break the span
286-
close or the request itself."""
284+
"""Record the GenAI metrics for a successful LLM call."""
285+
self._guarded_record(lambda recorder: recorder.record(kwargs, response_obj, start_time, end_time))
286+
287+
def _record_failure_metrics(self, kwargs, start_time, end_time) -> None:
288+
"""Record the GenAI metrics for a failed LLM call, so the duration
289+
histogram covers the whole traffic rather than only what survived.
290+
291+
A synthetic proxy-gate log (auth / rate-limit rejection) is skipped for the
292+
same reason it gets no span: no upstream call happened, so its duration is
293+
not a GenAI operation's duration and would pull the histogram down."""
294+
if LLMCallEvent.from_dict(kwargs).is_no_upstream_call:
295+
return
296+
self._guarded_record(lambda recorder: recorder.record_failure(kwargs, start_time, end_time))
297+
298+
def _guarded_record(self, record: "Callable[[GenAIMetricRecorder], None]") -> None:
299+
"""Run one metric recording. Best-effort: a recording failure (e.g. a
300+
malformed payload) must never break the span close or the request itself. A
301+
misconfigured attribute filter is operator-fixable, so it is surfaced once
302+
at ERROR instead of being swallowed."""
287303
if self._metrics_recorder is None:
288304
return
289305
try:
290-
self._metrics_recorder.record(kwargs, response_obj, start_time, end_time)
306+
record(self._metrics_recorder)
291307
except ValueError as exc:
292308
if not self._metric_filter_error_logged:
293309
verbose_logger.error(
@@ -304,6 +320,7 @@ async def async_log_failure_event(self, kwargs, response_obj, start_time, end_ti
304320
if self._emit_mcp_list_tools(kwargs, start_time, end_time):
305321
return
306322
self._close_llm_call(kwargs, start_time, end_time)
323+
self._record_failure_metrics(kwargs, start_time, end_time)
307324

308325
def _seed_identity_baggage(self, identity: RequestIdentity, model: str | None, context: Context) -> Context:
309326
"""Seed authenticated request-identity Baggage onto ``context`` so the Baggage

litellm/integrations/otel/plumbing/metrics.py

Lines changed: 150 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""GenAI client metrics: the six ``gen_ai.client.*`` histograms plus the
22
recorder that builds attributes, applies the shared cardinality filter, and
3-
records a request's metrics in the success path.
3+
records a request's metrics on both the success and the failure path.
44
55
The instrument names/units/descriptions and the recording + timing math mirror
66
the v1 :mod:`litellm.integrations.opentelemetry` integration so both engines emit
@@ -10,19 +10,20 @@
1010

1111
from dataclasses import dataclass
1212
from datetime import datetime
13-
from typing import Any, 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,
2122
_build_metric_attribute_filter,
2223
_resolve_metric_attribute_filter,
2324
)
2425
from litellm.integrations.otel.model.metadata import time_to_first_chunk_seconds
25-
from litellm.integrations.otel.model.semconv import Metric, resolve_operation
26+
from litellm.integrations.otel.model.semconv import Error, Metric, resolve_operation
2627
from litellm.integrations.otel.model.utils import to_seconds
2728
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
2829

@@ -72,8 +73,82 @@ 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+
80+
ERROR_TYPE_FALLBACK: Final = "_OTHER"
81+
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(
99+
(
100+
"gen_ai.operation.name",
101+
"gen_ai.system",
102+
"gen_ai.request.model",
103+
"gen_ai.framework",
104+
"metadata.user_api_key_hash",
105+
"metadata.user_api_key_alias",
106+
"metadata.user_api_key_team_id",
107+
"metadata.user_api_key_team_alias",
108+
"metadata.user_api_key_org_id",
109+
"metadata.user_api_key_user_id",
110+
"hidden_params",
111+
)
112+
)
113+
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+
127+
128+
def resolve_error_type(kwargs: Mapping[str, Any]) -> str:
129+
"""The ``error.type`` value for a failed request.
130+
131+
Bounded by construction: the mapped provider exception's class name (the same
132+
``error_information.error_class`` the failure span stamps), else the provider
133+
status code, else the raw exception's class name, else ``_OTHER`` — the value
134+
the convention reserves for a failure the instrumentation cannot classify. The
135+
exception *message* is unbounded and never becomes a label; it stays on the
136+
span and its exception event, where high cardinality is free.
137+
"""
138+
std_log = kwargs.get("standard_logging_object")
139+
info = getattr(std_log, "error_information", None) or (std_log or {}).get("error_information") or {}
140+
error_class = info.get("error_class") or info.get("error_code")
141+
if error_class:
142+
return str(error_class)
143+
exception = kwargs.get("exception")
144+
if exception is not None:
145+
return type(exception).__name__
146+
return ERROR_TYPE_FALLBACK
147+
148+
75149
class GenAIMetricRecorder:
76-
"""Records the six GenAI histograms for one successful LLM call.
150+
"""Records the six GenAI histograms for one successful LLM call, and the
151+
duration histogram alone for one failed LLM call (see :meth:`record_failure`).
77152
78153
The cardinality filter is resolved lazily on the first record: the proxy
79154
populates ``callback_settings.otel.attributes`` after the logger is built, so
@@ -96,7 +171,7 @@ def record(
96171
start_time: datetime,
97172
end_time: datetime,
98173
) -> None:
99-
common_attrs = self._filter_attributes(self._common_attributes(kwargs))
174+
common_attrs = self._filter_attributes(self._bounded_attributes(kwargs))
100175
duration_s = (end_time - start_time).total_seconds()
101176

102177
self._metrics.operation_duration.record(duration_s, attributes=common_attrs)
@@ -110,6 +185,38 @@ def record(
110185
self._record_time_per_output_token(kwargs, response_obj, end_time, duration_s, common_attrs)
111186
self._record_response_duration(kwargs, end_time, common_attrs)
112187

188+
def record_failure(
189+
self,
190+
kwargs: Mapping[str, Any],
191+
start_time: datetime,
192+
end_time: datetime,
193+
) -> None:
194+
"""Record the one metric a failed request can honestly report: the
195+
operation's duration, tagged with ``error.type``.
196+
197+
The other five instruments all describe a completed generation and have
198+
nothing to measure here. litellm hands the failure callback no
199+
``response_obj`` at all, so there is no usage to split into input/output
200+
tokens and no completion-token count to divide generation time by; it also
201+
zeroes ``response_cost`` on failure. Recording them anyway would put a
202+
fabricated zero into series that dashboards average.
203+
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.
209+
210+
``error.type`` is stamped after both filters, exactly like
211+
``gen_ai.token.type``, so an operator's include/exclude list cannot strip
212+
the discriminator and silently merge failures back into the success series.
213+
"""
214+
attributes = {
215+
**self._filter_attributes(self._bounded_attributes(kwargs)),
216+
Error.TYPE: resolve_error_type(kwargs),
217+
}
218+
self._metrics.operation_duration.record((end_time - start_time).total_seconds(), attributes=attributes)
219+
113220
# ------------------------------------------------------------------ #
114221
# Attribute building + cardinality filter
115222
# ------------------------------------------------------------------ #
@@ -136,11 +243,25 @@ def _common_attributes(self, kwargs: Mapping[str, Any]) -> dict:
136243
common_attrs[f"metadata.{key}"] = str(value)
137244

138245
hidden_params = getattr(std_log, "hidden_params", None) or (std_log or {}).get("hidden_params", {})
139-
if hidden_params:
140-
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)
141253

142254
return common_attrs
143255

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+
144265
def _ensure_filter(self) -> None:
145266
if self._filter_resolved:
146267
return
@@ -157,8 +278,29 @@ def _ensure_filter(self) -> None:
157278
# without reconstructing the recorder.
158279
self._include, self._exclude = _resolve_metric_attribute_filter(attributes)
159280
self._filter_resolved = True
281+
self._warn_about_metric_ineligible_names()
282+
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+
)
160302

161-
def _filter_attributes(self, attrs: dict) -> dict:
303+
def _filter_attributes(self, attrs: MetricAttributes) -> MetricAttributes:
162304
self._ensure_filter()
163305
if self._include is not None:
164306
return {k: v for k, v in attrs.items() if k in self._include}

0 commit comments

Comments
 (0)