Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 36 additions & 7 deletions langfuse/_client/attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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
67 changes: 17 additions & 50 deletions langfuse/_client/propagation.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
propagate to all child spans within the context.
"""

import math
import re
from typing import (
Any,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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}"
):
Expand Down Expand Up @@ -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
Expand Down
48 changes: 47 additions & 1 deletion langfuse/_client/span_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/test_core_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
11 changes: 7 additions & 4 deletions tests/e2e/test_experiments.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
ExperimentItem,
ExperimentItemResult,
)
from tests.support.utils import raw_metadata_value

T = TypeVar("T")

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"]

Expand Down
14 changes: 14 additions & 0 deletions tests/support/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_langchain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
39 changes: 39 additions & 0 deletions tests/unit/test_mask_otel_spans.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading