Repository navigation
feat(otel): emit gen_ai.conversation.id from the caller's session id on v2 LLM spans - #42486
Conversation
…on v2 LLM spans Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
|
bugbot run |
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
… generate and read replayed payload session ids Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…i_conversation_id
|
bugbot run |
…e other metadata key survives Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…i_conversation_id
|
bugbot run |
…payload trace id Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
bugbot run |
…fell back to it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
bugbot run |
…ted marker does not survive replay Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
bugbot run |
…ugh a real proxy, sink and postgres 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>
…ssion so shuffled shards do not reboot the proxy per test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…s for model info 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>
|
@greptileai review latest head |
|
bugbot run |
…ing the collector Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
@greptileai review latest head |
|
bugbot run |
…ink appends Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
@greptileai review latest head |
|
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 1923c87. Configure here.
…election to rc/1.103.0 (#43343) * feat(otel): emit gen_ai.conversation.id from the caller's session id on v2 LLM spans (#42486) * feat(otel): emit gen_ai.conversation.id from the caller's session id on v2 LLM spans Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(otel): keep the caller's header session under missing_session_id: generate and read replayed payload session ids Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(otel): drop only the proxy-minted session id so a caller id on the other metadata key survives Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(otel): keep a replayed session id hidden when it only echoes the payload trace id Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(otel): keep a replayed session id even when the payload trace id fell back to it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(otel): stop reading the replayed payload's session id, the generated marker does not survive replay Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): audit gen_ai.conversation.id on otel v2 spans through a real proxy, sink and postgres Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): keep otel conversation rigs alive for the whole session so shuffled shards do not reboot the proxy per test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(otel): stop the audit rig proxies from probing sibling test peers for model info Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(otel): record accepted OTLP batches in the sink instead of mutating the collector Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(otel): guard the accepted batch deque so snapshots cannot race sink appends Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: mrinal <mrinal@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: yucheng <yucheng@berri.ai> (cherry picked from commit a319690) * fix(jwt): accept a team alias in x-litellm-team-id (#42445) * fix(jwt): accept a team alias in x-litellm-team-id The header only matched canonical team ids, so a JWT caller selecting one of their teams by its alias got a 403 even though they belonged to it. The header value is now resolved through the existing alias lookup before the JWT allowed-team check and the DB membership fallback, while a value that is already a team id never costs an alias lookup and denials keep naming the value the caller sent Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(jwt): only alias a header team id the database provably lacks Under fallback_to_db_teams a header value whose team row read fails for any reason other than TeamNotFoundError now keeps the membership denial instead of falling through to the alias lookup, so a degraded read cannot select a different team that carries the value as an alias. Drops the HeaderTeam docstring that only restated its fields Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: ryan <ryan@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> (cherry picked from commit 071cb49) * fix(jwt): say x-litellm-team-id matched no team id or alias in the 403 (#42495) * fix(jwt): say x-litellm-team-id matched no team id or alias in the 403 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(jwt): tell the caller when x-litellm-team-id names an alias shared by several teams Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(jwt): deny a shared x-litellm-team-id alias exactly like an unknown value A distinct 403 for an alias several teams share was raised before the allowed-teams check, so any JWT could probe which aliases exist. The alias lookup now treats the duplicate as a miss, and both denials say the value does not resolve to a team id or a unique team alias, which is true for unknown, unauthorized and duplicate values alike Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: ryan <ryan@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> (cherry picked from commit 08639fc) * fix(jwt): let x-litellm-team-id select DB membership teams when the token also carries a team claim (#43206) * fix(jwt): let x-litellm-team-id select DB membership teams when the token also carries a team claim Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * docs(jwt): describe header team selection under fallback_to_db_teams Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yassin <yassin@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> (cherry picked from commit 7b4fd47) --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: mrinal <mrinal@berri.ai> Co-authored-by: yucheng <yucheng@berri.ai> Co-authored-by: ryan <ryan@berri.ai> Co-authored-by: yassin <yassin@berri.ai>
TLDR
Problem this solves:
gen_ai.conversation.idHow it solves it:
gen_ai.conversation.idon thechat <model>spantests/integration/observabilitysuite (23 cells) that proves it against a real proxyUser Flow
Before: a developer who sends a session id with every turn sees no conversation id on the span, so their tracing backend cannot group the turns
"litellm_session_id": "conv-lit8243-body"in the body (or the same id in alangfuse_session_idorx-litellm-session-idheader)chat.completionobject backchat gpt-5.4-minispan hasgen_ai.request.model,gen_ai.usage.*andlitellm.call_id, but nogen_ai.conversation.id, so each turn shows up as an unrelated spanAfter: the same request produces a span that carries the caller's session id as
gen_ai.conversation.id"litellm_session_id": "conv-lit8243-body"(or the header variants)chat.completionobject backchat gpt-5.4-minispan now hasgen_ai.conversation.id: "conv-lit8243-body", and every turn that reuses the id lands in the same conversation viewgen_ai.conversation.idat all, rather than a made-up oneRelevant issues
Pylon #8891 (customer request for
gen_ai.conversation.idon OTEL v2 spans)Affected release
Linear ticket
Resolves LIT-8381
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 more@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).
Screenshots / Proof of Fix
Deterministic integration audit (
!audit), one run per leg, both legs on a real proxy with--num_workers 2, real Postgres, real Redis, the scripted upstream fromtests/integration/_support/upstream.pyand an owned OTLP/HTTP sink. No provider call, no credential, no mock inside the proxy. Every cell is a test intests/integration/observability/test_otel_conversation_id.py, registered intests/integration/contracts.jsonMerge base (Before):
b395bfefddde142dc650383d71e2612851284545Tip (After):
9b4d3e69f9529b71fd1a56af3a837d7f5e3fc69aRun ids:
base/run7(b395bfe),head/run12andhead/run13(9b4d3e6), all with--hypothesis-seed=4106601 --integration-order-seed=0 -p no:pytest-retry -p no:rerunfailures --timeout=90.diff head/run12/nodes.txt head/run13/nodes.txtis empty: identical collected and passed selections, 0 skipped, 0 retriesMatrix (node ids are
test_otel_conversation_id.py::<name>; base column quotes the failing assertion from base/run7, head column is the run12 and run13 result)litellm_session_idtest_chat_completion_sdk_body_litellm_session_id_lands_as_conversation_idNone == 'conv-53d96769...'x-litellm-session-idtest_chat_stream_async_sdk_x_litellm_session_id_header_lands_as_conversation_idNone == 'conv-79e2ca26...'x-litellm-session-idtest_messages_sdk_x_litellm_session_id_header_lands_as_conversation_idNone == 'conv-a3f038a7...'langfuse_session_idtest_messages_stream_async_sdk_langfuse_session_id_header_lands_as_conversation_idNone == 'conv-5aaf5ee3...'x-litellm-session-idtest_responses_sdk_x_litellm_session_id_header_lands_as_conversation_idNone == 'conv-d84eb402...'metadata.session_idtest_responses_stream_raw_metadata_session_id_lands_as_conversation_idNone == 'conv-e433ccf6...'metadata.session_idtest_chat_raw_metadata_session_id_lands_as_conversation_idNone == 'conv-96ced9b9...'test_integer_and_list_litellm_session_id_match_the_spend_row_or_are_dropped_togetherNone == '123'test_empty_string_litellm_session_id_leaves_the_span_without_a_conversation_idtest_five_kilobyte_session_header_round_trips_to_the_span_and_the_spend_rowNone == 'sss...'test_duplicate_session_header_lands_once_and_unchangedNone == 'conv-a2c2d1e0...'test_unauthenticated_request_with_session_header_is_rejected_and_leaves_no_spantest_sink_rejecting_with_403_drops_those_spans_and_later_spans_still_landNone == 'conv-301cc328...'test_request_without_any_session_input_has_no_conversation_idmissing_session_id: generate, minted id stays off the spantest_generate_policy_minted_session_id_reaches_the_spend_row_but_not_the_spanlangfuse_session_idheadertest_generate_policy_keeps_the_langfuse_session_header_as_conversation_idNone == 'conv-61debc8d...'test_header_body_and_metadata_session_ids_resolve_to_the_same_id_as_the_spend_rowNone == 'conv-header-9f37483e...'test_three_identical_requests_produce_one_span_each_with_the_same_conversation_id(None, None, None) == ('conv-e36bde7e...', ...)metadata.trace_idonly fills the spend row, not the spantest_metadata_trace_id_alone_fills_the_spend_row_but_not_the_span/health/services?service=otelwhile down, exactly once after recoverytest_sink_outage_during_a_mixed_burst_lands_every_response_exactly_once_after_recoverytest_slow_sink_during_a_burst_lands_every_response_exactly_oncetest_killing_one_of_two_workers_mid_burst_keeps_serving_and_never_duplicates_a_spanNone == 'conv-after-kill'test_terminating_the_proxy_right_after_a_burst_flushes_every_span_before_exitEvery cell asserts the complete caller response, the complete request the scripted upstream received, the
LiteLLM_SpendLogsrow and the OTLP span, all matched by response id. The only difference between the legs is the new attribute; the five "unchanged" cells prove the attribute stays absent where no caller id exists on both legsBefore (b395bfe)
Case 1: whole selection on the merge base
git -C /home/ubuntu/repos/litellm_otelconv_base2 rev-parse HEADLITELLM_DISABLE_NO_REDIS_WARNING=true litellm --config <leg cfg> --port 4320 --num_workers 2 --use_v2_migration_resolverthencurl -s -o /dev/null -w '%{http_code}\n' http://localhost:4320/health/readinesspython -m pytest tests/integration/observability/test_otel_conversation_id.py -vv --strict-markers -p no:pytest-retry -p no:rerunfailures --timeout=90 --hypothesis-seed=4106601 --integration-order-seed=0(run id base/run7)Case 2: determinism check
Not applicable on the merge base; the base leg runs once and its 18 failures are the expected shape
After (9b4d3e6)
Case 1: whole selection on the tip
git -C /home/ubuntu/repos/litellm_otelconv_head rev-parse HEADLITELLM_DISABLE_NO_REDIS_WARNING=true litellm --config <leg cfg> --port 4310 --num_workers 2 --use_v2_migration_resolverthencurl -s -o /dev/null -w '%{http_code}\n' http://localhost:4310/health/readinessCase 2: determinism check
diff head/run12/nodes.txt head/run13/nodes.txt && echo IDENTICALtests,failures,errors,skippedattributes of thetestsuiteelement)Type
🆕 New Feature
Caveats (if any)
Medium
litellm_session_id(unless it echoesmetadata.trace_id), then the header or metadatasession_idtrace control. When the proxy minted the body id (litellm_session_id_generated), only that minted value is dropped: the header trace control or a callersession_idon the other metadata key (metadatavslitellm_metadata) still winsStandardLoggingPayload.session_idis never a source, so a payload replayed through/v1/rust_control_plane/logsgets nogen_ai.conversation.ideven when the original request carried a caller session. Replay rebuildslitellm_paramsfrom key metadata only and drops the generated marker, so a minted id and a caller id are indistinguishable there, and hiding both is the side that cannot mislabel a conversation. This is not a change against the default branch, which emits no attribute on replay either, and the replay route emits nochatspan today in any case (it never setsapi_call_start_time)chat <model>LLM span gets the attribute. The Langfuse-compat trace root span keeps its ownsession.id(from the metadatasession_idtrace control only) and is untouchedlitellm_metadata.session_idis lost whenmissing_session_id: generatehas already mintedmetadata.session_id, because the request preprocessing merges the two keys before any callback runs; the resolver handles both keys but this PR does not change that proxy mergemetadata.session_idon a/v1/responsesbody is forwarded to the provider unchanged on both legs (cell H6 asserts the full upstream request); this PR only adds the span attribute and does not strip itLow
litellm_session_idfrom the OTel trace id when the caller sends none, andmissing_session_id: generatemints one. Both are recognised (value equalsmetadata.trace_id, or the generated marker is set) and dropped, so a request without a caller id yields no attribute (cells E1, E2, E6). If a caller deliberately reuses their own trace id as their live session id it is treated as back-filled and droppedlangfuse_session_id,x-litellm-session-id) still wins when the body only carries a back-filled or generated id (cells E3, E4)123and['a', 'b']become the attribute value verbatim and match the spend row (cell S1)/health/services?service=otelanswers 200 while the OTLP sink is down on both legs; it does not probe the exporter. Documented gap, unchanged by this PRtests/e2e/: a real OTLP vendor backend receiving the attribute, and a real provider A/B at this tipFinal Attestation
Link to Devin session: https://app.devin.ai/sessions/ad86be4efa404cbf8373c9f5948d8bf2
Open in Devin Desktop: https://app.devin.ai/desktop/session/ad86be4efa404cbf8373c9f5948d8bf2?variant=devin
Requested by: @yucheng-berri
Note
Medium Risk
Observability-only, but wrong session resolution would mislabel conversations in customer tracing backends; logic is narrow to OTEL v2 span attributes and heavily tested.
Overview
OTEL v2 GenAI LLM spans now stamp
gen_ai.conversation.idwhen the caller supplies a real session id (bodylitellm_session_id, headers likex-litellm-session-id/langfuse_session_id, or metadatasession_id).A new
caller_session_idresolver on the LLM call event picks that id once per call and threads it throughLLMCallSpanDatainto the GenAI mapper. It does not treat proxy-minted ids (missing_session_id: generate), OTel trace_id backfill, orStandardLoggingPayload.session_idon replay as caller conversations, so the attribute stays absent or caller-owned rather than auto-filled.Coverage adds unit cases for precedence and replay, plus a 23-test integration suite against a real proxy, OTLP sink, and spend logs.
Reviewed by Cursor Bugbot for commit 1923c87. Bugbot is set up for automated code reviews on this repo. Configure here.