Skip to content

feat(otel): stamp litellm.request.route on the LLM call span - #39698

Merged
yucheng-berri merged 5 commits into
litellm_internal_stagingfrom
litellm_lit_6459_otel_request_route
Sep 5, 2026
Merged

feat(otel): stamp litellm.request.route on the LLM call span#39698
yucheng-berri merged 5 commits into
litellm_internal_stagingfrom
litellm_lit_6459_otel_request_route

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • LLM call spans don't say which proxy endpoint the request hit
  • Filtering LLM spans by route needs a join to the parent SERVER span

How it solves it:

  • Stamp litellm.request.route on the LLM call span
  • The value is read straight off the root SERVER span's own http.route, so the two cannot disagree; SDK calls, which have no server span, omit the key

User Flow

Before: a team using OTel v2 with an APM backend can't tell from the LLM span whether traffic came through /v1/responses or /v1/chat/completions

  1. They send POST https://litellm-domain/v1/responses with {"model": "gpt-4o-mini", "input": "say hi"} and get a 200 with a resp_... id
  2. In their APM they open the trace, click the chat gpt-4o-mini span, and see gen_ai.*, litellm.call_id, litellm.provider.model, cost fields, but nothing naming the route
  3. To split LLM spans by endpoint they have to join each one back to its parent POST /v1/responses span's http.route

After: the same LLM span carries the route directly

  1. They send the same POST https://litellm-domain/v1/responses and get the same 200
  2. The chat gpt-4o-mini span now also shows litellm.request.route: /v1/responses, the same value as the parent span's http.route
  3. They filter or group LLM spans on litellm.request.route with no join

Relevant issues

Linear ticket

Resolves LIT-6459

Pre-Submission checklist

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

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*, make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.). The earlier red integrations / Run tests job was a staging-wide Datadog test failure fixed by fix(datadog_llm_obs): keep guardrail_cost_by_unit on redacted spans #39848; staging is merged in and the job is green again
  • 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).

Behavior changes

Removed / renamed

  • None. The only new public surface is the span attribute itself and context.request_root_http_route(). No auth field, no logging-metadata key, no config, no DB column

Silent regressions to watch

  • RequestIdentity.request_route keeps its meaning and its single reader, but it is now the backstop rather than the primary source: the span's own http.route wins whenever a server span was anchored
  • Off the proxy the attribute is absent rather than empty, so a dashboard filtering on it sees SDK spans drop out of the result set rather than land in an "" bucket

Migration for existing customers

  • None required

Regression tests

  • test_otel_v2_mount.py::test_llm_span_route_is_read_off_the_server_span drives a real FastAPI app through instrument_fastapi_app, the real passthrough hook, and the same create_litellm_proxy_request_started_span call the proxy makes per request, then asserts the resolver returns the SERVER span's exported http.route for a templated route and for a passthrough catch-all. Deleting the anchor capture fails it
  • ::test_server_span_route_survives_the_span_ending pins that the value is still readable after the server span ends, which is when the async close callback runs
  • ::test_blank_route_on_the_server_span_is_omitted and ::test_no_server_span_means_no_route pin the two omission cases
  • test_otel_v2_logger.py::test_llm_call_span_reports_the_server_spans_route plus the _failed_ and _deferred_ variants drive the real open-and-close boundary flow on all three legs (success, failure, and the thread-pool path where the span is created in the close callback)
  • test_otel_v2_sources_of_truth.py::test_llm_span_carries_proxy_request_route pins that the span's route wins over the logged literal, and ::test_llm_span_falls_back_to_the_logged_route_without_a_server_span pins the backstop

Screenshots / Proof of Fix

Two proxies, same config, same scenarios, LITELLM_OTEL_V2=true, OTLP/HTTP into a real Jaeger, real OpenAI calls, four uvicorn workers per side. Left column is the http.route the root SERVER span reports; right is the litellm.request.route the LLM call span reports for the same trace.

before and after, in the Jaeger trace view

Across 7 route classes (/v1/chat/completions incl. streaming, /v1/responses, /v1/messages, /engines/{model:path}/chat/completions, /openai/deployments/{model:path}/chat/completions, /openai/v1/chat/completions, /openai/v1/responses/{id}), decoding the OTLP protobuf on both sides:

leg traces with both spans SERVER and LLM route agree disagree
base 742b4ae405 59 42 17
head 59 59 0

The 17 are all on /engines/{model:path}/chat/completions, where the LLM span spelled the model name while the SERVER span carried the template. The set of attribute keys on the LLM call span is identical between base and head, so nothing else moved.

The same A/B was re-run under five mapper vocabularies plus legacy_compat (60 keys per LLM span, symmetric difference empty, dropped_attributes_count=0), under a paused Postgres, against a blackhole upstream (both sides 408, attribute lands on the ERROR span), through a real stdio MCP server, and with OTEL_PYTHON_FASTAPI_EXCLUDED_URLS covering the route so no SERVER span exists at all. That last leg is the backstop: both sides report /v1/chat/completions off the route the proxy recorded at auth.

Type

🆕 New Feature

Caveats (if any)

