Skip to content

fix(otel): record the GenAI duration metric on failed requests - #35152

Merged
yassin-berriai merged 3 commits into
litellm_internal_stagingfrom
litellm_otel_failure_path_metrics
Jul 30, 2026
Merged

fix(otel): record the GenAI duration metric on failed requests#35152
yassin-berriai merged 3 commits into
litellm_internal_stagingfrom
litellm_otel_failure_path_metrics

Conversation

@yassin-berriai

@yassin-berriai yassin-berriai commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • OTel metrics were recorded only on the success path
  • gen_ai.client.operation.duration measured only surviving traffic
  • No error dimension existed, so failure-rate panels were unbuildable

How it solves it:

  • A failed call now records that same duration histogram
  • The failure datapoint carries the semconv error.type
  • Failure labels are a bounded allowlist, so failures cannot inflate cardinality
  • Success attributes are unchanged, so current queries keep working

Relevant issues

Linear ticket

Resolves LIT-4955

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

Live proxy on port 4955, no DB, real OpenAI calls against gpt-5.5 costing real money, plus a second deployment pointed at an unreachable api_base for a deterministic upstream failure. OTEL_EXPORTER=console so metric datapoints print; OTEL_ENDPOINT="" (exported empty rather than unset, otherwise load_dotenv restores the real collector endpoint from .env and the datapoints ship off-box instead of printing).

Config used, lit4955_config.yaml. The exclude_list is there to keep the console output readable, and it doubles as proof that the cardinality filter cannot strip error.type:

model_list:
  - model_name: live-openai
    litellm_params:
      model: openai/gpt-5.5
      api_key: os.environ/OPENAI_API_KEY
  - model_name: unreachable
    litellm_params:
      model: openai/gpt-5.5
      api_key: os.environ/OPENAI_API_KEY
      api_base: http://127.0.0.1:14955/v1

litellm_settings:
  callbacks: ["otel"]
  num_retries: 0
  callback_settings:
    otel:
      attributes:
        exclude_list:
          - hidden_params
          - metadata.requester_metadata
          - metadata.requester_ip_address
          - metadata.user_api_key_hash
          - metadata.user_api_key_user_id
          - metadata.applied_guardrails

general_settings:
  master_key: sk-lit4955

Both captures ran these exact commands:

lsof -iTCP:4955 -sTCP:LISTEN   # empty; port is free

set -a; source .env; set +a
export OTEL_ENDPOINT="" OTEL_EXPORTER="console" \
       LITELLM_OTEL_V2=1 LITELLM_OTEL_INTEGRATION_ENABLE_METRICS=1 PYTHONUNBUFFERED=1
python litellm/proxy/proxy_cli.py --config lit4955_config.yaml --port 4955 2>&1 | tee capture.log &

curl -s -o /dev/null -w "success http=%{http_code}\n" -X POST http://127.0.0.1:4955/v1/chat/completions \
  -H 'Authorization: Bearer sk-lit4955' -H 'Content-Type: application/json' \
  -d '{"model":"live-openai","messages":[{"role":"user","content":"say ok"}],"max_completion_tokens":16}'

curl -s -o /dev/null -w "failure http=%{http_code}\n" -X POST http://127.0.0.1:4955/v1/chat/completions \
  -H 'Authorization: Bearer sk-lit4955' -H 'Content-Type: application/json' \
  -d '{"model":"unreachable","messages":[{"role":"user","content":"say ok"}],"max_completion_tokens":16}'

sleep 12   # the metric reader exports on a 5s period

Both runs answered:

success http=200
failure http=500

The console exporter pretty-prints one JSON document per export, so the datapoints of the last export are read out of the log with:

python3 - capture.log <<'PY'
import json, sys
text = open(sys.argv[1]).read()
doc = text[text.rfind('{\n    "resource_metrics"'):]
depth = 0
for j, ch in enumerate(doc):
    depth += (ch == "{") - (ch == "}")
    if depth == 0 and ch == "}":
        break
