From 9d2063a169d419ca6026005c78aa875faf7716ce Mon Sep 17 00:00:00 2001 From: Dhanush Reddy Chilakala Date: Fri, 26 Jun 2026 00:52:33 -0500 Subject: [PATCH] fix(langfuse): start generation span under metadata trace-id context Langfuse derives a trace's identity from the OTEL span-context trace id, but LangfuseOtelLogger only ever wrote metadata.trace_id as a span attribute, so the exported trace kept a random id and metadata.trace_id from extra_body was ignored (#31378). Override _get_span_context to anchor the otherwise-random root span to the normalized trace id derived from existing_trace_id/trace_id, building a non-recording parent context the same way the Langfuse SDK does for trace_context={'trace_id': ...}. Any upstream parent (explicit parent span, traceparent header, active span) still wins, so distributed tracing and existing_trace_id continuation are preserved. Fixes #31378 --- .../integrations/langfuse/langfuse_otel.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/litellm/integrations/langfuse/langfuse_otel.py b/litellm/integrations/langfuse/langfuse_otel.py index 1ba6c27b108..7ebe27d1e3d 100644 --- a/litellm/integrations/langfuse/langfuse_otel.py +++ b/litellm/integrations/langfuse/langfuse_otel.py @@ -18,11 +18,14 @@ from litellm.types.integrations.langfuse_otel import ( from litellm.types.utils import StandardCallbackDynamicParams if TYPE_CHECKING: + from opentelemetry.context import Context as _Context from opentelemetry.trace import Span as _Span Span = Union[_Span, Any] + Context = Union[_Context, Any] else: Span = Any + Context = Any LANGFUSE_CLOUD_EU_ENDPOINT = "https://cloud.langfuse.com/api/public/otel" @@ -101,6 +104,61 @@ class LangfuseOtelLogger(OpenTelemetry): return metadata + @staticmethod + def _effective_langfuse_trace_id(kwargs) -> Optional[str]: + """Return the user-provided Langfuse trace id seed, if any. + + ``existing_trace_id`` continues an existing trace and wins over + ``trace_id``, matching the vanilla Langfuse precedence. + """ + metadata = LangfuseOtelLogger._extract_langfuse_metadata(kwargs) + raw = metadata.get("existing_trace_id") or metadata.get("trace_id") + return raw if isinstance(raw, str) and raw else None + + @staticmethod + def _trace_id_parent_context(otel_trace_id_hex: str) -> "Context": + """Build an OTEL context whose non-recording parent carries + ``otel_trace_id_hex`` so a span started under it inherits that trace id. + + Mirrors how the Langfuse SDK turns ``trace_context={"trace_id": ...}`` + into a context: a non-recording parent with a synthetic span id and the + sampled flag set. + """ + from opentelemetry.sdk.trace.id_generator import RandomIdGenerator + from opentelemetry.trace import ( + NonRecordingSpan, + SpanContext, + TraceFlags, + set_span_in_context, + ) + + span_context = SpanContext( + trace_id=int(otel_trace_id_hex, 16), + span_id=RandomIdGenerator().generate_span_id(), + is_remote=False, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + ) + return set_span_in_context(NonRecordingSpan(span_context)) + + def _get_span_context(self, kwargs, default_span: Optional[Span] = None): + """Honor an explicit Langfuse trace id from request metadata. + + Any upstream parent (explicit parent span, traceparent header, or active + span) still wins, preserving distributed-tracing behavior. Only the + otherwise-random root span is anchored to the normalized trace id so the + Langfuse trace identity matches the caller's ``metadata.trace_id``. + """ + ctx, parent_span = super()._get_span_context(kwargs, default_span=default_span) + if ctx is not None or parent_span is not None: + return ctx, parent_span + + effective_trace_id = self._effective_langfuse_trace_id(kwargs) + if effective_trace_id is None: + return ctx, parent_span + + otel_trace_id = normalize_trace_id_for_otel(effective_trace_id) + return self._trace_id_parent_context(otel_trace_id), None + @staticmethod def _set_metadata_attributes(span: Span, metadata: dict): """Helper to set metadata attributes from mapping."""