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

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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
72 changes: 68 additions & 4 deletions tests/unit/test_propagate_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"""

import concurrent.futures
from datetime import datetime
from datetime import datetime, timezone

import pytest
from opentelemetry.instrumentation.threading import ThreadingInstrumentor
Expand Down Expand Up @@ -464,14 +464,30 @@ 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 = {
"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": 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"):
Expand All @@ -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"):
Expand Down
Loading