diff --git a/langfuse/_client/propagation.py b/langfuse/_client/propagation.py index ecf961e4f..358752ef8 100644 --- a/langfuse/_client/propagation.py +++ b/langfuse/_client/propagation.py @@ -5,6 +5,7 @@ propagate to all child spans within the context. """ +import json import re from typing import ( Any, @@ -40,6 +41,7 @@ from langfuse._client.attributes import LangfuseOtelSpanAttributes from langfuse._client.constants import LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT +from langfuse._utils.serializer import EventSerializer from langfuse.logger import langfuse_logger from langfuse.model import PromptClient @@ -153,8 +155,8 @@ def propagate_attributes( within a user session (e.g., a conversation thread, multi-turn interaction). metadata: Additional key-value metadata to propagate to all spans. - Keys must be US-ASCII strings - - Values are coerced to strings - - Coerced values must be ≤200 characters + - Non-string values are serialized as JSON strings, None values are skipped + - Values must be ≤200 characters after serialization - Use for dimensions like internal correlating identifiers - AVOID: large payloads or sensitive data version: Version identfier for parts of your application that are independently versioned, e.g. agents @@ -281,8 +283,9 @@ def propagate_attributes( - **Validation**: Attribute values (user_id, session_id, version, tags, trace_name) must be strings ≤200 characters. Environment must also match Langfuse's environment format: lowercase alphanumeric with optional - hyphens or underscores, must be ≤40 characters, and it must not start with "langfuse". Metadata - values are coerced to strings before the 200 character limit is applied. + hyphens or underscores, must be ≤40 characters, and it must not start with "langfuse". Non-string + metadata values are serialized as JSON before the 200 character limit is + applied, and None values are skipped. Invalid values will be dropped with a warning logged. - **OpenTelemetry**: This uses OpenTelemetry context propagation under the hood, making it compatible with other OTel-instrumented libraries. @@ -393,7 +396,10 @@ def _propagate_attributes( validated_metadata: Dict[str, str] = {} for key, value in metadata_value.items(): - coerced_value = value if isinstance(value, str) else str(value) + if value is None: + continue + + coerced_value = _coerce_metadata_value(value) if _validate_string_value(value=coerced_value, key=f"{metadata_key}.{key}"): validated_metadata[key] = coerced_value @@ -641,6 +647,22 @@ def _validate_propagated_value( return value +def _coerce_metadata_value(value: Any) -> str: + """Coerce a propagated metadata value to a string. + + Strings are kept as they are. Other values are serialized as compact JSON, so + e.g. a list is stored as `["a","b"]` instead of its Python repr `['a', 'b']`. + """ + if isinstance(value, str): + return value + + # ensure_ascii=False: escaping non-ASCII as \uXXXX would count 6 characters + # per character against the 200 character limit + return json.dumps( + value, cls=EventSerializer, separators=(",", ":"), ensure_ascii=False + ) + + def _validate_string_value(*, value: str, key: str) -> bool: if not isinstance(value, str): langfuse_logger.warning( # type: ignore diff --git a/tests/unit/test_propagate_attributes.py b/tests/unit/test_propagate_attributes.py index cdf4f9351..5e46e22cb 100644 --- a/tests/unit/test_propagate_attributes.py +++ b/tests/unit/test_propagate_attributes.py @@ -6,7 +6,7 @@ """ import concurrent.futures -from datetime import datetime +from datetime import datetime, timezone import pytest from opentelemetry.instrumentation.threading import ThreadingInstrumentor @@ -464,7 +464,7 @@ def test_non_string_user_id_dropped(self, langfuse_client, memory_exporter): def test_non_string_metadata_values_coerced( self, langfuse_client, memory_exporter, caplog ): - """Verify non-string metadata values are coerced instead of dropped.""" + """Verify non-string metadata values are coerced to JSON instead of dropped.""" caplog.set_level("WARNING", logger="langfuse") metadata = { @@ -472,6 +472,22 @@ def test_non_string_metadata_values_coerced( "langgraph_triggers": ["branch:agent"], "langgraph_path": ("root", "agent"), "max_search_results": 5, + "ratio": 0.5, + "enabled": True, + "config": {"model": "gpt-4", "retries": 2}, + "started_at": datetime(2024, 1, 1, tzinfo=timezone.utc), + "plain": "it's a string", + } + expected = { + "langgraph_step": "1", + "langgraph_triggers": '["branch:agent"]', + "langgraph_path": '["root","agent"]', + "max_search_results": "5", + "ratio": "0.5", + "enabled": "true", + "config": '{"model":"gpt-4","retries":2}', + "started_at": '"2024-01-01T00:00:00Z"', + "plain": "it's a string", } with langfuse_client.start_as_current_observation(name="parent-span"): @@ -481,15 +497,63 @@ def test_non_string_metadata_values_coerced( child_span = self.get_span_by_name(memory_exporter, "child-span") - for key, value in metadata.items(): + for key, value in expected.items(): self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{key}", - str(value), + value, ) assert "value is not a string. Dropping value." not in caplog.text + def test_non_ascii_coerced_metadata_is_not_escaped( + self, langfuse_client, memory_exporter + ): + """Verify non-ASCII text isn't \\u-escaped, which would inflate the length.""" + # 2 x 90 characters: fits the 200 character limit only without escaping + path = ["ü" * 90, "é" * 90] + + with langfuse_client.start_as_current_observation(name="parent-span"): + with propagate_attributes(metadata={"langgraph_path": path}): + child = langfuse_client.start_observation(name="child-span") + child.end() + + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.langgraph_path", + f'["{"ü" * 90}","{"é" * 90}"]', + ) + + def test_none_metadata_values_are_skipped(self, langfuse_client, memory_exporter): + """Verify None metadata values are skipped instead of stored as a string.""" + with langfuse_client.start_as_current_observation(name="parent-span"): + with propagate_attributes(metadata={"missing": None, "present": "ok"}): + child = langfuse_client.start_observation(name="child-span") + child.end() + + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_missing_attribute( + child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.missing" + ) + self.verify_span_attribute( + child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.present", "ok" + ) + + def test_coerced_metadata_value_over_200_characters_is_dropped( + self, langfuse_client, memory_exporter + ): + """Verify the 200 character limit applies to the JSON-coerced value.""" + with langfuse_client.start_as_current_observation(name="parent-span"): + with propagate_attributes(metadata={"long_list": ["x" * 100] * 2}): + child = langfuse_client.start_observation(name="child-span") + child.end() + + child_span = self.get_span_by_name(memory_exporter, "child-span") + self.verify_missing_attribute( + child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.long_list" + ) + def test_mixed_valid_invalid_metadata(self, langfuse_client, memory_exporter): """Verify mixed valid/invalid metadata - valid entries kept, invalid dropped.""" with langfuse_client.start_as_current_observation(name="parent-span"):