diff --git a/langfuse/_client/propagation.py b/langfuse/_client/propagation.py index ecf961e4f..20aefe7f1 100644 --- a/langfuse/_client/propagation.py +++ b/langfuse/_client/propagation.py @@ -5,6 +5,7 @@ propagate to all child spans within the context. """ +import math 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 @@ -281,8 +283,11 @@ 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 like JavaScript's `JSON.stringify` + (compact separators, non-ASCII kept as is, None becomes "null", + integers keep their exact digits) before the 200 character limit is + applied. Values containing NaN or Infinity are dropped. 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,10 +398,21 @@ 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) + serialized_value = _serialize_propagated_metadata_value(value) + + if serialized_value is None: + langfuse_logger.warning( + "Propagated attribute '%s.%s' contains NaN or Infinity, which " + "is not valid JSON. Dropping value.", + metadata_key, + key, + ) + continue - if _validate_string_value(value=coerced_value, key=f"{metadata_key}.{key}"): - validated_metadata[key] = coerced_value + if _validate_string_value( + value=serialized_value, key=f"{metadata_key}.{key}" + ): + validated_metadata[key] = serialized_value if validated_metadata: context = _set_propagated_attribute( @@ -641,6 +657,41 @@ def _validate_propagated_value( return value +class _PropagatedMetadataSerializer(EventSerializer): + """EventSerializer variant that matches the JS SDK for propagated metadata. + + Integers keep their exact digits as JSON numbers at any depth, and values + containing NaN or Infinity are flagged so the caller can drop them. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.found_non_finite_number = False + + def default(self, obj: Any) -> Any: + if isinstance(obj, int) and not isinstance(obj, bool): + return obj + + if isinstance(obj, float) and not math.isfinite(obj): + self.found_non_finite_number = True + return None + + return super().default(obj) + + +def _serialize_propagated_metadata_value(value: Any) -> Optional[str]: + """Serialize like JSON.stringify in the JS SDK; None means drop the value.""" + if isinstance(value, str): + return value + + serializer = _PropagatedMetadataSerializer( + separators=(",", ":"), ensure_ascii=False + ) + serialized = serializer.encode(value) + + return None if serializer.found_non_finite_number else serialized + + 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..2330269f0 100644 --- a/tests/unit/test_propagate_attributes.py +++ b/tests/unit/test_propagate_attributes.py @@ -461,10 +461,10 @@ def test_non_string_user_id_dropped(self, langfuse_client, memory_exporter): child_span, LangfuseOtelSpanAttributes.TRACE_USER_ID ) - def test_non_string_metadata_values_coerced( + def test_non_string_metadata_values_json_serialized( self, langfuse_client, memory_exporter, caplog ): - """Verify non-string metadata values are coerced instead of dropped.""" + """Verify non-string metadata values are JSON-serialized instead of dropped.""" caplog.set_level("WARNING", logger="langfuse") metadata = { @@ -472,6 +472,23 @@ def test_non_string_metadata_values_coerced( "langgraph_triggers": ["branch:agent"], "langgraph_path": ("root", "agent"), "max_search_results": 5, + "is_cached": True, + "ratio": 0.5, + "config": {"model": "gpt-4o", "nested": {"b": [1, None], "a": "ü"}}, + "label": ["Läufe", "🚀"], + "empty": None, + } + # Byte-identical to JSON.stringify in the JS SDK for the same values. + expected = { + "langgraph_step": "1", + "langgraph_triggers": '["branch:agent"]', + "langgraph_path": '["root","agent"]', + "max_search_results": "5", + "is_cached": "true", + "ratio": "0.5", + "config": '{"model":"gpt-4o","nested":{"b":[1,null],"a":"ü"}}', + "label": '["Läufe","🚀"]', + "empty": "null", } with langfuse_client.start_as_current_observation(name="parent-span"): @@ -481,15 +498,84 @@ 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_large_integer_metadata_keeps_its_digits( + self, langfuse_client, memory_exporter + ): + """Verify integers beyond JS's safe range are sent as plain digits.""" + with langfuse_client.start_as_current_observation(name="parent-span"): + with propagate_attributes(metadata={"snowflake_id": 9007199254740993}): + 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}.snowflake_id", + "9007199254740993", + ) + + def test_nested_large_integer_metadata_keeps_its_digits( + self, langfuse_client, memory_exporter + ): + """Verify nested integers beyond JS's safe range stay unquoted digits.""" + with langfuse_client.start_as_current_observation(name="parent-span"): + with propagate_attributes( + metadata={ + "ids": [9007199254740993, 1], + "ref": {"snowflake_id": 9007199254740993}, + } + ): + 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}.ids", + "[9007199254740993,1]", + ) + self.verify_span_attribute( + child_span, + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.ref", + '{"snowflake_id":9007199254740993}', + ) + + def test_non_finite_number_metadata_is_dropped( + self, langfuse_client, memory_exporter, caplog + ): + """Verify values containing NaN or Infinity are dropped, like in the JS SDK.""" + caplog.set_level("WARNING", logger="langfuse") + with langfuse_client.start_as_current_observation(name="parent-span"): + with propagate_attributes( + metadata={ + "kept": 1.5, + "nan": float("nan"), + "inf": float("-inf"), + "nested": {"scores": [1.0, float("inf")]}, + } + ): + 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}.kept", "1.5" + ) + for key in ("nan", "inf", "nested"): + self.verify_missing_attribute( + child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{key}" + ) + assert "metadata.nan" in caplog.text + 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"):