From 0f26ca998404c1ad8190bc35fb72f7554fb34ec4 Mon Sep 17 00:00:00 2001 From: yucheng Date: Wed, 23 Sep 2026 07:39:26 +0000 Subject: [PATCH] fix(langfuse_otel): leave joined traces untouched by derived trace fields Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../integrations/langfuse/langfuse_otel.py | 32 +++++++------ .../integrations/test_langfuse_otel.py | 45 +++++++++++++++++-- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/litellm/integrations/langfuse/langfuse_otel.py b/litellm/integrations/langfuse/langfuse_otel.py index 38c086d9282..883a95a39c1 100644 --- a/litellm/integrations/langfuse/langfuse_otel.py +++ b/litellm/integrations/langfuse/langfuse_otel.py @@ -132,7 +132,7 @@ class LangfuseOtelLogger(OpenTelemetry): return _extract_output_items(response_obj) or _extract_choices_output(response_obj) @staticmethod - def _trace_tags(kwargs: dict, metadata: dict) -> tuple[str, ...]: + def _trace_tags(kwargs: dict, metadata: dict, derive_defaults: bool = True) -> tuple[str, ...]: """Order-preserving dedupe of caller tags, request tags and langfuse_default_tags expansions.""" import litellm @@ -155,15 +155,16 @@ class LangfuseOtelLogger(OpenTelemetry): expanded: Final = tuple(_default_tag(key) for key in default_tags) if isinstance(default_tags, list) else () candidates: Final = ( - ( - (caller_tags,) - if isinstance(caller_tags, str) - else tuple(tag for tag in caller_tags if isinstance(tag, str)) - if isinstance(caller_tags, list) - else () - ) - + (tuple(tag for tag in request_tags if isinstance(tag, str)) if isinstance(request_tags, list) else ()) + (caller_tags,) + if isinstance(caller_tags, str) + else tuple(tag for tag in caller_tags if isinstance(tag, str)) + if isinstance(caller_tags, list) + else () + ) + ( + (tuple(tag for tag in request_tags if isinstance(tag, str)) if isinstance(request_tags, list) else ()) + tuple(tag for tag in expanded if tag is not None) + if derive_defaults + else () ) return tuple(dict.fromkeys(candidates)) @@ -194,26 +195,29 @@ class LangfuseOtelLogger(OpenTelemetry): metadata: Final = LangfuseOtelLogger._extract_langfuse_metadata(kwargs) LangfuseOtelLogger._set_metadata_attributes(span=span, metadata=metadata) - if metadata.get("trace_name") is None and metadata.get("existing_trace_id") is None: + joins_existing_trace: Final = metadata.get("existing_trace_id") is not None + if metadata.get("trace_name") is None and not joins_existing_trace: safe_set_attribute( span, LangfuseSpanAttributes.TRACE_NAME.value, f"litellm-{kwargs.get('call_type') or 'completion'}", ) - tags: Final = LangfuseOtelLogger._trace_tags(kwargs, metadata) + tags: Final = LangfuseOtelLogger._trace_tags(kwargs, metadata, derive_defaults=not joins_existing_trace) if tags: safe_set_attribute(span, LangfuseSpanAttributes.TAGS.value, json.dumps(list(tags))) input_json: Final = safe_dumps(kwargs.get("messages")) if kwargs.get("messages") else None if input_json is not None: safe_set_attribute(span, LangfuseSpanAttributes.OBSERVATION_INPUT.value, input_json) - safe_set_attribute(span, LangfuseSpanAttributes.TRACE_INPUT.value, input_json) + if not joins_existing_trace: + safe_set_attribute(span, LangfuseSpanAttributes.TRACE_INPUT.value, input_json) output_json: Final = LangfuseOtelLogger._observation_output(response_obj) if output_json is not None: safe_set_attribute(span, LangfuseSpanAttributes.OBSERVATION_OUTPUT.value, output_json) - safe_set_attribute(span, LangfuseSpanAttributes.TRACE_OUTPUT.value, output_json) + if not joins_existing_trace: + safe_set_attribute(span, LangfuseSpanAttributes.TRACE_OUTPUT.value, output_json) @staticmethod def _get_langfuse_otel_host() -> str | None: @@ -430,7 +434,7 @@ def _extract_choices_output(response_obj) -> str | None: output_data: Final = { key: value for key, value in ( - ("role", message.get("role")), + ("role", message.get("role") or None), ("content", message.get("content")), ) if value is not None diff --git a/tests/test_litellm/integrations/test_langfuse_otel.py b/tests/test_litellm/integrations/test_langfuse_otel.py index 9c38ecd4e13..9945355505d 100644 --- a/tests/test_litellm/integrations/test_langfuse_otel.py +++ b/tests/test_litellm/integrations/test_langfuse_otel.py @@ -1012,15 +1012,40 @@ class TestDerivedTraceFields: ) assert attributes["langfuse.trace.name"] == "caller-trace" - def test_existing_trace_id_without_trace_name_emits_no_name(self): + def test_existing_trace_id_leaves_derived_trace_fields_off(self, monkeypatch): + import litellm + from litellm.types.utils import Choices, ModelResponse + + monkeypatch.setattr(litellm, "langfuse_default_tags", ["cache_hit"]) + response_obj = ModelResponse( + id="chatcmpl-joined", + model="gpt-4o", + choices=[ + Choices( + finish_reason="stop", + message={"role": "assistant", "content": "hi"}, + ) + ], + ) attributes = _emitted( { "call_type": "acompletion", - "litellm_params": {"metadata": {"existing_trace_id": "abc123"}}, - } + "messages": [{"role": "user", "content": "hi"}], + "cache_hit": True, + "litellm_params": { + "metadata": {"existing_trace_id": "abc123", "tags": ["mine"]}, + }, + "standard_logging_object": {"request_tags": ["injected"]}, + }, + response_obj, ) assert "langfuse.trace.name" not in attributes assert attributes["langfuse.trace.existing_id"] == "abc123" + assert "langfuse.trace.input" not in attributes + assert "langfuse.trace.output" not in attributes + assert "langfuse.observation.input" in attributes + assert "langfuse.observation.output" in attributes + assert json.loads(attributes["langfuse.trace.tags"]) == ["mine"] def test_responses_api_output_mirrors_observation_output_items(self): from openai.types.responses import ResponseFunctionToolCall @@ -1098,6 +1123,20 @@ class TestDerivedTraceFields: ) assert "langfuse.trace.tags" not in attributes + def test_empty_string_role_is_omitted_from_output(self): + response_obj = { + "id": "chatcmpl-norole", + "choices": [{"message": {"role": "", "content": "hi"}}], + } + attributes = _emitted( + { + "call_type": "acompletion", + "litellm_params": {"metadata": {}}, + }, + response_obj, + ) + assert json.loads(attributes["langfuse.observation.output"]) == {"content": "hi"} + def test_no_messages_leaves_trace_input_unset_but_emits_output(self): from litellm.types.utils import Choices, ModelResponse