diff --git a/langfuse/_client/attributes.py b/langfuse/_client/attributes.py index c744dfa82..c3ca70a09 100644 --- a/langfuse/_client/attributes.py +++ b/langfuse/_client/attributes.py @@ -20,6 +20,7 @@ ) from langfuse._utils.serializer import EventSerializer from langfuse.api import MapValue +from langfuse.logger import langfuse_logger from langfuse.model import PromptClient from langfuse.types import SpanLevel @@ -160,6 +161,26 @@ def _serialize(obj: Any) -> Optional[str]: return json.dumps(obj, cls=EventSerializer) +def _serialize_metadata_value(value: Any) -> str: + """JSON-encode one metadata value, strings included. + + The server decodes metadata values from v5 SDKs, so encoding every value + keeps "123" and 123 distinct. EventSerializer turns ints outside the + JS-safe range and NaN/Infinity into JSON strings and never raises, so one + bad value cannot drop the other keys. Compact separators and raw non-ASCII + match JS JSON.stringify. + """ + # Plain strings skip EventSerializer, which returns them unchanged and then + # uses the same string encoder, so the output is identical but cheaper. + # str subclasses (e.g. str enums) still go through EventSerializer. + if type(value) is str: + return json.dumps(value, ensure_ascii=False) + + return json.dumps( + value, cls=EventSerializer, separators=(",", ":"), ensure_ascii=False + ) + + def _flatten_and_serialize_metadata( metadata: Any, type: Literal["observation", "trace"] ) -> dict: @@ -169,16 +190,24 @@ def _flatten_and_serialize_metadata( else LangfuseOtelSpanAttributes.TRACE_METADATA ) - metadata_attributes: Dict[str, Union[str, int, None]] = {} + metadata_attributes: Dict[str, Optional[str]] = {} if not isinstance(metadata, dict): - metadata_attributes[prefix] = _serialize(metadata) + # JSON-encode non-dict metadata too, strings included, matching the + # per-key values below. None stays None so it is not written. + metadata_attributes[prefix] = ( + None if metadata is None else _serialize_metadata_value(metadata) + ) else: for key, value in metadata.items(): - metadata_attributes[f"{prefix}.{key}"] = ( - value - if isinstance(value, str) or isinstance(value, int) - else _serialize(value) - ) + # Skip None so an update does not overwrite an earlier value. + if value is None: + langfuse_logger.debug( + 'Observation metadata key "%s" was not written because its value is None', + key, + ) + continue + + metadata_attributes[f"{prefix}.{key}"] = _serialize_metadata_value(value) return metadata_attributes diff --git a/langfuse/_client/propagation.py b/langfuse/_client/propagation.py index a79f57260..d36ca534a 100644 --- a/langfuse/_client/propagation.py +++ b/langfuse/_client/propagation.py @@ -5,7 +5,6 @@ propagate to all child spans within the context. """ -import math import re from typing import ( Any, @@ -39,10 +38,12 @@ _agnosticcontextmanager, ) -from langfuse._client.attributes import LangfuseOtelSpanAttributes +from langfuse._client.attributes import ( + LangfuseOtelSpanAttributes, + _serialize_metadata_value, +) from langfuse._client.constants import LANGFUSE_SDK_EXPERIMENT_ENVIRONMENT from langfuse._client.span import _set_span_attributes_within_limit -from langfuse._utils.serializer import EventSerializer from langfuse.logger import langfuse_logger from langfuse.model import PromptClient @@ -156,8 +157,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 + - Values are JSON-encoded, strings included + - Encoded values must be ≤200 characters - 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 @@ -284,11 +285,13 @@ 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". 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. + hyphens or underscores, must be ≤40 characters, and it must not start with "langfuse". Metadata + values, strings included, are JSON-encoded the same way as + observation metadata (compact separators, non-ASCII kept as is, + integers outside the JavaScript safe range and NaN/Infinity become + JSON strings) before the 200 character limit is applied, so `"123"` + stays a string on the server. None values are dropped with a warning, + like observation metadata skips them. 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. @@ -388,17 +391,16 @@ def _propagate_attributes( validated_metadata: Dict[str, str] = {} for key, value in metadata_value.items(): - serialized_value = _serialize_propagated_metadata_value(value) - - if serialized_value is None: + if value is None: langfuse_logger.warning( - "Propagated attribute '%s.%s' contains NaN or Infinity, which " - "is not valid JSON. Dropping value.", + "Propagated attribute '%s.%s' is None. Dropping value.", metadata_key, key, ) continue + serialized_value = _serialize_metadata_value(value) + if _validate_string_value( value=serialized_value, key=f"{metadata_key}.{key}" ): @@ -659,41 +661,6 @@ 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/langfuse/_client/span_exporter.py b/langfuse/_client/span_exporter.py index efbb04261..6c85cf1c5 100644 --- a/langfuse/_client/span_exporter.py +++ b/langfuse/_client/span_exporter.py @@ -10,7 +10,10 @@ from opentelemetry.sdk.util import BoundedList from opentelemetry.trace import format_span_id, format_trace_id -from langfuse._client.attributes import LangfuseOtelSpanAttributes +from langfuse._client.attributes import ( + LangfuseOtelSpanAttributes, + _serialize_metadata_value, +) from langfuse._task_manager.media_manager import MediaManager from langfuse._utils.serializer import EventSerializer from langfuse.logger import langfuse_logger @@ -211,6 +214,13 @@ def _process_media_string( attribute_key: str, value: str, ) -> str: + if value.startswith('"data:') and _is_langfuse_metadata_attribute( + attribute_key + ): + return self._process_json_encoded_media_string( + span=span, attribute_key=attribute_key, value=value + ) + media_manager = cast(MediaManager, self._media_manager) field = _media_field_for_attribute(attribute_key) @@ -260,6 +270,32 @@ def _process_media_string( return _serialize_media_value(processed_json_value, fallback=value) + def _process_json_encoded_media_string( + self, + *, + span: ReadableSpan, + attribute_key: str, + value: str, + ) -> str: + # Langfuse metadata values are JSON-encoded, so a top-level data URI + # arrives quoted. Decode it, extract the media, and re-encode the result. + try: + decoded_value = json.loads(value) + except Exception: + return value + + if not isinstance(decoded_value, str): + return value + + processed_value = self._process_media_string( + span=span, attribute_key=attribute_key, value=decoded_value + ) + + if processed_value == decoded_value: + return value + + return _serialize_metadata_value(processed_value) + def _apply_mask_otel_spans( self, *, @@ -556,6 +592,16 @@ def _is_attribute_sequence(value: AttributeValue) -> bool: return isinstance(value, SequenceCollection) and not isinstance(value, (str, bytes)) +def _is_langfuse_metadata_attribute(attribute_key: str) -> bool: + return any( + attribute_key == prefix or attribute_key.startswith(f"{prefix}.") + for prefix in ( + LangfuseOtelSpanAttributes.OBSERVATION_METADATA, + LangfuseOtelSpanAttributes.TRACE_METADATA, + ) + ) + + def _is_base64_data_uri(value: str) -> bool: if not value.startswith("data:") or "," not in value: return False diff --git a/tests/e2e/test_core_sdk.py b/tests/e2e/test_core_sdk.py index 5330932c3..6d80f821e 100644 --- a/tests/e2e/test_core_sdk.py +++ b/tests/e2e/test_core_sdk.py @@ -37,7 +37,7 @@ async def update_generation(i, langfuse: Langfuse): ) # Update generation with metadata - generation.update(metadata={"count": str(i)}) + generation.update(metadata={"count": i}) # End the generation generation.end() diff --git a/tests/e2e/test_experiments.py b/tests/e2e/test_experiments.py index ceddba7be..4e3c7c125 100644 --- a/tests/e2e/test_experiments.py +++ b/tests/e2e/test_experiments.py @@ -18,6 +18,7 @@ ExperimentItem, ExperimentItemResult, ) +from tests.support.utils import raw_metadata_value T = TypeVar("T") @@ -216,7 +217,7 @@ def test_run_experiment_on_local_dataset(sample_dataset): assert item.output == expected_output assert item.expected_output == expected_answer assert item.metadata is not None - assert item.metadata["experiment_name"] == "Euro capitals" + assert item.metadata["experiment_name"] == raw_metadata_value("Euro capitals") # Run-level evaluations are persisted for local data, too experiment = get_experiment( @@ -362,9 +363,11 @@ def test_run_experiment_on_langfuse_dataset(): assert item.expected_output == dataset_item.expected_output assert item.metadata is not None - assert item.metadata["experiment_name"] == experiment_name - assert item.metadata["dataset_id"] == dataset.id - assert item.metadata["dataset_item_id"] == item.experiment_item_id + assert item.metadata["experiment_name"] == raw_metadata_value(experiment_name) + assert item.metadata["dataset_id"] == raw_metadata_value(dataset.id) + assert item.metadata["dataset_item_id"] == raw_metadata_value( + item.experiment_item_id + ) assert [s.name for s in item.scores or []] == ["factuality"] diff --git a/tests/support/utils.py b/tests/support/utils.py index 191eeae05..a6b126526 100644 --- a/tests/support/utils.py +++ b/tests/support/utils.py @@ -5,6 +5,7 @@ from typing import Any, Callable, Sequence, TypeVar from uuid import uuid4 +from langfuse._version import __version__ as langfuse_version from langfuse.api import LangfuseAPI, ObservationV2, ScoreV3 from tests.support.retry import ( DEFAULT_RETRY_INTERVAL_SECONDS, @@ -37,6 +38,19 @@ ) _SDK_METADATA_KEY_PREFIXES = ("scope.", "resourceAttributes.") +# The server JSON-decodes per-key observation metadata values only for Python +# SDK major >= 5 (langfuse/langfuse#18436). Older SDK versions read back the raw +# JSON string on endpoints that do not parse metadata values on read. +_SERVER_DECODES_METADATA_VALUES = int(langfuse_version.split(".")[0]) >= 5 + + +def raw_metadata_value(value: Any) -> Any: + """Per-key metadata value as stored by the server for this SDK version.""" + if _SERVER_DECODES_METADATA_VALUES: + return value + + return json.dumps(value, separators=(",", ":"), ensure_ascii=False) + def _has_filters(kwargs: dict[str, Any]) -> bool: return any( diff --git a/tests/unit/test_langchain.py b/tests/unit/test_langchain.py index 0e63fb6b1..dbaffda91 100644 --- a/tests/unit/test_langchain.py +++ b/tests/unit/test_langchain.py @@ -845,7 +845,7 @@ def test_tool_when_structured_inputs_only_store_in_inputs_attribute_not_metadata metadata_prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA - assert span.attributes[f"{metadata_prefix}.custom_key"] == "custom_value" + assert span.attributes[f"{metadata_prefix}.custom_key"] == '"custom_value"' assert f"{metadata_prefix}.inputs" not in span.attributes diff --git a/tests/unit/test_mask_otel_spans.py b/tests/unit/test_mask_otel_spans.py index d9a23befb..1b034da17 100644 --- a/tests/unit/test_mask_otel_spans.py +++ b/tests/unit/test_mask_otel_spans.py @@ -21,6 +21,11 @@ from opentelemetry.trace import SpanContext, TraceFlags, TraceState import langfuse._client.span_exporter as span_exporter_module +from langfuse._client.attributes import ( + LangfuseOtelSpanAttributes, + _flatten_and_serialize_metadata, + _serialize_metadata_value, +) from langfuse._client.constants import LANGFUSE_TRACER_NAME from langfuse._client.span_processor import LangfuseSpanProcessor from langfuse._task_manager.media_manager import MediaManager @@ -250,6 +255,40 @@ def test_export_stage_media_processes_direct_data_uri_string(): assert not media_queue.empty() +def test_export_stage_media_processes_json_encoded_metadata_data_uri(): + exporter = InMemorySpanExporter() + media_manager, media_queue = _media_manager() + image_base64 = base64.b64encode(b"image-bytes").decode("utf-8") + data_uri = f"data:image/jpeg;base64,{image_base64}" + + provider = _tracer_provider(exporter=exporter, media_manager=media_manager) + tracer = provider.get_tracer("openinference.instrumentation.openai") + + observation_key = f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.image" + trace_key = f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.image" + + with tracer.start_as_current_span("json-metadata-media-span") as span: + span.set_attributes( + { + **_flatten_and_serialize_metadata({"image": data_uri}, "observation"), + trace_key: _serialize_metadata_value(data_uri), + # A quoted data URI outside Langfuse metadata is left as is. + "custom.payload": _serialize_metadata_value(data_uri), + } + ) + + provider.force_flush() + + attributes = exporter.get_finished_spans()[0].attributes + + for key in (observation_key, trace_key): + assert attributes[key].startswith('"@@@langfuseMedia:') + assert json.loads(attributes[key]).startswith("@@@langfuseMedia:") + assert image_base64 not in attributes[key] + assert attributes["custom.payload"] == json.dumps(data_uri) + assert media_queue.qsize() == 2 + + def test_export_stage_media_processes_string_sequence_attributes(): exporter = InMemorySpanExporter() media_manager, media_queue = _media_manager() diff --git a/tests/unit/test_metadata_attribute_limit.py b/tests/unit/test_metadata_attribute_limit.py index 4e871b17a..edf517d1f 100644 --- a/tests/unit/test_metadata_attribute_limit.py +++ b/tests/unit/test_metadata_attribute_limit.py @@ -130,7 +130,7 @@ def test_update_over_limit_keeps_earlier_attributes( assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_INPUT] == "the input" assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] == "the output" for i in range(100): - assert attributes[f"{METADATA_PREFIX}first_{i}"] == i + assert attributes[f"{METADATA_PREFIX}first_{i}"] == str(i) second = [key for key in _metadata_keys(span) if ".second_" in key] assert second == [f"{METADATA_PREFIX}second_{i}" for i in range(len(second))] @@ -177,7 +177,7 @@ def test_overwriting_existing_metadata_keys_at_limit_is_allowed( assert span.dropped_attributes == 0 assert len(span.attributes) == len(span_at_start.attributes) for key in kept: - assert span.attributes[key] == "new" + assert span.attributes[key] == '"new"' assert _limit_warnings(caplog) == [] @@ -242,8 +242,8 @@ def test_metadata_under_limit_is_unchanged(langfuse_memory_client, get_span, cap assert span.dropped_attributes == 0 assert len(_metadata_keys(span)) == 51 for i in range(50): - assert span.attributes[f"{METADATA_PREFIX}key_{i}"] == i - assert span.attributes[f"{METADATA_PREFIX}extra"] == "value" + assert span.attributes[f"{METADATA_PREFIX}key_{i}"] == str(i) + assert span.attributes[f"{METADATA_PREFIX}extra"] == '"value"' assert _limit_warnings(caplog) == [] @@ -566,10 +566,14 @@ def task(**kwargs): # Observation metadata is trimmed, but the run keys survive and the run # value wins over the user key of the same name. assert 0 < len(_metadata_keys(task_span)) < len(item_metadata) + 4 - assert attributes[f"{METADATA_PREFIX}experiment_name"] == "big-metadata-experiment" - assert attributes[f"{METADATA_PREFIX}experiment_run_name"] == result.run_name - assert attributes[f"{METADATA_PREFIX}dataset_id"] == "dataset-1" - assert attributes[f"{METADATA_PREFIX}dataset_item_id"] == "item-1" + assert attributes[f"{METADATA_PREFIX}experiment_name"] == json.dumps( + "big-metadata-experiment" + ) + assert attributes[f"{METADATA_PREFIX}experiment_run_name"] == json.dumps( + result.run_name + ) + assert attributes[f"{METADATA_PREFIX}dataset_id"] == '"dataset-1"' + assert attributes[f"{METADATA_PREFIX}dataset_item_id"] == '"item-1"' assert f"{METADATA_PREFIX}item_149" not in attributes item_run = get_span("experiment-item-run") @@ -577,8 +581,8 @@ def task(**kwargs): assert item_run.attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] == ( "the answer" ) - assert ( - item_run.attributes[f"{METADATA_PREFIX}experiment_run_name"] == result.run_name + assert item_run.attributes[f"{METADATA_PREFIX}experiment_run_name"] == json.dumps( + result.run_name ) assert LangfuseOtelSpanAttributes.EXPERIMENT_ITEM_METADATA in item_run.attributes diff --git a/tests/unit/test_openai.py b/tests/unit/test_openai.py index cacdae5c2..b688fc6f1 100644 --- a/tests/unit/test_openai.py +++ b/tests/unit/test_openai.py @@ -289,7 +289,7 @@ def test_chat_completion_exports_generation_span( assert ( span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_MODEL] == "gpt-4o-mini" ) - assert span.attributes["langfuse.observation.metadata.suite"] == "unit" + assert span.attributes["langfuse.observation.metadata.suite"] == '"unit"' assert json_attr(span, LangfuseOtelSpanAttributes.OBSERVATION_INPUT) == [ {"role": "user", "content": "1 + 1 = ?"} ] @@ -371,7 +371,7 @@ def test_openai_stream_with_none_choices_chunk_does_not_crash( span = get_span("unit-openai-stream-none-choices") assert span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] == "2" - assert span.attributes["langfuse.observation.metadata.finish_reason"] == "stop" + assert span.attributes["langfuse.observation.metadata.finish_reason"] == '"stop"' def test_streaming_chat_completion_preserves_tool_calls_after_content(): @@ -507,7 +507,7 @@ def fake_stream(): span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_COMPLETION_START_TIME] is not None ) - assert span.attributes["langfuse.observation.metadata.finish_reason"] == "stop" + assert span.attributes["langfuse.observation.metadata.finish_reason"] == '"stop"' assert json_attr(span, LangfuseOtelSpanAttributes.OBSERVATION_USAGE_DETAILS) == { "prompt_tokens": 3, "completion_tokens": 1, @@ -575,7 +575,7 @@ def test_openai_stream_preserves_original_stream_contract( span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_COMPLETION_START_TIME] is not None ) - assert span.attributes["langfuse.observation.metadata.finish_reason"] == "stop" + assert span.attributes["langfuse.observation.metadata.finish_reason"] == '"stop"' assert json_attr(span, LangfuseOtelSpanAttributes.OBSERVATION_USAGE_DETAILS) == { "prompt_tokens": 3, "completion_tokens": 1, @@ -610,7 +610,7 @@ def test_openai_stream_handles_trailing_azure_content_filter_chunk( span = get_span("unit-openai-native-stream-azure-filter") assert span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] == "2" - assert span.attributes["langfuse.observation.metadata.finish_reason"] == "stop" + assert span.attributes["langfuse.observation.metadata.finish_reason"] == '"stop"' assert json_attr(span, LangfuseOtelSpanAttributes.OBSERVATION_USAGE_DETAILS) == { "prompt_tokens": 3, "completion_tokens": 1, @@ -734,7 +734,7 @@ async def test_openai_async_stream_preserves_original_stream_contract( span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_COMPLETION_START_TIME] is not None ) - assert span.attributes["langfuse.observation.metadata.finish_reason"] == "stop" + assert span.attributes["langfuse.observation.metadata.finish_reason"] == '"stop"' assert json_attr(span, LangfuseOtelSpanAttributes.OBSERVATION_USAGE_DETAILS) == { "prompt_tokens": 3, "completion_tokens": 1, @@ -777,7 +777,7 @@ async def test_openai_async_stream_supports_anext( span.attributes[LangfuseOtelSpanAttributes.OBSERVATION_COMPLETION_START_TIME] is not None ) - assert span.attributes["langfuse.observation.metadata.finish_reason"] == "stop" + assert span.attributes["langfuse.observation.metadata.finish_reason"] == '"stop"' assert json_attr(span, LangfuseOtelSpanAttributes.OBSERVATION_USAGE_DETAILS) == { "prompt_tokens": 3, "completion_tokens": 1, diff --git a/tests/unit/test_otel.py b/tests/unit/test_otel.py index 286a5e4d0..ac851a95a 100644 --- a/tests/unit/test_otel.py +++ b/tests/unit/test_otel.py @@ -1,4 +1,5 @@ import json +import logging from datetime import datetime from hashlib import sha256 from typing import List, Sequence @@ -414,7 +415,7 @@ def test_span_attributes(self, langfuse_client, memory_exporter): # Verify attribute values assert input_data == {"prompt": "Test prompt"} assert output_data == {"response": "Updated response"} - assert metadata_data == "test-session" + assert metadata_data == '"test-session"' assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_LEVEL] == "INFO" assert ( attributes[LangfuseOtelSpanAttributes.OBSERVATION_STATUS_MESSAGE] @@ -530,7 +531,7 @@ def test_trace_update(self, langfuse_client, memory_exporter): # Check attribute values assert sorted(tags) == sorted(["tag1", "tag2"]) - assert metadata == "data" + assert metadata == '"data"' def test_complex_scenario(self, langfuse_client, memory_exporter): """Test a more complex scenario with multiple operations and nesting.""" @@ -608,7 +609,7 @@ def test_complex_scenario(self, langfuse_client, memory_exporter): proc_metadata = proc["attributes"][ f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.step" ] - assert proc_metadata == "processing" + assert proc_metadata == '"processing"' # Parse input/output JSON llm_input = json.loads( @@ -1545,17 +1546,15 @@ class TestMetadataHandling(TestOTelBase): def test_complex_metadata_serialization(self): """Test the _flatten_and_serialize_metadata function directly.""" - from langfuse._client.attributes import ( - _flatten_and_serialize_metadata, - _serialize, - ) + from langfuse._client.attributes import _flatten_and_serialize_metadata # Test case 1: Non-dict metadata non_dict_result = _flatten_and_serialize_metadata("string-value", "observation") assert LangfuseOtelSpanAttributes.OBSERVATION_METADATA in non_dict_result - assert non_dict_result[ - LangfuseOtelSpanAttributes.OBSERVATION_METADATA - ] == _serialize("string-value") + assert ( + non_dict_result[LangfuseOtelSpanAttributes.OBSERVATION_METADATA] + == '"string-value"' + ) # Test case 2: Simple dict simple_dict = {"key1": "value1", "key2": 123} @@ -1568,11 +1567,11 @@ def test_complex_metadata_serialization(self): ) assert ( simple_result[f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.key1"] - == "value1" + == '"value1"' ) assert ( simple_result[f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.key2"] - == 123 + == "123" ) # Test case 3: Nested dict (will be flattened in current implementation) @@ -1625,7 +1624,7 @@ def test_complex_metadata_serialization(self): # The nested structures are serialized as JSON strings assert json.loads(complex_result[level1_key]) == complex_dict["level1"] - assert complex_result[sibling_key] == "value" + assert complex_result[sibling_key] == '"value"' def test_nested_metadata_updates(self): """Test that nested metadata updates don't overwrite unrelated keys.""" @@ -1980,7 +1979,206 @@ def update_random_metadata(thread_id): assert "version" in system_data assert "features" in system_data + def test_metadata_values_are_json_encoded(self, langfuse_client, memory_exporter): + """Every per-key metadata value is sent as a JSON-encoded string.""" + from datetime import timezone + + prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA + with langfuse_client.start_as_current_observation( + name="json-metadata-span", + metadata={ + "string": "hello", + "numeric_string": "123", + "bool_string": "true", + "int": 5, + "bool": True, + "float": 1.5, + "big_int": 2**70, + "nan": float("nan"), + "list": [1, "a", None], + "nested": {"a": {"b": [1, 2]}, "c": "d"}, + "datetime": datetime(2024, 1, 2, 3, 4, 5, tzinfo=timezone.utc), + }, + ): + pass + + attributes = self.get_spans_by_name(memory_exporter, "json-metadata-span")[0][ + "attributes" + ] + + expected = { + "string": '"hello"', + "numeric_string": '"123"', + "bool_string": '"true"', + "int": "5", + "bool": "true", + "float": "1.5", + "big_int": '"1180591620717411303424"', + "nan": '"NaN"', + "list": '[1,"a",null]', + "nested": '{"a":{"b":[1,2]},"c":"d"}', + "datetime": '"2024-01-02T03:04:05Z"', + } + for key, value in expected.items(): + assert attributes[f"{prefix}.{key}"] == value, key + + # Decoding restores the original type, so strings and numbers stay distinct. + assert json.loads(attributes[f"{prefix}.numeric_string"]) == "123" + assert json.loads(attributes[f"{prefix}.int"]) == 5 + assert json.loads(attributes[f"{prefix}.bool"]) is True + + def test_big_int_metadata_value_survives_otlp_encoding( + self, langfuse_client, memory_exporter + ): + """Ints beyond int64 must not make the OTLP encoder drop the key.""" + from opentelemetry.exporter.otlp.proto.common.trace_encoder import ( + encode_spans, + ) + + with langfuse_client.start_as_current_observation( + name="big-int-span", metadata={"big": 2**70, "small": 1} + ): + pass + + spans = [ + s for s in memory_exporter.get_finished_spans() if s.name == "big-int-span" + ] + request = encode_spans(spans) + encoded = { + kv.key: kv.value.string_value + for kv in request.resource_spans[0].scope_spans[0].spans[0].attributes + } + + prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA + assert encoded[f"{prefix}.big"] == '"1180591620717411303424"' + assert encoded[f"{prefix}.small"] == "1" + + def test_none_metadata_value_keeps_earlier_value_on_update( + self, langfuse_client, memory_exporter + ): + """None values are skipped, so they don't overwrite earlier keys.""" + prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA + with langfuse_client.start_as_current_observation( + name="none-metadata-span", metadata={"keep": "first", "other": 1} + ) as span: + span.update(metadata={"keep": None, "other": 2}) + + attributes = self.get_spans_by_name(memory_exporter, "none-metadata-span")[0][ + "attributes" + ] + + assert attributes[f"{prefix}.keep"] == '"first"' + assert attributes[f"{prefix}.other"] == "2" + + def test_none_metadata_value_is_debug_logged(self, caplog): + """Skipped None values are logged at debug level, non-None keys are not.""" + from langfuse._client.attributes import _flatten_and_serialize_metadata + + caplog.set_level(logging.DEBUG, logger="langfuse") + + _flatten_and_serialize_metadata( + {"keep": None, "other": 2, "text": "x"}, "observation" + ) + + messages = [ + record.getMessage() + for record in caplog.records + if record.name == "langfuse" + ] + assert messages == [ + 'Observation metadata key "keep" was not written because its value is None' + ] + + @pytest.mark.parametrize( + "metadata, expected", + [("foo", '"foo"'), (5, "5"), ([1, "a", None], '[1,"a",null]')], + ) + def test_non_dict_metadata_is_json_encoded( + self, langfuse_client, memory_exporter, metadata, expected + ): + """Non-dict metadata is sent JSON-encoded on the bare key, strings included.""" + with langfuse_client.start_as_current_observation( + name="non-dict-metadata-span", metadata=metadata + ): + pass + + attributes = self.get_spans_by_name(memory_exporter, "non-dict-metadata-span")[ + 0 + ]["attributes"] + + assert attributes[LangfuseOtelSpanAttributes.OBSERVATION_METADATA] == expected + + def test_flatten_metadata_trace_prefix_uses_same_encoding(self): + """The trace prefix variant encodes values the same way.""" + from langfuse._client.attributes import _flatten_and_serialize_metadata + + result = _flatten_and_serialize_metadata( + {"s": "x", "i": 1, "none": None}, "trace" + ) + + prefix = LangfuseOtelSpanAttributes.TRACE_METADATA + assert result == {f"{prefix}.s": '"x"', f"{prefix}.i": "1"} + + def test_metadata_json_matches_js_json_stringify(self): + """Values use compact separators and keep non-ASCII, like JSON.stringify.""" + from langfuse._client.attributes import _flatten_and_serialize_metadata + + prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA + result = _flatten_and_serialize_metadata( + {"umlaut": "ü", "nested": {"a": {"b": [1, "x"]}}}, "observation" + ) + + assert result == { + f"{prefix}.umlaut": '"ü"', + f"{prefix}.nested": '{"a":{"b":[1,"x"]}}', + } + + non_dict_result = _flatten_and_serialize_metadata([1, "ü"], "observation") + assert non_dict_result == {prefix: '[1,"ü"]'} + + # Expected values are the output of JSON.stringify in Node. + @pytest.mark.parametrize( + "value, js_json_stringify", + [ + ("prod", '"prod"'), + ("123", '"123"'), + ("", '""'), + ('a"b\\c', '"a\\"b\\\\c"'), + ("line\nbreak\ttab\b\f\r", '"line\\nbreak\\ttab\\b\\f\\r"'), + ("\x00\x1f\x7f", '"\\u0000\\u001f\x7f"'), + ("\u2028\u2029", '"\u2028\u2029"'), + ("ü Läufe 🚀 日本", '"ü Läufe 🚀 日本"'), + ("", '""'), + ], + ) + def test_string_fast_path_matches_event_serializer_and_js( + self, value, js_json_stringify + ): + """Plain strings skip EventSerializer but must encode to the same bytes.""" + from langfuse._client.attributes import _serialize_metadata_value + from langfuse._utils.serializer import EventSerializer + + via_event_serializer = json.dumps( + value, cls=EventSerializer, separators=(",", ":"), ensure_ascii=False + ) + + assert _serialize_metadata_value(value) == via_event_serializer + assert _serialize_metadata_value(value) == js_json_stringify + + def test_str_subclass_metadata_goes_through_event_serializer(self): + """str subclasses keep EventSerializer handling, e.g. str enums.""" + from enum import Enum + + from langfuse._client.attributes import _serialize_metadata_value + + class Stage(str, Enum): + PROD = "prod" + + assert _serialize_metadata_value(Stage.PROD) == '"prod"' + MASK_FALLBACK = "" + # Metadata attributes carry the fallback JSON-encoded like any other value. + ENCODED_MASK_FALLBACK = json.dumps(MASK_FALLBACK) def get_metadata_attributes(self, span_data: dict) -> dict: prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA @@ -2006,9 +2204,9 @@ def mask(*, data, **kwargs): span_data = self.get_spans_by_name(memory_exporter, "mask-fail")[0] prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA assert self.get_metadata_attributes(span_data) == { - f"{prefix}.a": 1, - f"{prefix}.secret": self.MASK_FALLBACK, - f"{prefix}.b": self.MASK_FALLBACK, + f"{prefix}.a": "1", + f"{prefix}.secret": self.ENCODED_MASK_FALLBACK, + f"{prefix}.b": self.ENCODED_MASK_FALLBACK, } def test_failed_mask_on_start_masks_each_metadata_key( @@ -2028,8 +2226,8 @@ def mask(*, data, **kwargs): span_data = self.get_spans_by_name(memory_exporter, "mask-fail-start")[0] prefix = LangfuseOtelSpanAttributes.OBSERVATION_METADATA assert self.get_metadata_attributes(span_data) == { - f"{prefix}.secret": self.MASK_FALLBACK, - f"{prefix}.b": self.MASK_FALLBACK, + f"{prefix}.secret": self.ENCODED_MASK_FALLBACK, + f"{prefix}.b": self.ENCODED_MASK_FALLBACK, } def test_failed_mask_keeps_string_fallback_for_empty_dict_metadata( @@ -2048,7 +2246,7 @@ def mask(*, data, **kwargs): span_data = self.get_spans_by_name(memory_exporter, "mask-fail-empty-dict")[0] assert self.get_metadata_attributes(span_data) == { - LangfuseOtelSpanAttributes.OBSERVATION_METADATA: self.MASK_FALLBACK, + LangfuseOtelSpanAttributes.OBSERVATION_METADATA: self.ENCODED_MASK_FALLBACK, } def test_failed_mask_keeps_string_fallback_for_non_dict_values( @@ -2071,7 +2269,7 @@ def mask(*, data, **kwargs): span_data = self.get_spans_by_name(memory_exporter, "mask-fail-non-dict")[0] attributes = span_data["attributes"] assert self.get_metadata_attributes(span_data) == { - LangfuseOtelSpanAttributes.OBSERVATION_METADATA: self.MASK_FALLBACK, + LangfuseOtelSpanAttributes.OBSERVATION_METADATA: self.ENCODED_MASK_FALLBACK, } assert ( attributes[LangfuseOtelSpanAttributes.OBSERVATION_INPUT] diff --git a/tests/unit/test_propagate_attributes.py b/tests/unit/test_propagate_attributes.py index 024bac64f..89d8fb3c2 100644 --- a/tests/unit/test_propagate_attributes.py +++ b/tests/unit/test_propagate_attributes.py @@ -156,24 +156,24 @@ def test_metadata_propagates_to_child_spans(self, langfuse_client, memory_export self.verify_span_attribute( child1_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment", - "variant_a", + '"variant_a"', ) self.verify_span_attribute( child1_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.version", - "1.0", + '"1.0"', ) child2_span = self.get_span_by_name(memory_exporter, "child-span-2") self.verify_span_attribute( child2_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment", - "variant_a", + '"variant_a"', ) self.verify_span_attribute( child2_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.version", - "1.0", + '"1.0"', ) def test_all_attributes_propagate_together(self, langfuse_client, memory_exporter): @@ -198,12 +198,12 @@ def test_all_attributes_propagate_together(self, langfuse_client, memory_exporte self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment", - "test", + '"test"', ) self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", - "prod", + '"prod"', ) @@ -485,7 +485,6 @@ def test_non_string_metadata_values_json_serialized( "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 = { @@ -497,7 +496,6 @@ def test_non_string_metadata_values_json_serialized( "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"): @@ -516,10 +514,37 @@ def test_non_string_metadata_values_json_serialized( assert "value is not a string. Dropping value." not in caplog.text - def test_large_integer_metadata_keeps_its_digits( + def test_none_metadata_value_is_dropped_with_warning( + self, langfuse_client, memory_exporter, caplog + ): + """Verify None values are dropped and don't overwrite an outer value.""" + caplog.set_level("WARNING", logger="langfuse") + prefix = LangfuseOtelSpanAttributes.TRACE_METADATA + with langfuse_client.start_as_current_observation(name="parent-span"): + with propagate_attributes(metadata={"outer": "kept"}): + with propagate_attributes( + metadata={"outer": None, "empty": None, "other": "x"} + ): + 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"{prefix}.outer", '"kept"') + self.verify_span_attribute(child_span, f"{prefix}.other", '"x"') + self.verify_missing_attribute(child_span, f"{prefix}.empty") + assert ( + "Propagated attribute 'metadata.empty' is None. Dropping value." + in caplog.text + ) + assert ( + "Propagated attribute 'metadata.outer' is None. Dropping value." + in caplog.text + ) + + def test_large_integer_metadata_becomes_json_string( self, langfuse_client, memory_exporter ): - """Verify integers beyond JS's safe range are sent as plain digits.""" + """Verify integers beyond JS's safe range become JSON strings, like observation metadata.""" 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") @@ -529,13 +554,13 @@ def test_large_integer_metadata_keeps_its_digits( self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.snowflake_id", - "9007199254740993", + '"9007199254740993"', ) - def test_nested_large_integer_metadata_keeps_its_digits( + def test_nested_large_integer_metadata_becomes_json_string( self, langfuse_client, memory_exporter ): - """Verify nested integers beyond JS's safe range stay unquoted digits.""" + """Verify nested integers beyond JS's safe range become JSON strings.""" with langfuse_client.start_as_current_observation(name="parent-span"): with propagate_attributes( metadata={ @@ -550,19 +575,18 @@ def test_nested_large_integer_metadata_keeps_its_digits( self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.ids", - "[9007199254740993,1]", + '["9007199254740993",1]', ) self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.ref", - '{"snowflake_id":9007199254740993}', + '{"snowflake_id":"9007199254740993"}', ) - def test_non_finite_number_metadata_is_dropped( - self, langfuse_client, memory_exporter, caplog + def test_non_finite_number_metadata_becomes_json_string( + self, langfuse_client, memory_exporter ): - """Verify values containing NaN or Infinity are dropped, like in the JS SDK.""" - caplog.set_level("WARNING", logger="langfuse") + """Verify NaN and Infinity become JSON strings, like observation metadata.""" with langfuse_client.start_as_current_observation(name="parent-span"): with propagate_attributes( metadata={ @@ -576,14 +600,74 @@ def test_non_finite_number_metadata_is_dropped( child.end() child_span = self.get_span_by_name(memory_exporter, "child-span") + prefix = LangfuseOtelSpanAttributes.TRACE_METADATA + self.verify_span_attribute(child_span, f"{prefix}.kept", "1.5") + self.verify_span_attribute(child_span, f"{prefix}.nan", '"NaN"') + self.verify_span_attribute(child_span, f"{prefix}.inf", '"-Infinity"') self.verify_span_attribute( - child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.kept", "1.5" + child_span, f"{prefix}.nested", '{"scores":[1.0,"Infinity"]}' ) - for key in ("nan", "inf", "nested"): - self.verify_missing_attribute( - child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{key}" + + def test_string_metadata_is_json_encoded_like_observation_metadata( + self, langfuse_client, memory_exporter + ): + """Verify strings are JSON-encoded so "123" stays a string on the server.""" + metadata = { + "plain": "prod", + "numeric_string": "123", + "bool_string": "true", + "null_string": "null", + "json_string": '{"a":1}', + "umlaut": "ü", + } + with langfuse_client.start_as_current_observation(name="parent-span"): + with propagate_attributes(metadata=metadata): + child = langfuse_client.start_observation( + name="child-span", metadata=metadata + ) + child.end() + + attributes = self.get_span_by_name(memory_exporter, "child-span")["attributes"] + for key, value in metadata.items(): + trace_value = attributes[ + f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{key}" + ] + assert ( + trace_value + == attributes[ + f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.{key}" + ] ) - assert "metadata.nan" in caplog.text + assert json.loads(trace_value) == value + + assert ( + attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.numeric_string"] + == '"123"' + ) + assert ( + attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.umlaut"] == '"ü"' + ) + + def test_metadata_length_limit_applies_after_json_encoding( + self, langfuse_client, memory_exporter + ): + """Verify the 200 character limit counts the quotes added by JSON encoding.""" + with langfuse_client.start_as_current_observation(name="parent-span"): + with propagate_attributes( + metadata={"fits": "a" * 198, "too_long": "b" * 199} + ): + 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}.fits", + '"' + "a" * 198 + '"', + ) + self.verify_missing_attribute( + child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.too_long" + ) def test_mixed_valid_invalid_metadata(self, langfuse_client, memory_exporter): """Verify mixed valid/invalid metadata - valid entries kept, invalid dropped.""" @@ -603,12 +687,12 @@ def test_mixed_valid_invalid_metadata(self, langfuse_client, memory_exporter): self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.valid_key", - "valid_value", + '"valid_value"', ) self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.another_valid", - "ok", + '"ok"', ) self.verify_missing_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.invalid_key" @@ -717,12 +801,12 @@ def test_nested_metadata_merges_additively(self, langfuse_client, memory_exporte self.verify_span_attribute( outer_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", - "prod", + '"prod"', ) self.verify_span_attribute( outer_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.region", - "us-east", + '"us-east"', ) self.verify_missing_attribute( outer_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment" @@ -733,22 +817,22 @@ def test_nested_metadata_merges_additively(self, langfuse_client, memory_exporte self.verify_span_attribute( inner_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", - "prod", + '"prod"', ) self.verify_span_attribute( inner_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.region", - "us-east", + '"us-east"', ) self.verify_span_attribute( inner_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment", - "A", + '"A"', ) self.verify_span_attribute( inner_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.version", - "2.0", + '"2.0"', ) # Verify: after span has only outer metadata (inner context exited) @@ -756,12 +840,12 @@ def test_nested_metadata_merges_additively(self, langfuse_client, memory_exporte self.verify_span_attribute( after_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", - "prod", + '"prod"', ) self.verify_span_attribute( after_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.region", - "us-east", + '"us-east"', ) self.verify_missing_attribute( after_span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment" @@ -789,26 +873,26 @@ def test_nested_metadata_inner_overwrites_conflicting_keys( self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", - "production", # Inner value wins + '"production"', # Inner value wins ) # Preserved keys from outer self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.version", - "1.0", # From outer + '"1.0"', # From outer ) self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.region", - "us-west", # From outer + '"us-west"', # From outer ) # New key from inner self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment", - "B", # From inner + '"B"', # From inner ) def test_triple_nested_metadata_accumulates(self, langfuse_client, memory_exporter): @@ -827,24 +911,24 @@ def test_triple_nested_metadata_accumulates(self, langfuse_client, memory_export self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.level", - "3", + '"3"', ) # Unique keys from each level self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.a", - "outer", + '"outer"', ) self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.b", - "middle", + '"middle"', ) self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.c", - "inner", + '"inner"', ) def test_metadata_merge_with_empty_inner(self, langfuse_client, memory_exporter): @@ -861,12 +945,12 @@ def test_metadata_merge_with_empty_inner(self, langfuse_client, memory_exporter) self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.key1", - "value1", + '"value1"', ) self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.key2", - "value2", + '"value2"', ) def test_metadata_merge_preserves_user_session( @@ -894,12 +978,12 @@ def test_metadata_merge_preserves_user_session( self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.outer", - "value", + '"value"', ) self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.inner", - "value", + '"value"', ) @@ -1050,9 +1134,9 @@ def test_multiple_metadata_keys_independent(self, langfuse_client, memory_export attributes = child_span["attributes"] # Verify all three are separate attributes with correct values - assert attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.k1"] == "v1" - assert attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.k2"] == "v2" - assert attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.k3"] == "v3" + assert attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.k1"] == '"v1"' + assert attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.k2"] == '"v2"' + assert attributes[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.k3"] == '"v3"' class TestPropagateAttributesThreading(TestPropagateAttributesBase): @@ -1361,7 +1445,7 @@ def test_nested_spans_from_multiple_tracers( self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment", - "cross_tracer", + '"cross_tracer"', ) def test_other_tracer_span_before_propagate_context( @@ -1421,17 +1505,17 @@ def test_mixed_tracers_with_metadata( self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", - "production", + '"production"', ) self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.version", - "2.0", + '"2.0"', ) self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.feature_flag", - "enabled", + '"enabled"', ) def test_propagate_without_langfuse_parent( @@ -1555,7 +1639,7 @@ async def level_1(): self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.level", - "nested", + '"nested"', ) @pytest.mark.asyncio @@ -1685,12 +1769,12 @@ async def async_with_metadata(): self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.async", - "true", + '"true"', ) self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.operation", - "test", + '"test"', ) @@ -1721,10 +1805,10 @@ def test_baggage_is_set_when_as_baggage_true(self, langfuse_client): assert baggage_entries["langfuse_session_id"] == "session_abc" assert "langfuse_metadata_env" in baggage_entries - assert baggage_entries["langfuse_metadata_env"] == "test" + assert baggage_entries["langfuse_metadata_env"] == '"test"' assert "langfuse_metadata_version" in baggage_entries - assert baggage_entries["langfuse_metadata_version"] == "2.0" + assert baggage_entries["langfuse_metadata_version"] == '"2.0"' def test_spans_receive_attributes_from_baggage( self, langfuse_client, memory_exporter @@ -1754,7 +1838,7 @@ def test_spans_receive_attributes_from_baggage( self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.source", - "baggage", + '"baggage"', ) def test_baggage_disabled_by_default(self, langfuse_client): @@ -1803,12 +1887,12 @@ def test_metadata_key_with_user_id_substring_doesnt_collide( self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.user_info", - "some_data", + '"some_data"', ) self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.user_id_copy", - "another", + '"another"', ) def test_metadata_key_with_session_substring_doesnt_collide( @@ -1834,12 +1918,12 @@ def test_metadata_key_with_session_substring_doesnt_collide( self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.session_data", - "value1", + '"value1"', ) self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.session_id_backup", - "value2", + '"value2"', ) def test_metadata_keys_extract_correctly_from_baggage( @@ -1864,17 +1948,17 @@ def test_metadata_keys_extract_correctly_from_baggage( self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", - "production", + '"production"', ) self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.region", - "us-west", + '"us-west"', ) self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.experiment_id", - "exp_123", + '"exp_123"', ) def test_baggage_and_context_both_propagate(self, langfuse_client, memory_exporter): @@ -1904,7 +1988,7 @@ def test_baggage_and_context_both_propagate(self, langfuse_client, memory_export self.verify_span_attribute( span_data, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.source", - "both", + '"both"', ) def test_baggage_survives_context_isolation(self, langfuse_client, memory_exporter): @@ -2214,12 +2298,12 @@ def test_version_with_metadata(self, langfuse_client, memory_exporter): self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", - "production", + '"production"', ) self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.region", - "us-east", + '"us-east"', ) def test_version_validation_over_200_chars(self, langfuse_client, memory_exporter): @@ -2493,7 +2577,7 @@ def test_tags_with_metadata(self, langfuse_client, memory_exporter): self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", - "staging", + '"staging"', ) def test_tags_validation_with_invalid_tag(self, langfuse_client, memory_exporter): @@ -2857,17 +2941,17 @@ def test_experiment_run_metadata_overrides_item_metadata( self.verify_span_attribute( span, f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.shared", - "run", + '"run"', ) self.verify_span_attribute( span, f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.experiment_run_name", - "run-name", + '"run-name"', ) self.verify_span_attribute( span, f"{LangfuseOtelSpanAttributes.OBSERVATION_METADATA}.item_only", - "yes", + '"yes"', ) def test_experiment_attributes_propagate_with_dataset( @@ -3352,12 +3436,12 @@ def test_trace_name_with_metadata(self, langfuse_client, memory_exporter): self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.env", - "production", + '"production"', ) self.verify_span_attribute( child_span, f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.region", - "us-east", + '"us-east"', ) def test_trace_name_validation_over_200_chars(