Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions litellm/integrations/otel/langfuse_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
from litellm.integrations.otel.mappers.langfuse import (
LANGFUSE_OBSERVATION_INPUT,
LANGFUSE_OBSERVATION_OUTPUT,
LANGFUSE_TRACE_NAME,
LangfuseMapper,
)
from litellm.integrations.otel.model.metadata import caller_trace_name
from litellm.integrations.otel.model.request_io import request_input, response_output, stream_output
from litellm.integrations.otel.model.trace_controls import caller_trace_controls
from litellm.integrations.otel.plumbing.context import request_root_span

if TYPE_CHECKING:
Expand All @@ -18,14 +18,13 @@


class LangfuseOpenTelemetryV2(OpenTelemetryV2):
"""Names the trace from the request. Langfuse reads ``langfuse.trace.name`` off the root observation,
and the proxy's root span is still recording when the LLM call starts."""
"""Stamps the caller's trace controls (name, user, session, tags) on the request. Langfuse reads them off
the root observation, and the proxy's root span is still recording when the LLM call starts."""

def log_pre_api_call(self, model: str, messages: object, kwargs: Mapping[str, object]) -> None:
root: Final = request_root_span()
name: Final = caller_trace_name(kwargs)
if root is not None and root.is_recording() and name is not None:
root.set_attribute(LANGFUSE_TRACE_NAME, name)
if root is not None and root.is_recording():
root.set_attributes(LangfuseMapper.trace_attributes(caller_trace_controls(kwargs)))
super().log_pre_api_call(model, messages, kwargs)


Expand Down
2 changes: 1 addition & 1 deletion litellm/integrations/otel/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@
model_from_request_data,
)
from litellm.integrations.otel.model.payloads import (
GuardrailSpanData,

Check failure

Code scanning / CodeQL

Module-level cyclic import Error

'GuardrailSpanData' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of GuardrailSpanData occurs after the cyclic
import
of litellm.integrations.otel.logger.
'GuardrailSpanData' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of GuardrailSpanData occurs after the cyclic
import
of litellm.integrations.otel.logger.
'GuardrailSpanData' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of GuardrailSpanData occurs after the cyclic
import
of litellm.integrations.otel.logger.
'GuardrailSpanData' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of GuardrailSpanData occurs after the cyclic
import
of litellm.integrations.otel.logger.
LLMCallSpanData,

Check failure

Code scanning / CodeQL

Module-level cyclic import Error

'LLMCallSpanData' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of LLMCallSpanData occurs after the cyclic
import
of litellm.integrations.otel.logger.
'LLMCallSpanData' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of LLMCallSpanData occurs after the cyclic
import
of litellm.integrations.otel.logger.
'LLMCallSpanData' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of LLMCallSpanData occurs after the cyclic
import
of litellm.integrations.otel.logger.
'LLMCallSpanData' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of LLMCallSpanData occurs after the cyclic
import
of litellm.integrations.otel.logger.
MCPListToolsSpanData,

Check failure

Code scanning / CodeQL

Module-level cyclic import Error

'MCPListToolsSpanData' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of MCPListToolsSpanData occurs after the cyclic
import
of litellm.integrations.otel.logger.
'MCPListToolsSpanData' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of MCPListToolsSpanData occurs after the cyclic
import
of litellm.integrations.otel.logger.
'MCPListToolsSpanData' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of MCPListToolsSpanData occurs after the cyclic
import
of litellm.integrations.otel.logger.
'MCPListToolsSpanData' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of MCPListToolsSpanData occurs after the cyclic
import
of litellm.integrations.otel.logger.
MCPToolCallSpanData,

Check failure

Code scanning / CodeQL

Module-level cyclic import Error

'MCPToolCallSpanData' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of MCPToolCallSpanData occurs after the cyclic
import
of litellm.integrations.otel.logger.
'MCPToolCallSpanData' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of MCPToolCallSpanData occurs after the cyclic
import
of litellm.integrations.otel.logger.
'MCPToolCallSpanData' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of MCPToolCallSpanData occurs after the cyclic
import
of litellm.integrations.otel.logger.
'MCPToolCallSpanData' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of MCPToolCallSpanData occurs after the cyclic
import
of litellm.integrations.otel.logger.
ServiceSpanData,

Check failure

Code scanning / CodeQL

Module-level cyclic import Error

'ServiceSpanData' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of ServiceSpanData occurs after the cyclic
import
of litellm.integrations.otel.logger.
'ServiceSpanData' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of ServiceSpanData occurs after the cyclic
import
of litellm.integrations.otel.logger.
'ServiceSpanData' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of ServiceSpanData occurs after the cyclic
import
of litellm.integrations.otel.logger.
'ServiceSpanData' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of ServiceSpanData occurs after the cyclic
import
of litellm.integrations.otel.logger.
SpanError,

Check failure

Code scanning / CodeQL

Module-level cyclic import Error

'SpanError' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of SpanError occurs after the cyclic
import
of litellm.integrations.otel.logger.
'SpanError' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of SpanError occurs after the cyclic
import
of litellm.integrations.otel.logger.
'SpanError' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of SpanError occurs after the cyclic
import
of litellm.integrations.otel.logger.
'SpanError' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of SpanError occurs after the cyclic
import
of litellm.integrations.otel.logger.
is_mcp_list_tools,

Check failure

Code scanning / CodeQL

Module-level cyclic import Error

'is_mcp_list_tools' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of is_mcp_list_tools occurs after the cyclic
import
of litellm.integrations.otel.logger.
'is_mcp_list_tools' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of is_mcp_list_tools occurs after the cyclic
import
of litellm.integrations.otel.logger.
'is_mcp_list_tools' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of is_mcp_list_tools occurs after the cyclic
import
of litellm.integrations.otel.logger.
'is_mcp_list_tools' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of is_mcp_list_tools occurs after the cyclic
import
of litellm.integrations.otel.logger.
is_mcp_tool_call,

Check failure

Code scanning / CodeQL

Module-level cyclic import Error

'is_mcp_tool_call' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of is_mcp_tool_call occurs after the cyclic
import
of litellm.integrations.otel.logger.
'is_mcp_tool_call' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of is_mcp_tool_call occurs after the cyclic
import
of litellm.integrations.otel.logger.
'is_mcp_tool_call' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of is_mcp_tool_call occurs after the cyclic
import
of litellm.integrations.otel.logger.
'is_mcp_tool_call' may not be defined if module
litellm.integrations.otel.model.payloads
is imported before module
litellm.integrations.otel.logger
, as the
definition
of is_mcp_tool_call occurs after the cyclic
import
of litellm.integrations.otel.logger.
)
from litellm.integrations.otel.model.semconv import Error
from litellm.integrations.otel.model.spans import SpanRole, span_role_for_service
Expand Down Expand Up @@ -554,7 +554,7 @@
capture_content=self.config.capture_span_content,
time_to_first_chunk_seconds=call.time_to_first_chunk_seconds,
request_route=request_root_http_route(),
trace_name=call.trace_name,
trace=call.trace,
)
end_time_ns: Final = to_ns(end_time)
if carrier is not None and carrier.span is not None:
Expand Down
27 changes: 23 additions & 4 deletions litellm/integrations/otel/mappers/langfuse.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
``GenAIMapper`` to send canonical + Langfuse-flavored spans simultaneously.

