diff --git a/litellm/integrations/langfuse/langfuse_otel.py b/litellm/integrations/langfuse/langfuse_otel.py index 0117348da66..4be89e4011d 100644 --- a/litellm/integrations/langfuse/langfuse_otel.py +++ b/litellm/integrations/langfuse/langfuse_otel.py @@ -94,6 +94,21 @@ def _caller_tag_list(value: object) -> tuple[str, ...]: return () +def _end_user_without_request_user(kwargs: Mapping[str, object]) -> str | None: + """The proxy end-user id (header mapping, ``x-litellm-end-user-id``, ...) when the request carries no ``user``. + + The vanilla Langfuse logger uses this id as the trace user; the shared OTel attributes only fall back to the + key owner's ``user_api_key_user_id``, so without this every such call lands on one Langfuse user.""" + payload: Final = as_str_mapping(kwargs.get("standard_logging_object")) or {} + metadata: Final = as_str_mapping(payload.get("metadata")) or {} + end_user: Final = metadata.get("user_api_key_end_user_id") + if not end_user: + return None + optional_params: Final = as_str_mapping(kwargs.get("optional_params")) or {} + model_params: Final = as_str_mapping(payload.get("model_parameters")) or {} + return None if optional_params.get("user") or model_params.get("user") else str(end_user) + + def _parent_of(span: object) -> trace.SpanContext | None: parent: Final = getattr(span, "parent", None) return parent if isinstance(parent, trace.SpanContext) else None @@ -158,7 +173,7 @@ class LangfuseOtelLogger(OpenTelemetry): super().__init__(config=config, *args, **kwargs) @staticmethod - def set_langfuse_otel_attributes(span: Span, kwargs, response_obj): + def set_langfuse_otel_attributes(span: Span, kwargs: Mapping[str, object], response_obj: object) -> None: """ Sets OpenTelemetry span attributes for Langfuse observability. Uses the same attribute setting logic as Arize Phoenix for consistency. @@ -306,6 +321,11 @@ class LangfuseOtelLogger(OpenTelemetry): langfuse_environment, ) + # Before the metadata mapping, so an explicit `trace_user_id` still wins. + end_user: Final = _end_user_without_request_user(kwargs) + if end_user is not None: + safe_set_attribute(span, LangfuseSpanAttributes.TRACE_USER_ID.value, end_user) + metadata: Final = LangfuseOtelLogger._extract_langfuse_metadata(kwargs) LangfuseOtelLogger._set_metadata_attributes(span=span, metadata=metadata) diff --git a/tests/unit/integrations/test_langfuse_otel.py b/tests/unit/integrations/test_langfuse_otel.py index 71087b4de51..3c10414f4bb 100644 --- a/tests/unit/integrations/test_langfuse_otel.py +++ b/tests/unit/integrations/test_langfuse_otel.py @@ -311,6 +311,51 @@ class TestLangfuseOtelIntegration: actual == expected ), "Mismatch between expected and actual OTEL attribute mapping." + @pytest.mark.parametrize( + "end_user, optional_params, metadata, expected_user", + [ + ("agent-a", {}, {}, "agent-a"), + ("agent-a", {"user": "body-user"}, {}, "body-user"), + ("agent-a", {}, {"trace_user_id": "explicit-user"}, "explicit-user"), + (None, {}, {}, "default_user_id"), + ], + ids=[ + "end-user-replaces-key-owner-fallback", + "request-user-still-wins", + "explicit-trace-user-id-still-wins", + "without-end-user-key-owner-fallback-unchanged", + ], + ) + def test_trace_user_falls_back_to_end_user( + self, + end_user: str | None, + optional_params: dict[str, str], + metadata: dict[str, str], + expected_user: str, + ) -> None: + """Like the vanilla Langfuse logger, the proxy end user (e.g. from `user_header_mappings`) is the trace user + when the request carries no `user`, instead of the key owner's `user_api_key_user_id`.""" + from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes + + exporter: Final = InMemorySpanExporter() + provider: Final = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + span: Final = provider.get_tracer(__name__).start_span("litellm_request") + logging_metadata: Final[dict[str, str]] = {"user_api_key_user_id": "default_user_id"} + if end_user is not None: + logging_metadata["user_api_key_end_user_id"] = end_user + kwargs: Final[dict[str, object]] = { + "optional_params": optional_params, + "litellm_params": {"metadata": metadata}, + "standard_logging_object": {"metadata": logging_metadata, "model_parameters": optional_params}, + } + + LangfuseOtelLogger.set_langfuse_otel_attributes(span, kwargs, None) + span.end() + + attributes: Final = exporter.get_finished_spans()[0].attributes or {} + assert attributes.get(LangfuseSpanAttributes.TRACE_USER_ID.value) == expected_user + @pytest.mark.parametrize( "metadata, expected_version", [