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>
This commit is contained in:
yucheng 2026-09-23 07:39:26 +00:00
parent 98964dca87
commit 0f26ca9984
2 changed files with 60 additions and 17 deletions

View file

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

View file

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