From e6646863deb8087f559970a0d84d3b999f465ea2 Mon Sep 17 00:00:00 2001 From: yucheng Date: Mon, 14 Sep 2026 23:49:16 +0000 Subject: [PATCH] fix(langfuse): propagate trace_metadata as trace-level attributes in v4 v2 wrote trace(metadata=...) onto the trace object. In v4 the trace only carries what the observations propagate, so a continuation request with update_trace_keys=["trace_metadata"] updated the generation's metadata while the trace kept its stale values. Coerce each entry to the SDK's string limit and hand it to propagate_attributes(metadata=...). Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/langfuse/langfuse.py | 18 ++++++++++-- .../completion_with_langfuse_metadata.json | 1 + .../integrations/test_langfuse.py | 28 +++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/litellm/integrations/langfuse/langfuse.py b/litellm/integrations/langfuse/langfuse.py index fda2e098788..01838ed2a1b 100644 --- a/litellm/integrations/langfuse/langfuse.py +++ b/litellm/integrations/langfuse/langfuse.py @@ -210,6 +210,14 @@ def _coerce_propagated_value(value: object) -> str | Sequence[str]: return str(value)[:_PROPAGATED_VALUE_MAX_CHARS] +def _propagated_trace_metadata(value: object) -> Mapping[str, str] | None: + """v2's ``trace(metadata=...)`` took any JSON; v4 propagates one flat string per key.""" + entries: Final = _object_mapping(value) + if not entries: + return None + return MappingProxyType({str(key): str(item)[:_PROPAGATED_VALUE_MAX_CHARS] for key, item in entries.items()}) + + def _trace_attributes_for_propagation(trace_params: Mapping[str, object]) -> Mapping[str, object]: """Trace-level fields in v4 are propagated onto the observations, not set on a trace object. @@ -217,11 +225,15 @@ def _trace_attributes_for_propagation(trace_params: Mapping[str, object]) -> Map warning, and a dropped ``version`` would vanish from the generation too, because ``_generation_attributes`` already stripped it as propagated. """ + trace_metadata: Final = _propagated_trace_metadata(trace_params.get("metadata")) return MappingProxyType( { - propagated: _coerce_propagated_value(trace_params[key]) - for key, propagated in _PROPAGATED_TRACE_KEYS.items() - if trace_params.get(key) is not None + **{ + propagated: _coerce_propagated_value(trace_params[key]) + for key, propagated in _PROPAGATED_TRACE_KEYS.items() + if trace_params.get(key) is not None + }, + **({"metadata": trace_metadata} if trace_metadata is not None else {}), } ) diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_langfuse_metadata.json b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_langfuse_metadata.json index 0847571c667..a979448002d 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_langfuse_metadata.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_langfuse_metadata.json @@ -35,6 +35,7 @@ "cache_read_input_tokens": 0 }, "langfuse.release": "test_trace_release", + "langfuse.trace.metadata.test_key": "test_value", "langfuse.trace.name": "test_trace_name", "langfuse.trace.tags": [ "test_tag", diff --git a/tests/test_litellm/integrations/test_langfuse.py b/tests/test_litellm/integrations/test_langfuse.py index 392944b4624..64c211cad1b 100644 --- a/tests/test_litellm/integrations/test_langfuse.py +++ b/tests/test_litellm/integrations/test_langfuse.py @@ -1879,6 +1879,34 @@ def test_update_trace_keys_from_the_request_body_list_applies_when_enabled(): assert span.attributes["langfuse.release"] == "v1.2.3" +def test_update_trace_keys_trace_metadata_reaches_the_trace_not_just_the_generation(): + """v2 updated the trace object's metadata; v4 has to propagate it as a trace attribute.""" + rig = _steering_logger() + + with patch.object(litellm, "langfuse_enable_update_trace_keys", True): + _, _, span = _emit( + rig, + metadata={ + "existing_trace_id": "trace-1", + "parent_observation_id": "b" * 16, + "update_trace_keys": ["trace_metadata"], + "trace_metadata": {"step": 2, "note": "x" * 300}, + }, + ) + + assert span.attributes["langfuse.trace.metadata.step"] == "2" + assert span.attributes["langfuse.trace.metadata.note"] == "x" * 200 + assert span.attributes["langfuse.observation.metadata.step"] == 2 + + +def test_trace_metadata_is_not_propagated_when_absent(): + rig = _steering_logger() + + _, _, span = _emit(rig, metadata={"trace_name": "plain"}) + + assert not any(key.startswith("langfuse.trace.metadata.") for key in span.attributes or {}) + + def test_update_trace_keys_matches_whole_keys_not_substrings(): rig = _steering_logger()