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>
This commit is contained in:
yucheng 2026-09-16 07:22:03 +00:00
parent a8979fe054
commit f1fd1c8996
2 changed files with 118 additions and 0 deletions

View file

@ -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,

View file

@ -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."""