Low

  • Key lives only on the LLM call span, not promoted into Baggage for child spans
  • A request rejected at auth emits no LLM call span, so blocked traffic still has to be counted off the SERVER span
  • Whatever the SERVER span reports is what this reports, including the high-cardinality cases: mount._passthrough_span_name_hook rewrites any path whose first segment is a passthrough prefix to the literal path, so /openai/deployments/gpt-4o-mini/... is per-model on both spans. Pre-existing SERVER-span behavior, unchanged here, and the two now agree instead of disagreeing
  • Driving a real stdio MCP server over tools/call produced no LLM call span at all on either side, so this PR changes nothing observable on the MCP path. The SERVER span for a Starlette Mount reports http.route=/mcp, so any LLM span anchored to it would name the mount point rather than the tool
  • A deployment where the FastAPI instrumentation never mounted has no server span to read, and falls back to the route the proxy recorded at auth
  • OTel v1 has the same route divergence on its own inference span and is not touched by this PR
  • Docs row added in BerriAI/litellm-docs docs/observability/opentelemetry_v2.md in docs(otel): document litellm.request.route on the LLM call span litellm-docs#1185

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

Link to Devin session: https://app.devin.ai/sessions/8f826e7e7cf44af999aa5a354f95ef70
Open in Devin Desktop: https://app.devin.ai/desktop/session/8f826e7e7cf44af999aa5a354f95ef70?variant=devin
Requested by: @yucheng-berri

shivamrawat1 and others added 2 commits September 4, 2026 02:29
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@greptile-apps

greptile-apps Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds litellm.request.route to OTel v2 LLM call spans, preferring the anchored SERVER span’s http.route and falling back to the proxy’s recorded route when no server route is available.

  • Adds route propagation through LLM span data and the GenAI attribute mapper.
  • Reads the route from the anchored request-root span, including after that span ends.
  • Covers successful, failed, deferred, templated, passthrough, blank-route, and no-server-span cases.
  • Updates the in-tree OTel implementation README.

Confidence Score: 5/5

The PR appears safe to merge; the current implementation and tests consistently propagate the request route without an established regression.

No new actionable findings remain. The sole previous finding was manually resolved, and the current code contains the requested annotations.

Important Files Changed

Filename Overview
litellm/integrations/otel/plumbing/context.py Adds a guarded resolver for reading a nonblank http.route from the anchored readable SERVER span.
litellm/integrations/otel/model/payloads.py Carries the resolved server route into LLM span data with the logged request route as a backstop.
litellm/integrations/otel/logger.py Resolves the request-root route while constructing the LLM call span data.
litellm/integrations/otel/mappers/genai.py Emits the resolved route as litellm.request.route.
tests/test_litellm/integrations/otel/test_otel_v2_mount.py Exercises real FastAPI instrumentation, route templates, passthrough rewriting, ended spans, and omission cases.
tests/test_litellm/integrations/otel/test_otel_v2_logger.py Verifies route emission across successful, failed, and deferred LLM span lifecycles.
tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py Verifies server-span precedence, metadata fallback, and SDK omission behavior.

Reviews (3): Last reviewed commit: "fix(otel): read litellm.request.route of..." | Re-trigger Greptile

@codspeed-hq

codspeed-hq Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit_6459_otel_request_route (a73dba6) with litellm_internal_staging (df3b8a6)

Open in CodSpeed

@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@shivamrawat1

Copy link
Copy Markdown
Contributor

@greptile review

@yucheng-berri

Copy link
Copy Markdown
Contributor

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

yucheng-berri added a commit that referenced this pull request Sep 4, 2026
The LLM call span took the auth-normalized literal path from logging
metadata, which disagrees with the SERVER span wherever FastAPI matched a
template: on /engines/{model:path}/chat/completions the LLM span spelled the
model name while http.route carried the template, so the two spans grouped
into different buckets and the PR's premise did not hold.

Read the value off the span that already holds it. The request's root SERVER
span is anchored per request for parenting, and its attributes stay readable
after it ends, so request_root_http_route() answers from the async close
callback with the same http.route the SERVER span exports: the route template
on a normal route, the literal path where the passthrough hook rewrote it, and
the mount point on an MCP call. Nothing has to re-derive any of that, so the
two spans cannot drift apart.

The route the proxy recorded at auth stays as the backstop for a deployment
whose FastAPI instrumentation never mounted, where there is no server span to
disagree with. Off the proxy the attribute is omitted rather than empty.
@yucheng-berri
yucheng-berri requested a review from a team September 5, 2026 02:18
@yucheng-berri

Copy link
Copy Markdown
Contributor

@greptileai please review the current head c284488

@yucheng-berri

Copy link
Copy Markdown
Contributor

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit c284488. Configure here.

@yucheng-berri
yucheng-berri enabled auto-merge (squash) September 5, 2026 03:03
@yucheng-berri
yucheng-berri merged commit e7dd524 into litellm_internal_staging Sep 5, 2026
185 checks passed
@yucheng-berri
yucheng-berri deleted the litellm_lit_6459_otel_request_route branch September 5, 2026 03:33
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.

2 participants