diff --git a/litellm/integrations/langfuse/langfuse_otel.py b/litellm/integrations/langfuse/langfuse_otel.py index d94294d5f74..d5e0665f316 100644 --- a/litellm/integrations/langfuse/langfuse_otel.py +++ b/litellm/integrations/langfuse/langfuse_otel.py @@ -267,7 +267,8 @@ class LangfuseOtelLogger(OpenTelemetry): def _set_trace_user_attribute(span: Span, kwargs: Mapping[str, object]) -> None: caller: Final = caller_trace_controls(kwargs).user_id if caller is not None: - safe_set_attribute(span, LangfuseSpanAttributes.TRACE_USER_ID.value, caller) + if caller != LangfuseOtelLogger._mapped_caller_trace_user_id(kwargs): + safe_set_attribute(span, LangfuseSpanAttributes.TRACE_USER_ID.value, caller) return slp: Final[Mapping[str, object]] = as_str_mapping(kwargs.get("standard_logging_object")) or MappingProxyType({}) slp_metadata: Final[Mapping[str, object]] = as_str_mapping(slp.get("metadata")) or MappingProxyType({}) @@ -279,6 +280,13 @@ class LangfuseOtelLogger(OpenTelemetry): if end_user: safe_set_attribute(span, LangfuseSpanAttributes.TRACE_USER_ID.value, str(end_user)) + @staticmethod + def _mapped_caller_trace_user_id(kwargs: Mapping[str, object]) -> str | None: + """The caller trace user that `_set_metadata_attributes` already wrote from the header or `metadata`.""" + request: Final[Mapping[str, object]] = as_str_mapping(kwargs.get("litellm_params")) or MappingProxyType({}) + mapped_sources: Final = {key: request.get(key) for key in ("metadata", "proxy_server_request")} + return caller_trace_controls({"litellm_params": mapped_sources}).user_id + @staticmethod def _get_langfuse_otel_host() -> str | None: """ diff --git a/tests/integration/observability/test_langfuse_otel_identity_edges.py b/tests/integration/observability/test_langfuse_otel_identity_edges.py index 586fb8844d3..e5b51990632 100644 --- a/tests/integration/observability/test_langfuse_otel_identity_edges.py +++ b/tests/integration/observability/test_langfuse_otel_identity_edges.py @@ -150,7 +150,7 @@ def test_langfuse_otel_list_body_user_does_not_crash(gateway: Gateway, tmp_path: _subsequent_request_still_lands(candidate, collector, batches, model) -def test_langfuse_otel_integer_trace_user_id_does_not_crash(gateway: Gateway, tmp_path: Path) -> None: +def test_langfuse_otel_integer_trace_user_id_keeps_its_integer_form(gateway: Gateway, tmp_path: Path) -> None: marker: Final = uuid.uuid4().hex with ( wire_server(lambda request: _upstream_reply_for(request, _marker_from_body(request))) as provider, @@ -163,11 +163,26 @@ def test_langfuse_otel_integer_trace_user_id_does_not_crash(gateway: Gateway, tm assert response.status_code == 200, response.text batches: Final[list[bytes]] = [] attributes: Final = _await_generation(collector, batches, marker) - assert attributes.get("user.id") in ("123", f"end-user-{marker}"), attributes - assert attributes.get("session.id") is None, attributes + assert _identity(attributes) == {"user.id": 123, "session.id": None}, attributes _subsequent_request_still_lands(candidate, collector, batches, model) +def test_langfuse_otel_list_trace_user_id_keeps_its_json_form(gateway: Gateway, tmp_path: Path) -> None: + marker: Final = uuid.uuid4().hex + with ( + wire_server(lambda request: _upstream_reply_for(request, _marker_from_body(request))) as provider, + wire_server(_sink) as collector, + _langfuse_proxy(gateway, tmp_path, collector.url) as candidate, + candidate.scenario() as scenario, + ): + model: Final = scenario.model(model="openai/gpt-4o-mini", api_base=provider.url + "/v1") + response: Final = _end_user_request(candidate, model, marker, metadata={"trace_user_id": ["caller-1"]}) + assert response.status_code == 200, response.text + batches: Final[list[bytes]] = [] + attributes: Final = _await_generation(collector, batches, marker) + assert _identity(attributes) == {"user.id": '["caller-1"]', "session.id": None}, attributes + + def test_langfuse_otel_null_metadata_still_maps_the_end_user(gateway: Gateway, tmp_path: Path) -> None: marker: Final = uuid.uuid4().hex with ( diff --git a/tests/unit/integrations/test_langfuse_otel.py b/tests/unit/integrations/test_langfuse_otel.py index f89b74088fc..2d7241c0fc8 100644 --- a/tests/unit/integrations/test_langfuse_otel.py +++ b/tests/unit/integrations/test_langfuse_otel.py @@ -1137,6 +1137,24 @@ class TestLangfuseOtelTraceIdentity: identity, _ = self._identity(self._kwargs(slp_metadata={"user_api_key_user_id": "internal-1"})) assert identity == {"user.id": None, "session.id": None} + def test_non_string_caller_trace_user_id_keeps_its_metadata_form(self): + listed, _ = self._identity( + self._kwargs( + slp_metadata={"user_api_key_end_user_id": "end-1"}, + request_metadata={"trace_user_id": ["caller-1"]}, + ) + ) + numbered, _ = self._identity( + self._kwargs( + slp_metadata={"user_api_key_end_user_id": "end-1"}, + request_metadata={"trace_user_id": 123}, + ) + ) + assert (listed, numbered) == ( + {"user.id": '["caller-1"]', "session.id": None}, + {"user.id": 123, "session.id": None}, + ) + def test_logged_end_user_lands_in_user_id_without_a_metadata_copy(self): identity, _ = self._identity(self._kwargs(slp_extra={"end_user": "end-1"})) assert identity == {"user.id": "end-1", "session.id": None}