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