From f1fd1c899664803288e94510787358aa34879347 Mon Sep 17 00:00:00 2001 From: yucheng Date: Wed, 16 Sep 2026 07:22:03 +0000 Subject: [PATCH 1/6] fix(proxy): default litellm_trace_id to the OTel server span trace id When the otel callback is enabled and the client sends no trace or session identity, the request now inherits the W3C trace id of the proxy's server span as litellm_trace_id and metadata.trace_id. The missing_session_id policy and SpendLogs then persist that value as session_id, so a trace in the OTel backend and its row in the Logs UI carry the same id. Explicit x-litellm-trace-id, traceparent, body metadata.trace_id and litellm_trace_id keep priority. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/litellm_pre_call_utils.py | 32 +++++++ .../proxy/test_litellm_pre_call_utils.py | 86 +++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 563db811edc..9580cd8bffe 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -108,6 +108,31 @@ def _trace_id_from_traceparent(traceparent: str) -> str | None: return trace_id if trace_id != "0" * 32 else None +def _trace_id_from_otel_span(span: "OtelSpan | None") -> str | None: + if span is None: + return None + span_context: Final = span.get_span_context() + if not span_context.is_valid: + return None + return format(span_context.trace_id, "032x") + + +def add_otel_trace_id_to_request( + data: dict[str, object], _metadata_variable_name: str, parent_otel_span: "OtelSpan | None" +) -> None: + if "litellm_trace_id" in data: + return + metadata: Final = data.get(_metadata_variable_name) + if isinstance(metadata, dict) and metadata.get("trace_id"): + return + trace_id: Final = _trace_id_from_otel_span(parent_otel_span) + if trace_id is None: + return + data["litellm_trace_id"] = trace_id # rebind-ok: data is an out-param + if isinstance(metadata, dict): + metadata["trace_id"] = trace_id + + def _session_id_from_baggage(baggage: str) -> str | None: """Extract a session.id entry from a W3C Baggage header (https://www.w3.org/TR/baggage/), e.g. "session.id=abc-123,user.id=42".""" @@ -173,6 +198,8 @@ _ENABLE_TEAM_STALE_ALIAS_BYPASS: bool | None = None if TYPE_CHECKING: + from opentelemetry.trace import Span as OtelSpan + from litellm.integrations.otel.model.destination import OtelDestination from litellm.proxy.policy_engine.attachment_registry import AttachmentRegistry from litellm.proxy.proxy_server import ProxyConfig as _ProxyConfig @@ -2042,6 +2069,11 @@ async def add_litellm_data_to_request( data=data, _metadata_variable_name=_metadata_variable_name, ) + add_otel_trace_id_to_request( + data=data, + _metadata_variable_name=_metadata_variable_name, + parent_otel_span=user_api_key_dict.parent_otel_span, + ) apply_missing_session_id_policy( data=data, _metadata_variable_name=_metadata_variable_name, diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 099afa57eec..5dc2ef1fc2b 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from botocore.credentials import Credentials from fastapi import Request +from opentelemetry.trace import INVALID_SPAN, NonRecordingSpan, SpanContext from pydantic import ValidationError as PydanticValidationError from starlette.datastructures import Headers @@ -3536,6 +3537,91 @@ def test_add_litellm_metadata_from_request_headers_explicit_trace_id_beats_trace assert data["litellm_session_id"] == "explicit-trace-id-value" +def _otel_span_with_trace_id(trace_id: int) -> NonRecordingSpan: + return NonRecordingSpan(SpanContext(trace_id=trace_id, span_id=0x00F067AA0BA902B7, is_remote=False)) + + +def _request_mock_without_trace_headers() -> MagicMock: + request_mock = MagicMock(spec=Request) + request_mock.url = MagicMock() + request_mock.url.path = "/v1/chat/completions" + request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions" + request_mock.method = "POST" + request_mock.query_params = {} + request_mock.headers = {"Content-Type": "application/json"} + request_mock.client = MagicMock() + request_mock.client.host = "127.0.0.1" + return request_mock + + +@pytest.mark.asyncio +async def test_add_litellm_data_to_request_defaults_trace_id_to_otel_server_span(): + """With OTel on and a client that sends no trace headers, the request's + litellm_trace_id (and so the spend log session_id) must be the W3C trace-id + of the proxy's server span, so a trace in the OTel backend can be looked up + in the Logs UI and vice versa.""" + otel_trace_id = 0x4BF92F3577B34DA6A3CE929D0E0E4736 + user_api_key_dict = UserAPIKeyAuth(api_key="hashed-key", parent_otel_span=_otel_span_with_trace_id(otel_trace_id)) + + data = await add_litellm_data_to_request( + data={"model": "gpt-5.6", "messages": [{"role": "user", "content": "hi"}]}, + request=_request_mock_without_trace_headers(), + user_api_key_dict=user_api_key_dict, + proxy_config=MagicMock(), + general_settings={}, + ) + + assert data["litellm_trace_id"] == format(otel_trace_id, "032x") + assert data["metadata"]["trace_id"] == format(otel_trace_id, "032x") + assert "litellm_session_id" not in data + + +@pytest.mark.asyncio +async def test_add_litellm_data_to_request_otel_span_does_not_override_caller_trace_id(): + """A caller's own trace identity (x-litellm-trace-id header or body + metadata.trace_id) keeps priority over the OTel server span's trace-id.""" + span = _otel_span_with_trace_id(0x4BF92F3577B34DA6A3CE929D0E0E4736) + + header_request = _request_mock_without_trace_headers() + header_request.headers = {"Content-Type": "application/json", "x-litellm-trace-id": "caller-trace"} + from_header = await add_litellm_data_to_request( + data={"model": "gpt-5.6"}, + request=header_request, + user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key", parent_otel_span=span), + proxy_config=MagicMock(), + general_settings={}, + ) + assert from_header["litellm_trace_id"] == "caller-trace" + assert from_header["metadata"]["trace_id"] == "caller-trace" + + from_body = await add_litellm_data_to_request( + data={"model": "gpt-5.6", "metadata": {"trace_id": "body-trace"}}, + request=_request_mock_without_trace_headers(), + user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key", parent_otel_span=span), + proxy_config=MagicMock(), + general_settings={}, + ) + assert "litellm_trace_id" not in from_body + assert from_body["metadata"]["trace_id"] == "body-trace" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("parent_otel_span", [None, "invalid_span"]) +async def test_add_litellm_data_to_request_no_trace_id_without_valid_otel_span(parent_otel_span): + """No OTel span (OTel off) or a span with an invalid context must leave + litellm_trace_id unset so downstream keeps generating its own id.""" + span = INVALID_SPAN if parent_otel_span == "invalid_span" else None + data = await add_litellm_data_to_request( + data={"model": "gpt-5.6"}, + request=_request_mock_without_trace_headers(), + user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key", parent_otel_span=span), + proxy_config=MagicMock(), + general_settings={}, + ) + assert "litellm_trace_id" not in data + assert "trace_id" not in data["metadata"] + + def test_add_litellm_metadata_from_request_headers_anthropic_metadata_beats_baggage(): """The existing Anthropic metadata.user_id session_id path must win over a baggage session.id fallback.""" From 49417d4fa2760896fd1553c0c5edee1e7ee7cf3f Mon Sep 17 00:00:00 2001 From: yucheng Date: Wed, 16 Sep 2026 07:41:48 +0000 Subject: [PATCH 2/6] fix(proxy): ignore non-span parent_otel_span when deriving litellm_trace_id UserAPIKeyAuth.parent_otel_span is Any at runtime (opentelemetry is an optional extra), so the OTel trace-id fallback must only format an int trace id, otherwise an object that merely quacks like a span turns the whole request into a 500 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/litellm_pre_call_utils.py | 5 +++-- tests/test_litellm/proxy/test_litellm_pre_call_utils.py | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 9580cd8bffe..fc81f07c7f2 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -112,9 +112,10 @@ def _trace_id_from_otel_span(span: "OtelSpan | None") -> str | None: if span is None: return None span_context: Final = span.get_span_context() - if not span_context.is_valid: + trace_id: Final = span_context.trace_id + if not span_context.is_valid or not isinstance(trace_id, int): return None - return format(span_context.trace_id, "032x") + return format(trace_id, "032x") def add_otel_trace_id_to_request( diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 5dc2ef1fc2b..81ec555b59c 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -3606,11 +3606,12 @@ async def test_add_litellm_data_to_request_otel_span_does_not_override_caller_tr @pytest.mark.asyncio -@pytest.mark.parametrize("parent_otel_span", [None, "invalid_span"]) +@pytest.mark.parametrize("parent_otel_span", [None, "invalid_span", "not_a_span"]) async def test_add_litellm_data_to_request_no_trace_id_without_valid_otel_span(parent_otel_span): - """No OTel span (OTel off) or a span with an invalid context must leave - litellm_trace_id unset so downstream keeps generating its own id.""" - span = INVALID_SPAN if parent_otel_span == "invalid_span" else None + """No OTel span (OTel off), a span with an invalid context, or an object + that only quacks like a span (auth is typed loosely and often stubbed) must + leave litellm_trace_id unset so downstream keeps generating its own id.""" + span = {"invalid_span": INVALID_SPAN, "not_a_span": MagicMock()}.get(parent_otel_span) data = await add_litellm_data_to_request( data={"model": "gpt-5.6"}, request=_request_mock_without_trace_headers(), From 4cb4493fa7fb953d9713b9050e85a0d9ddb96f36 Mon Sep 17 00:00:00 2001 From: yucheng Date: Wed, 16 Sep 2026 07:48:58 +0000 Subject: [PATCH 3/6] fix(proxy): let the OTel trace id fallback fill a null litellm_trace_id A body that serializes litellm_trace_id as null or an empty string carries no identity, so it must not block the server span fallback. Also mark the nested metadata write as an out-param store Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/litellm_pre_call_utils.py | 4 ++-- .../proxy/test_litellm_pre_call_utils.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index fc81f07c7f2..a1ed2287e7c 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -121,7 +121,7 @@ def _trace_id_from_otel_span(span: "OtelSpan | None") -> str | None: def add_otel_trace_id_to_request( data: dict[str, object], _metadata_variable_name: str, parent_otel_span: "OtelSpan | None" ) -> None: - if "litellm_trace_id" in data: + if data.get("litellm_trace_id"): return metadata: Final = data.get(_metadata_variable_name) if isinstance(metadata, dict) and metadata.get("trace_id"): @@ -131,7 +131,7 @@ def add_otel_trace_id_to_request( return data["litellm_trace_id"] = trace_id # rebind-ok: data is an out-param if isinstance(metadata, dict): - metadata["trace_id"] = trace_id + metadata["trace_id"] = trace_id # rebind-ok: metadata is the request's own out-param dict def _session_id_from_baggage(baggage: str) -> str | None: diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 81ec555b59c..1573845b8e3 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -3605,6 +3605,23 @@ async def test_add_litellm_data_to_request_otel_span_does_not_override_caller_tr assert from_body["metadata"]["trace_id"] == "body-trace" +@pytest.mark.asyncio +@pytest.mark.parametrize("empty_trace_id", [None, ""]) +async def test_add_litellm_data_to_request_otel_span_fills_empty_body_trace_id(empty_trace_id): + """A serialized-but-empty litellm_trace_id in the body (null or "") carries + no identity, so it must not block the OTel server span fallback.""" + otel_trace_id = 0x4BF92F3577B34DA6A3CE929D0E0E4736 + data = await add_litellm_data_to_request( + data={"model": "gpt-5.6", "litellm_trace_id": empty_trace_id}, + request=_request_mock_without_trace_headers(), + user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key", parent_otel_span=_otel_span_with_trace_id(otel_trace_id)), + proxy_config=MagicMock(), + general_settings={}, + ) + assert data["litellm_trace_id"] == format(otel_trace_id, "032x") + assert data["metadata"]["trace_id"] == format(otel_trace_id, "032x") + + @pytest.mark.asyncio @pytest.mark.parametrize("parent_otel_span", [None, "invalid_span", "not_a_span"]) async def test_add_litellm_data_to_request_no_trace_id_without_valid_otel_span(parent_otel_span): From 898fbd37a7dbbfe9d09599ad8aeff9b6e89d96a5 Mon Sep 17 00:00:00 2001 From: yucheng Date: Wed, 16 Sep 2026 08:02:04 +0000 Subject: [PATCH 4/6] test(proxy): mark locals Final in the OTel trace id fallback tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../proxy/test_litellm_pre_call_utils.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 1573845b8e3..5f80588d9f5 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -2814,7 +2814,7 @@ def test_add_headers_to_llm_call_by_model_group_existing_headers_in_data(): litellm.model_group_settings = original_model_group_settings -from typing import Optional +from typing import Final, Optional from fastapi.responses import Response @@ -3542,7 +3542,7 @@ def _otel_span_with_trace_id(trace_id: int) -> NonRecordingSpan: def _request_mock_without_trace_headers() -> MagicMock: - request_mock = MagicMock(spec=Request) + request_mock: Final = MagicMock(spec=Request) request_mock.url = MagicMock() request_mock.url.path = "/v1/chat/completions" request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions" @@ -3560,10 +3560,10 @@ async def test_add_litellm_data_to_request_defaults_trace_id_to_otel_server_span litellm_trace_id (and so the spend log session_id) must be the W3C trace-id of the proxy's server span, so a trace in the OTel backend can be looked up in the Logs UI and vice versa.""" - otel_trace_id = 0x4BF92F3577B34DA6A3CE929D0E0E4736 - user_api_key_dict = UserAPIKeyAuth(api_key="hashed-key", parent_otel_span=_otel_span_with_trace_id(otel_trace_id)) + otel_trace_id: Final = 0x4BF92F3577B34DA6A3CE929D0E0E4736 + user_api_key_dict: Final = UserAPIKeyAuth(api_key="hashed-key", parent_otel_span=_otel_span_with_trace_id(otel_trace_id)) - data = await add_litellm_data_to_request( + data: Final = await add_litellm_data_to_request( data={"model": "gpt-5.6", "messages": [{"role": "user", "content": "hi"}]}, request=_request_mock_without_trace_headers(), user_api_key_dict=user_api_key_dict, @@ -3580,11 +3580,11 @@ async def test_add_litellm_data_to_request_defaults_trace_id_to_otel_server_span async def test_add_litellm_data_to_request_otel_span_does_not_override_caller_trace_id(): """A caller's own trace identity (x-litellm-trace-id header or body metadata.trace_id) keeps priority over the OTel server span's trace-id.""" - span = _otel_span_with_trace_id(0x4BF92F3577B34DA6A3CE929D0E0E4736) + span: Final = _otel_span_with_trace_id(0x4BF92F3577B34DA6A3CE929D0E0E4736) - header_request = _request_mock_without_trace_headers() + header_request: Final = _request_mock_without_trace_headers() header_request.headers = {"Content-Type": "application/json", "x-litellm-trace-id": "caller-trace"} - from_header = await add_litellm_data_to_request( + from_header: Final = await add_litellm_data_to_request( data={"model": "gpt-5.6"}, request=header_request, user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key", parent_otel_span=span), @@ -3594,7 +3594,7 @@ async def test_add_litellm_data_to_request_otel_span_does_not_override_caller_tr assert from_header["litellm_trace_id"] == "caller-trace" assert from_header["metadata"]["trace_id"] == "caller-trace" - from_body = await add_litellm_data_to_request( + from_body: Final = await add_litellm_data_to_request( data={"model": "gpt-5.6", "metadata": {"trace_id": "body-trace"}}, request=_request_mock_without_trace_headers(), user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key", parent_otel_span=span), @@ -3610,8 +3610,8 @@ async def test_add_litellm_data_to_request_otel_span_does_not_override_caller_tr async def test_add_litellm_data_to_request_otel_span_fills_empty_body_trace_id(empty_trace_id): """A serialized-but-empty litellm_trace_id in the body (null or "") carries no identity, so it must not block the OTel server span fallback.""" - otel_trace_id = 0x4BF92F3577B34DA6A3CE929D0E0E4736 - data = await add_litellm_data_to_request( + otel_trace_id: Final = 0x4BF92F3577B34DA6A3CE929D0E0E4736 + data: Final = await add_litellm_data_to_request( data={"model": "gpt-5.6", "litellm_trace_id": empty_trace_id}, request=_request_mock_without_trace_headers(), user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key", parent_otel_span=_otel_span_with_trace_id(otel_trace_id)), @@ -3628,8 +3628,8 @@ async def test_add_litellm_data_to_request_no_trace_id_without_valid_otel_span(p """No OTel span (OTel off), a span with an invalid context, or an object that only quacks like a span (auth is typed loosely and often stubbed) must leave litellm_trace_id unset so downstream keeps generating its own id.""" - span = {"invalid_span": INVALID_SPAN, "not_a_span": MagicMock()}.get(parent_otel_span) - data = await add_litellm_data_to_request( + span: Final = {"invalid_span": INVALID_SPAN, "not_a_span": MagicMock()}.get(parent_otel_span) + data: Final = await add_litellm_data_to_request( data={"model": "gpt-5.6"}, request=_request_mock_without_trace_headers(), user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key", parent_otel_span=span), From 3628025aaeee2d7acbde9fb3da5a976b8df86022 Mon Sep 17 00:00:00 2001 From: yucheng Date: Wed, 16 Sep 2026 08:30:54 +0000 Subject: [PATCH 5/6] fix(proxy): keep caller metadata.trace_id ahead of the OTel fallback on litellm_metadata routes Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/litellm_pre_call_utils.py | 3 +- .../proxy/test_litellm_pre_call_utils.py | 31 +++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index a1ed2287e7c..19f7d075922 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -124,7 +124,8 @@ def add_otel_trace_id_to_request( if data.get("litellm_trace_id"): return metadata: Final = data.get(_metadata_variable_name) - if isinstance(metadata, dict) and metadata.get("trace_id"): + requester_metadata: Final = data.get("metadata") + if any(isinstance(m, dict) and m.get("trace_id") for m in (metadata, requester_metadata)): return trace_id: Final = _trace_id_from_otel_span(parent_otel_span) if trace_id is None: diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 5f80588d9f5..07365500016 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -3561,7 +3561,9 @@ async def test_add_litellm_data_to_request_defaults_trace_id_to_otel_server_span of the proxy's server span, so a trace in the OTel backend can be looked up in the Logs UI and vice versa.""" otel_trace_id: Final = 0x4BF92F3577B34DA6A3CE929D0E0E4736 - user_api_key_dict: Final = UserAPIKeyAuth(api_key="hashed-key", parent_otel_span=_otel_span_with_trace_id(otel_trace_id)) + user_api_key_dict: Final = UserAPIKeyAuth( + api_key="hashed-key", parent_otel_span=_otel_span_with_trace_id(otel_trace_id) + ) data: Final = await add_litellm_data_to_request( data={"model": "gpt-5.6", "messages": [{"role": "user", "content": "hi"}]}, @@ -3605,6 +3607,29 @@ async def test_add_litellm_data_to_request_otel_span_does_not_override_caller_tr assert from_body["metadata"]["trace_id"] == "body-trace" +@pytest.mark.asyncio +@pytest.mark.parametrize("path", ["/v1/responses", "/v1/messages"]) +async def test_add_litellm_data_to_request_otel_span_does_not_override_body_trace_id_on_litellm_metadata_routes(path): + """On routes that keep LiteLLM state in litellm_metadata, the caller's body + metadata.trace_id is only promoted into litellm_metadata later in the + pipeline, so the OTel fallback must look at the requester metadata too or + it would claim the slot first and the caller's id would be lost.""" + request_mock: Final = _request_mock_without_trace_headers() + request_mock.url.path = path + request_mock.url.__str__.return_value = f"http://localhost{path}" + data: Final = await add_litellm_data_to_request( + data={"model": "gpt-5.6", "metadata": {"trace_id": "body-trace"}}, + request=request_mock, + user_api_key_dict=UserAPIKeyAuth( + api_key="hashed-key", parent_otel_span=_otel_span_with_trace_id(0x4BF92F3577B34DA6A3CE929D0E0E4736) + ), + proxy_config=MagicMock(), + general_settings={}, + ) + assert "litellm_trace_id" not in data + assert data["litellm_metadata"]["trace_id"] == "body-trace" + + @pytest.mark.asyncio @pytest.mark.parametrize("empty_trace_id", [None, ""]) async def test_add_litellm_data_to_request_otel_span_fills_empty_body_trace_id(empty_trace_id): @@ -3614,7 +3639,9 @@ async def test_add_litellm_data_to_request_otel_span_fills_empty_body_trace_id(e data: Final = await add_litellm_data_to_request( data={"model": "gpt-5.6", "litellm_trace_id": empty_trace_id}, request=_request_mock_without_trace_headers(), - user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key", parent_otel_span=_otel_span_with_trace_id(otel_trace_id)), + user_api_key_dict=UserAPIKeyAuth( + api_key="hashed-key", parent_otel_span=_otel_span_with_trace_id(otel_trace_id) + ), proxy_config=MagicMock(), general_settings={}, ) From 1b5dacc717ee3d26eeac603b74cf93a75544a540 Mon Sep 17 00:00:00 2001 From: yucheng Date: Wed, 16 Sep 2026 18:05:38 +0000 Subject: [PATCH 6/6] fix(proxy): tolerate malformed auth spans and read the OTel span from request state for custom auth Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/litellm_pre_call_utils.py | 14 +++++-- .../proxy/test_litellm_pre_call_utils.py | 38 ++++++++++++++++--- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 19f7d075922..18f4280c01c 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -111,9 +111,13 @@ def _trace_id_from_traceparent(traceparent: str) -> str | None: def _trace_id_from_otel_span(span: "OtelSpan | None") -> str | None: if span is None: return None - span_context: Final = span.get_span_context() - trace_id: Final = span_context.trace_id - if not span_context.is_valid or not isinstance(trace_id, int): + try: + span_context: Final = span.get_span_context() + is_valid: Final = span_context.is_valid + trace_id: Final = span_context.trace_id + except AttributeError: + return None + if not is_valid or not isinstance(trace_id, int): return None return format(trace_id, "032x") @@ -2074,7 +2078,9 @@ async def add_litellm_data_to_request( add_otel_trace_id_to_request( data=data, _metadata_variable_name=_metadata_variable_name, - parent_otel_span=user_api_key_dict.parent_otel_span, + parent_otel_span=user_api_key_dict.parent_otel_span + if user_api_key_dict.parent_otel_span is not None + else getattr(request.state, "parent_otel_span", None), ) apply_missing_session_id_policy( data=data, diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 07365500016..72668dd3528 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -560,6 +560,7 @@ def _batches_request_mock() -> MagicMock: request_mock.headers = {"Content-Type": "application/json"} request_mock.client = MagicMock() request_mock.client.host = "127.0.0.1" + request_mock.state.parent_otel_span = None return request_mock @@ -3578,6 +3579,28 @@ async def test_add_litellm_data_to_request_defaults_trace_id_to_otel_server_span assert "litellm_session_id" not in data +@pytest.mark.asyncio +async def test_add_litellm_data_to_request_falls_back_to_request_state_otel_span(): + """Custom auth hooks return a UserAPIKeyAuth without parent_otel_span even + though user_api_key_auth already opened the server span on request.state, + so the fallback must read the span from there or custom-auth requests would + keep getting an unrelated session id.""" + otel_trace_id: Final = 0x4BF92F3577B34DA6A3CE929D0E0E4736 + request_mock: Final = _request_mock_without_trace_headers() + request_mock.state.parent_otel_span = _otel_span_with_trace_id(otel_trace_id) + + data: Final = await add_litellm_data_to_request( + data={"model": "gpt-5.6"}, + request=request_mock, + user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key", parent_otel_span=None), + proxy_config=MagicMock(), + general_settings={}, + ) + + assert data["litellm_trace_id"] == format(otel_trace_id, "032x") + assert data["metadata"]["trace_id"] == format(otel_trace_id, "032x") + + @pytest.mark.asyncio async def test_add_litellm_data_to_request_otel_span_does_not_override_caller_trace_id(): """A caller's own trace identity (x-litellm-trace-id header or body @@ -3650,12 +3673,17 @@ async def test_add_litellm_data_to_request_otel_span_fills_empty_body_trace_id(e @pytest.mark.asyncio -@pytest.mark.parametrize("parent_otel_span", [None, "invalid_span", "not_a_span"]) +@pytest.mark.parametrize("parent_otel_span", [None, "invalid_span", "not_a_span", "plain_string"]) async def test_add_litellm_data_to_request_no_trace_id_without_valid_otel_span(parent_otel_span): - """No OTel span (OTel off), a span with an invalid context, or an object - that only quacks like a span (auth is typed loosely and often stubbed) must - leave litellm_trace_id unset so downstream keeps generating its own id.""" - span: Final = {"invalid_span": INVALID_SPAN, "not_a_span": MagicMock()}.get(parent_otel_span) + """No OTel span (OTel off), a span with an invalid context, an object that + only quacks like a span, or a value that is not a span at all (custom auth + is typed loosely and can hand back anything) must leave litellm_trace_id + unset, and never fail the request, so downstream keeps generating its own id.""" + span: Final = { + "invalid_span": INVALID_SPAN, + "not_a_span": MagicMock(), + "plain_string": "not-a-span", + }.get(parent_otel_span) data: Final = await add_litellm_data_to_request( data={"model": "gpt-5.6"}, request=_request_mock_without_trace_headers(),