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>
This commit is contained in:
yucheng 2026-09-14 23:49:16 +00:00
parent 093edce970
commit e6646863de
3 changed files with 44 additions and 3 deletions

View file

@ -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 {}),
}
)

View file

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

View file

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