From d47867ac1a25fd415ff5edfaa87d02fd05d33cbc Mon Sep 17 00:00:00 2001 From: Niklas Semmler Date: Wed, 7 Oct 2026 15:53:27 +0200 Subject: [PATCH 1/2] fix(tracing): serialize non-string propagated metadata as JSON propagate_attributes coerced non-string trace metadata values with str(), so lists, tuples and dicts were stored as Python reprs (`['branch:agent']`, `('root', 'agent')`) that are not valid JSON, booleans became `True`, and None became the string `None`. Serialize non-string values as compact JSON instead, and skip None values. Strings are unchanged. The 200 character limit applies to the serialized value. Co-Authored-By: Claude Opus 5.5 (1M context) --- langfuse/_client/propagation.py | 28 ++++++++++--- tests/unit/test_propagate_attributes.py | 53 +++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/langfuse/_client/propagation.py b/langfuse/_client/propagation.py index ecf961e4f..64cecfbb0 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,18 @@ 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 + + return json.dumps(value, cls=EventSerializer, separators=(",", ":")) + + 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..9996c6ac8 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,44 @@ 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_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"): From ad63b3f45bc17e8f4cfaff7953f9657f00730157 Mon Sep 17 00:00:00 2001 From: Niklas Semmler Date: Wed, 7 Oct 2026 16:30:01 +0200 Subject: [PATCH 2/2] fix(tracing): don't escape non-ASCII in coerced propagated metadata json.dumps escapes non-ASCII characters as \uXXXX by default, so a list or dict value with non-ASCII text counted up to 6 characters per character against the 200 character limit and was dropped. Serialize with ensure_ascii=False, which also matches JSON.stringify output in the JS SDK. Co-Authored-By: Claude Opus 5.5 (1M context) --- langfuse/_client/propagation.py | 6 +++++- tests/unit/test_propagate_attributes.py | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/langfuse/_client/propagation.py b/langfuse/_client/propagation.py index 64cecfbb0..358752ef8 100644 --- a/langfuse/_client/propagation.py +++ b/langfuse/_client/propagation.py @@ -656,7 +656,11 @@ def _coerce_metadata_value(value: Any) -> str: if isinstance(value, str): return value - return json.dumps(value, cls=EventSerializer, separators=(",", ":")) + # 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: diff --git a/tests/unit/test_propagate_attributes.py b/tests/unit/test_propagate_attributes.py index 9996c6ac8..5e46e22cb 100644 --- a/tests/unit/test_propagate_attributes.py +++ b/tests/unit/test_propagate_attributes.py @@ -506,6 +506,25 @@ def test_non_string_metadata_values_coerced( 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"):