Every attribute is declared as a ``key -> extractor`` table entry (one callable
per mapping operation): ``_LLM_CALL_ATTRS`` for scalars and ``_BLOB_ATTRS`` for
the JSON-serialized payloads. ``_llm_call`` just applies both tables.
per mapping operation): ``_LLM_CALL_ATTRS`` for scalars, ``_TRACE_ATTRS`` for the
caller's trace controls (shared with the root observation), and ``_BLOB_ATTRS`` for
the JSON-serialized payloads. ``_llm_call`` just applies the three tables.
"""

import json
from collections.abc import Callable
from collections.abc import Callable, Mapping
from types import MappingProxyType
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
from typing import Final

from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue, SpanData
Expand All @@ -25,10 +27,14 @@
LLMRequestParams,
LLMUsage,
)
from litellm.integrations.otel.model.trace_controls import TraceControls

LANGFUSE_OBSERVATION_INPUT: Final = "langfuse.observation.input"
LANGFUSE_OBSERVATION_OUTPUT: Final = "langfuse.observation.output"
LANGFUSE_TRACE_NAME: Final = "langfuse.trace.name"
LANGFUSE_TRACE_USER_ID: Final = "user.id"
LANGFUSE_TRACE_SESSION_ID: Final = "session.id"
LANGFUSE_TRACE_TAGS: Final = "langfuse.trace.tags"


class LangfuseMapper:
Expand All @@ -37,11 +43,19 @@
"langfuse.observation.model.name": lambda d: d.request_model or None,
"langfuse.observation.metadata.provider": lambda d: d.provider or None,
"langfuse.observation.id": lambda d: d.identity.call_id or None,
LANGFUSE_TRACE_NAME: lambda d: d.trace_name or None,
"langfuse.trace.metadata.team_id": lambda d: d.identity.team_id or None,
"langfuse.trace.metadata.team_alias": lambda d: d.identity.team_alias or None,
}

_TRACE_ATTRS: Mapping[str, Callable[[TraceControls], AttrValue | None]] = MappingProxyType(
{
LANGFUSE_TRACE_NAME: lambda t: t.name or None,
LANGFUSE_TRACE_USER_ID: lambda t: t.user_id or None,
LANGFUSE_TRACE_SESSION_ID: lambda t: t.session_id or None,
LANGFUSE_TRACE_TAGS: lambda t: t.tags or None,
}
)

# Sub-tables folded into their respective JSON blobs.
_MODEL_PARAMS: dict[str, Callable[[LLMRequestParams], AttrValue | None]] = {
"temperature": lambda rp: rp.temperature,
Expand Down Expand Up @@ -77,9 +91,14 @@
case _:
return {}

@classmethod
def trace_attributes(cls, trace: TraceControls) -> AttributeMap:
return collect(cls._TRACE_ATTRS, trace)

@classmethod
def _llm_call(cls, data: LLMCallSpanData) -> AttributeMap:
return {
**collect(cls._LLM_CALL_ATTRS, data),
**cls.trace_attributes(data.trace),
**collect(cls._BLOB_ATTRS, data),
}
46 changes: 9 additions & 37 deletions litellm/integrations/otel/model/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,12 @@

from litellm.constants import LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL
from litellm.integrations.otel.model.semconv import resolve_operation
from litellm.integrations.otel.model.utils import as_str, to_seconds
from litellm.integrations.otel.model.trace_controls import TraceControls, caller_trace_controls
from litellm.integrations.otel.model.utils import as_str, as_str_mapping, to_seconds

if TYPE_CHECKING:
from litellm.types.utils import StandardLoggingPayload

LANGFUSE_TRACE_NAME_HEADER: Final = "langfuse_trace_name"


@dataclass(frozen=True)
class RequestIdentity:
Expand Down Expand Up @@ -217,7 +216,7 @@ class LLMCallEvent:
# needs to be reasonable for a span that never gets closed (a leak).
provisional_span_name: str
time_to_first_chunk_seconds: float | None
trace_name: str | None
trace: TraceControls

@classmethod
def from_dict(cls, kwargs: Mapping[str, Any]) -> LLMCallEvent:
Expand All @@ -234,30 +233,10 @@ def from_dict(cls, kwargs: Mapping[str, Any]) -> LLMCallEvent:
upstream_started=kwargs.get("api_call_start_time") is not None,
provisional_span_name=f"{operation.value} {model}".strip(),
time_to_first_chunk_seconds=time_to_first_chunk_seconds(kwargs),
trace_name=caller_trace_name(kwargs),
trace=caller_trace_controls(kwargs),
)


def caller_trace_name(kwargs: Mapping[str, object]) -> str | None:
request: Final = _as_str_mapping(kwargs.get("litellm_params"))
if request is None:
return None
proxy_request: Final = _as_str_mapping(request.get("proxy_server_request"))
headers: Final = _as_str_mapping(proxy_request.get("headers")) if proxy_request is not None else None
from_header: Final = as_str(headers.get(LANGFUSE_TRACE_NAME_HEADER)) if headers is not None else None
if from_header:
return from_header
return next(
(
name
for key in ("metadata", "litellm_metadata")
if (metadata := _as_str_mapping(request.get(key))) is not None
and (name := as_str(metadata.get("trace_name")))
),
None,
)


def time_to_first_chunk_seconds(kwargs: Mapping[str, Any]) -> float | None:
"""Seconds from the upstream request being issued (``api_call_start_time``)
to the first streamed chunk (``completion_start_time``); ``None`` for
Expand Down Expand Up @@ -292,15 +271,8 @@ def auth_metadata(payload: StandardLoggingPayload | None, kwargs: Mapping[str, o
)


def _as_str_mapping(value: object) -> Mapping[str, object] | None:
"""A read-only view of ``value`` when it is a mapping, else ``None``."""
if not isinstance(value, Mapping):
return None
return cast("Mapping[str, object]", value) # cast-ok: isinstance-guarded, JSON metadata has str keys


def _string_entries(value: object) -> Mapping[str, str] | None:
entries: Final = _as_str_mapping(value)
entries: Final = as_str_mapping(value)
if entries is None:
return None
typed: Final = MappingProxyType({key: item for key, item in entries.items() if isinstance(item, str)})
Expand All @@ -316,18 +288,18 @@ def _metadata_dicts(
litellm copies it onto ``metadata``, but both are yielded so a route that
populates only one is still covered.
"""
payload_view: Final = _as_str_mapping(payload)
payload_view: Final = as_str_mapping(payload)
if payload_view is not None:
payload_metadata: Final = _as_str_mapping(payload_view.get("metadata"))
payload_metadata: Final = as_str_mapping(payload_view.get("metadata"))
if payload_metadata is not None:
yield payload_metadata
params: Final = _as_str_mapping(kwargs.get("litellm_params"))
params: Final = as_str_mapping(kwargs.get("litellm_params"))
if params is None:
return
yield from (
metadata
for key in ("metadata", "litellm_metadata")
if (metadata := _as_str_mapping(params.get(key))) is not None
if (metadata := as_str_mapping(params.get(key))) is not None
)


Expand Down
12 changes: 5 additions & 7 deletions litellm/integrations/otel/model/payloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,7 @@
from typing import TYPE_CHECKING, ClassVar, Final, cast
from urllib.parse import urlsplit

from litellm.integrations.otel.model.metadata import (
RequestContext,
RequestIdentity,
)
from litellm.integrations.otel.model.metadata import RequestContext, RequestIdentity
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
from litellm.integrations.otel.model.semconv import (
GenAIOperation,
GenAIOutputType,
Expand All @@ -22,6 +19,7 @@
resolve_output_type,
resolve_provider,
)
from litellm.integrations.otel.model.trace_controls import TraceControls
from litellm.integrations.otel.model.utils import (
as_bool,
as_float,
Expand Down Expand Up @@ -387,7 +385,7 @@
output_type: GenAIOutputType | None = None
call_type: str | None = None
request_route: str | None = None
trace_name: str | None = None
trace: TraceControls = field(default_factory=TraceControls)

@classmethod
def from_standard_logging_payload(
Expand All @@ -396,7 +394,7 @@
capture_content: bool = False,
time_to_first_chunk_seconds: float | None = None,
request_route: str | None = None,
trace_name: str | None = None,
trace: TraceControls | None = None,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
) -> LLMCallSpanData:
params: Final = cast(Mapping[str, object], payload.get("model_parameters") or {})
# The single parse of the request's metadata — the request-vs-provider
Expand Down Expand Up @@ -438,7 +436,7 @@
output_type=resolve_output_type(call_type),
call_type=call_type or None,
request_route=request_route or context.identity.request_route,
trace_name=trace_name,
trace=trace or TraceControls(),
)


Expand Down
61 changes: 61 additions & 0 deletions litellm/integrations/otel/model/trace_controls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""The caller's Langfuse trace controls, parsed from the live callback kwargs."""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass
from typing import Final

from pydantic import TypeAdapter, ValidationError

from litellm.integrations.otel.model.utils import as_str, as_str_mapping

LANGFUSE_HEADER_PREFIX: Final = "langfuse_"
_ITEMS: Final = TypeAdapter(tuple[object, ...])


@dataclass(frozen=True, slots=True)
class TraceControls:
"""The caller's trace-level Langfuse controls: ``metadata.trace_name`` / ``trace_user_id`` / ``session_id`` /
``tags`` on the request (SDK or proxy body), with the proxy's ``langfuse_<control>`` headers winning over the
body for the scalar ones. Mutation controls (``trace_id``, ``existing_trace_id``, ``update_trace_keys``) are
deliberately not carried."""

name: str | None = None
user_id: str | None = None
session_id: str | None = None
tags: tuple[str, ...] = ()


def caller_trace_controls(kwargs: Mapping[str, object]) -> TraceControls:
request: Final = as_str_mapping(kwargs.get("litellm_params"))
if request is None:
return TraceControls()
proxy_request: Final = as_str_mapping(request.get("proxy_server_request"))
headers: Final = (as_str_mapping(proxy_request.get("headers")) if proxy_request is not None else None) or {}
bodies: Final = tuple(
metadata
for key in ("metadata", "litellm_metadata")
if (metadata := as_str_mapping(request.get(key))) is not None
)

def scalar(control: str) -> str | None:
from_header: Final = as_str(headers.get(f"{LANGFUSE_HEADER_PREFIX}{control}"))
if from_header:
return from_header
return next((value for body in bodies if (value := as_str(body.get(control)))), None)

return TraceControls(
name=scalar("trace_name"),
user_id=scalar("trace_user_id"),
session_id=scalar("session_id"),
tags=next((tags for body in bodies if (tags := _str_items(body.get("tags")))), ()),
)


def _str_items(value: object) -> tuple[str, ...]:
try:
items: Final = _ITEMS.validate_python(value)
except ValidationError:
return ()
return tuple(item for item in items if isinstance(item, str) and item)
13 changes: 13 additions & 0 deletions litellm/integrations/otel/model/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@
because it delegates to the OTel SDK's own W3C Baggage parser.
"""

from collections.abc import Mapping
from datetime import datetime
from typing import Final

from pydantic import TypeAdapter, ValidationError

_STR_MAPPING: Final = TypeAdapter(Mapping[str, object])


def as_str(value: object) -> str | None:
Expand Down Expand Up @@ -55,6 +61,13 @@ def as_bool(value: object) -> bool | None:
return bool(value)


def as_str_mapping(value: object) -> Mapping[str, object] | None:
try:
return _STR_MAPPING.validate_python(value)
except ValidationError:
return None


def as_str_tuple(value: object) -> tuple[str, ...] | None:
if value is None:
return None
Expand Down
Loading
Loading