This commit is contained in:
Thomas Wolf 2026-11-06 15:51:02 -05:00 • committed by GitHub
commit 6f18aeb98e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 66 additions and 1 deletions

View file

@ -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)

View file

@ -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",
[