mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
fix(langfuse_otel): use the proxy end user as trace user when the request has no user
The vanilla Langfuse logger sets the trace user from user_api_key_end_user_id. langfuse_otel only had the shared OTel fallback to user_api_key_user_id, so every call that identifies its end user via user_header_mappings or x-litellm-end-user-id (and sends no `user` field) landed on the key owner in Langfuse. A request `user` and an explicit metadata trace_user_id still win. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
60fc121ce8
commit
2366d298a9
2 changed files with 66 additions and 1 deletions
|
|
@ -93,6 +93,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
|
||||
|
|
@ -157,7 +172,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.
|
||||
|
|
@ -305,6 +320,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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
[
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue