Merge pull request #41386 from BerriAI/litellm_otel_trace_correlation

fix(proxy): default litellm_trace_id to the OTel server span trace id
This commit is contained in:
yucheng-berri 2026-09-16 11:58:45 -07:00 committed by GitHub
commit b04d530ecf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 200 additions and 1 deletions

View file

@ -108,6 +108,37 @@ 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
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")
def add_otel_trace_id_to_request(
data: dict[str, object], _metadata_variable_name: str, parent_otel_span: "OtelSpan | None"
) -> None:
if data.get("litellm_trace_id"):
return
metadata: Final = data.get(_metadata_variable_name)
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:
return
data["litellm_trace_id"] = trace_id # rebind-ok: data is an out-param
if isinstance(metadata, dict):
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:
"""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 +204,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 +2075,13 @@ 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
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,
_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
@ -559,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
@ -2813,7 +2815,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
@ -3536,6 +3538,163 @@ 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: 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"
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: Final = 0x4BF92F3577B34DA6A3CE929D0E0E4736
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"}]},
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_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
metadata.trace_id) keeps priority over the OTel server span's trace-id."""
span: Final = _otel_span_with_trace_id(0x4BF92F3577B34DA6A3CE929D0E0E4736)
header_request: Final = _request_mock_without_trace_headers()
header_request.headers = {"Content-Type": "application/json", "x-litellm-trace-id": "caller-trace"}
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),
proxy_config=MagicMock(),
general_settings={},
)
assert from_header["litellm_trace_id"] == "caller-trace"
assert from_header["metadata"]["trace_id"] == "caller-trace"
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),
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("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):
"""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: 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)
),
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", "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, 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(),
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."""