for rm in json.loads(doc[: j + 1])["resource_metrics"]:
    for sm in rm["scope_metrics"]:
        for m in sm["metrics"]:
            if not m["name"].startswith("gen_ai"):
                continue
            for dp in m["data"]["data_points"]:
                et = dp["attributes"].get("error.type", "<absent>")
                print(f'{m["name"]:34s} error.type={et:20s} count={dp["count"]} sum={dp["sum"]}')
PY

Before, at 551e5d097c (this branch's merge base). The failure contributed to nothing; every series is the success:

gen_ai.client.operation.duration   error.type=<absent>             count=1 sum=1.252323
gen_ai.client.token.usage          error.type=<absent>             count=1 sum=8
gen_ai.client.token.usage          error.type=<absent>             count=1 sum=4
gen_ai.usage.cost                  error.type=<absent>             count=1 sum=0.00016
gen_ai.client.response.duration    error.type=<absent>             count=1 sum=1.2418789863586426

After, at da00073979. The failure adds exactly one datapoint, on the duration histogram only, on its own series:

gen_ai.client.operation.duration   error.type=<absent>             count=1 sum=1.169349
gen_ai.client.operation.duration   error.type=InternalServerError  count=1 sum=0.020567
gen_ai.client.token.usage          error.type=<absent>             count=1 sum=8
gen_ai.client.token.usage          error.type=<absent>             count=1 sum=4
gen_ai.usage.cost                  error.type=<absent>             count=1 sum=0.00016
gen_ai.client.response.duration    error.type=<absent>             count=1 sum=1.1565179824829102

Three things to read off the after capture. error.type is present on the failure series and absent on the success series, so error_type="" still isolates the pre-existing series and error_type!="" is a failure-rate numerator. error.type survived an exclude_list that stripped six other attributes, because it is stamped after the filter. And no other instrument gained a failure series: token usage, cost and response duration each still carry exactly their one success datapoint, no fabricated zeros.

Third capture, at 858f3eab94, for the bounded failure label set. Same proxy, config with the exclude_list removed so nothing hides the difference, and both requests carrying caller-controlled fields ("user": "end-user-42" in the body and an x-litellm-spend-logs-metadata: {"ticket":"unique-per-request-value"} header). Printing the label names per datapoint of gen_ai.client.operation.duration:

error.type=<absent> count=1 labels=12
    gen_ai.framework
    gen_ai.operation.name
    gen_ai.request.model
    gen_ai.system
    hidden_params
    metadata.applied_guardrails
    metadata.requester_ip_address
    metadata.requester_metadata
    metadata.spend_logs_metadata
    metadata.user_api_key_end_user_id
    metadata.user_api_key_hash
    metadata.user_api_key_user_id
error.type=InternalServerError count=1 labels=7
    error.type
    gen_ai.framework
    gen_ai.operation.name
    gen_ai.request.model
    gen_ai.system
    metadata.user_api_key_hash
    metadata.user_api_key_user_id

The success series carries the caller's spend_logs_metadata, requester_metadata, end-user id and the hidden_params blob, which is the pre-existing behavior this PR does not change. The failure series carries none of them: seven labels, all fixed enums or operator-provisioned identity, so a caller cannot mint a series per failed request.

Type

🐛 Bug Fix

Changes

_record_metrics was called only from async_log_success_event, so a failed request contributed to no metric at all. Latency read off gen_ai.client.operation.duration during an incident was the latency of the traffic that happened to succeed, and since no instrument carried an error dimension, a failure-rate panel or a success/failure split per model could not be written at all.

async_log_failure_event now records too, through a new GenAIMetricRecorder.record_failure. The success and failure recordings share the existing best-effort wrapper (renamed to _guarded_record and given a callable, since both need the same "never break the request, surface a bad attribute filter once at ERROR" behavior).

Three decisions worth calling out.

One histogram, not two. A failed operation's duration goes into the same gen_ai.client.operation.duration as a successful one, separated by an attribute rather than by a second instrument. That is what the GenAI semantic conventions specify: error.type is a conditionally-required attribute on that instrument, present only when the operation ends in error. It keeps litellm's series where a consumer of GenAI telemetry looks for them, it makes the histogram's _count the request counter for both outcomes (so no separate error counter is needed to express a failure rate), and it stays separable because the attribute is only ever added on the failure path. Pooling them with no attribute would have been the harmful option, since every current dashboard query would silently start including failures with no way to exclude them.

error.type carries the error. The key is the semconv-owned error.type already declared in semconv.Error and already stamped on failure spans, not a new invented name. The value is the mapped provider exception's class name (error_information.error_class, the same value the span uses), falling back to the provider status code, then to the raw exception's class name, then to the semconv _OTHER. Every one of those is bounded. The exception message is unbounded and never becomes a label; it stays on the span and on the gen_ai.client.operation.exception event, where high cardinality is free. error.type is applied after the cardinality filter, exactly as gen_ai.token.type already is, so an operator's otel.attributes include or exclude list cannot strip the discriminator and silently merge failures back into the success series.

The failure attribute set is a bounded allowlist, not the success set. _common_attributes carries fields the client supplies, chiefly metadata.requester_metadata, along with per-request values such as the hidden_params blob (which holds the provider's response headers). A failed request costs the caller no provider spend, so nothing would rate-limit a caller who put a unique value in a field they control and minted one histogram series per request. A failure datapoint therefore carries exactly:

gen_ai.operation.name, gen_ai.system, gen_ai.request.model, gen_ai.framework, metadata.user_api_key_hash, metadata.user_api_key_alias, metadata.user_api_key_team_id, metadata.user_api_key_team_alias, metadata.user_api_key_org_id, metadata.user_api_key_user_id, and error.type

Every one 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, and the labels still answer the question an operator has during an incident: which team, on which model, is failing and how. Excluded are metadata.requester_metadata, metadata.spend_logs_metadata, metadata.user_api_key_end_user_id (taken from the request's user field), metadata.requester_ip_address, and hidden_params. metadata.user_api_key_user_email is left out as well; it is bounded, but it is PII duplicating the user id that is already on the series. The operator's own otel.attributes filter layers on top of the allowlist, so it can narrow the set further and never widen it.

Duration is the only instrument recorded. The other five describe a completed generation. litellm hands the failure callback response_obj=None by construction, so there is no usage to split into input/output tokens and no completion-token count to divide generation time by, and it zeroes response_cost on failure; recording them would push fabricated zeros into series that dashboards average. Cost and usage recovered from a stream that broke mid-flight are still billed through SpendLogs and still stamped on the span, so nothing a dashboard needs is lost. A synthetic proxy-gate failure log (an auth or rate-limit rejection) records nothing at all, for the same reason it already gets no span: no upstream call happened, so its wall time is not a GenAI operation's duration and would pull the histogram toward the proxy's own latency.

MCP is not covered by this. async_log_failure_event returns early once the MCP tool-call or tools/list span emitters have handled the event, so an MCP request never reaches the recorder on either the success or the failure path. This change gives MCP no error metric, and mcp_errors_total on Grafana's MCP dashboard is still unbuildable; that is LIT-4951 and is deliberately out of scope here.

A sibling PR (#35151, branch litellm_otel_genai_metric_attributes) is reworking attribute construction in the same plumbing/metrics.py and the operation mapping in model/semconv.py for LIT-4954 and LIT-4959, so expect a small overlap there; this diff was kept minimal for that reason.

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

`_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
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Comment thread litellm/integrations/otel/README.md
@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR extends OpenTelemetry GenAI duration metrics to failed upstream requests.

  • Records failed request durations in the existing gen_ai.client.operation.duration histogram.
  • Adds a bounded error.type discriminator and restricts failure attributes to an allowlist.
  • Preserves existing success metrics and skips proxy-gate failures and completion-only instruments.
  • Adds tests covering failure recording, attribute filtering, error classification, and success-series compatibility.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
litellm/integrations/otel/logger.py Routes eligible failure callbacks through the guarded GenAI metric recorder while retaining existing early-return behavior.
litellm/integrations/otel/plumbing/metrics.py Adds failure-duration recording with bounded attributes and layered error-type fallback handling.
tests/test_litellm/integrations/otel/test_otel_v2_metrics.py Adds focused regression coverage for failure metrics, cardinality controls, filtering, and unchanged success attributes.
litellm/integrations/otel/README.md Documents the maintainer-facing behavior of failed-request metric recording.

Reviews (2): Last reviewed commit: "fix(otel): bound the failure metric's at..." | Re-trigger Greptile

Comment thread litellm/integrations/otel/plumbing/metrics.py Outdated
@veria-ai

veria-ai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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.
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

Pushed 858f3eab94, a follow-up commit on top of da00073979.

It acts on the cardinality finding: a failure datapoint no longer reuses the success attribute set, it carries a bounded allowlist of the operation enum, provider, request model, framework, the key/alias/team/org/user identifiers, and error.type. Client-supplied and per-request fields are dropped, since a failed request costs the caller no provider spend and so nothing would rate-limit a caller minting one histogram series per request out of a field they control. Reasoning and the full inclusion/exclusion list are in the updated description, and each inline thread has a reply.

Live re-capture at 858f3eab94, same 4955 proxy against real gpt-5.5, this time with the config's exclude_list removed so nothing hides the difference, and both requests carrying caller-controlled values ("user": "end-user-42" plus an x-litellm-spend-logs-metadata header). Label names per datapoint of gen_ai.client.operation.duration:

error.type=<absent> count=1 labels=12
    gen_ai.framework
    gen_ai.operation.name
    gen_ai.request.model
    gen_ai.system
    hidden_params
    metadata.applied_guardrails
    metadata.requester_ip_address
    metadata.requester_metadata
    metadata.spend_logs_metadata
    metadata.user_api_key_end_user_id
    metadata.user_api_key_hash
    metadata.user_api_key_user_id
error.type=InternalServerError count=1 labels=7
    error.type
    gen_ai.framework
    gen_ai.operation.name
    gen_ai.request.model
    gen_ai.system
    metadata.user_api_key_hash
    metadata.user_api_key_user_id

The success series still carries the caller's spend_logs_metadata, requester_metadata, end-user id and the hidden_params blob, which is pre-existing behavior this PR does not change; the failure series carries none of them.

Two tests cover the allowlist. One drives the same payload through the success path first, asserts those keys really were present there, then asserts the failure datapoint's attribute set exactly, so it is a proven removal and the exact-set form is the guard against refactoring back to _common_attributes. The other asserts an operator exclude_list still narrows the allowlist, so the allowlist is a ceiling rather than a floor. Both were mutation-checked: dropping the allowlist kills the first, and collapsing the two filter layers into one kills the second along with the existing error.type-survives-the-filter test.

@greptileai

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

The misc / Run tests red on this PR is base breakage, not this diff.

The sole failure is tests/test_litellm/interactions/test_openapi_compliance.py::TestRequestCompliance::test_content_schema_uses_discriminator, which asserts a discriminator on the interactions Content union. Nothing in this PR touches interactions.

Evidence it is base:

  • it reproduces locally on a clean tree branched from 551e5d097c with no interactions changes (1 failed, 12 passed in that file, no network or credentials involved)
  • the identical single failure is on four unrelated PRs of mine touching different subsystems, and on an open PR from a different author

Tracked as LIT-4975. Leaving it red rather than pushing a no-op to re-roll, since a re-run will fail the same way until the base is fixed.

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

Root cause found for the misc / Run tests red on this PR, and it is not this diff. Fix is up as #35161.

tests/test_litellm/interactions/test_openapi_compliance.py::TestRequestCompliance::test_content_schema_uses_discriminator fetches Google's Interactions OpenAPI document over the network at run time and required an OpenAPI discriminator on the Content union. Google removed that keyword and now pins type with a const on each variant, so the assertion fails against the live spec on every open PR and will keep failing until the test is changed. Tracked as LIT-4975.

@codspeed-hq

codspeed-hq Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_otel_failure_path_metrics (9a3bf20) with litellm_internal_staging (ae242fd)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (71dfab7) during the generation of this report, so ae242fd was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

…raffic (#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
@yassin-berriai
yassin-berriai enabled auto-merge (squash) July 30, 2026 19:03
@yassin-berriai
yassin-berriai merged commit 8bb8628 into litellm_internal_staging Jul 30, 2026
74 checks passed
@yassin-berriai
yassin-berriai deleted the litellm_otel_failure_path_metrics branch July 30, 2026 19:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants