mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
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:
commit
b04d530ecf
2 changed files with 200 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue