feat(otel): stamp litellm.request.route on the LLM call span - #39698
Conversation
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 EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
Greptile SummaryThis PR adds
Confidence Score: 5/5The 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.
|
| 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
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>
|
@greptile review |
|
bugbot run |
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.
|
@greptileai please review the current head c284488 |
|
bugbot run |
There was a problem hiding this comment.
✅ 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.
…itellm_lit_6459_otel_request_route
TLDR
Problem this solves:
How it solves it:
litellm.request.routeon the LLM call spanhttp.route, so the two cannot disagree; SDK calls, which have no server span, omit the keyUser Flow
Before: a team using OTel v2 with an APM backend can't tell from the LLM span whether traffic came through
/v1/responsesor/v1/chat/completions{"model": "gpt-4o-mini", "input": "say hi"}and get a 200 with aresp_...idchat gpt-4o-minispan, and seegen_ai.*,litellm.call_id,litellm.provider.model, cost fields, but nothing naming the routePOST /v1/responsesspan'shttp.routeAfter: the same LLM span carries the route directly
chat gpt-4o-minispan now also showslitellm.request.route: /v1/responses, the same value as the parent span'shttp.routelitellm.request.routewith no joinRelevant issues
Linear ticket
Resolves LIT-6459
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
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 moreintegrations / Run testsjob 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@greptileaito 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
context.request_root_http_route(). No auth field, no logging-metadata key, no config, no DB columnSilent regressions to watch
RequestIdentity.request_routekeeps its meaning and its single reader, but it is now the backstop rather than the primary source: the span's ownhttp.routewins whenever a server span was anchored""bucketMigration for existing customers
Regression tests
test_otel_v2_mount.py::test_llm_span_route_is_read_off_the_server_spandrives a real FastAPI app throughinstrument_fastapi_app, the real passthrough hook, and the samecreate_litellm_proxy_request_started_spancall the proxy makes per request, then asserts the resolver returns the SERVER span's exportedhttp.routefor a templated route and for a passthrough catch-all. Deleting the anchor capture fails it::test_server_span_route_survives_the_span_endingpins 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_omittedand::test_no_server_span_means_no_routepin the two omission casestest_otel_v2_logger.py::test_llm_call_span_reports_the_server_spans_routeplus 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_routepins that the span's route wins over the logged literal, and::test_llm_span_falls_back_to_the_logged_route_without_a_server_spanpins the backstopScreenshots / 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 thehttp.routethe root SERVER span reports; right is thelitellm.request.routethe LLM call span reports for the same trace.Across 7 route classes (
/v1/chat/completionsincl. 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:742b4ae405The 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 withOTEL_PYTHON_FASTAPI_EXCLUDED_URLScovering the route so no SERVER span exists at all. That last leg is the backstop: both sides report/v1/chat/completionsoff the route the proxy recorded at auth.Type
🆕 New Feature
Caveats (if any)
Low
mount._passthrough_span_name_hookrewrites 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 disagreeingtools/callproduced no LLM call span at all on either side, so this PR changes nothing observable on the MCP path. The SERVER span for a StarletteMountreportshttp.route=/mcp, so any LLM span anchored to it would name the mount point rather than the tooldocs/observability/opentelemetry_v2.mdin docs(otel): document litellm.request.route on the LLM call span litellm-docs#1185Final Attestation
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