Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
18a7f21
chore!: require Python 3.11 or newer
cursoragent Oct 6, 2026
ee38a2b
feat(openai)!: drop support for OpenAI SDK versions below 1.0
cursoragent Oct 6, 2026
95b341d
feat(otel)!: send x-langfuse-ingestion-version: 4 by default
cursoragent Oct 6, 2026
466808b
feat(client)!: remove blocked_instrumentation_scopes
cursoragent Oct 6, 2026
e9623ee
feat(tracing)!: JSON-serialize propagated metadata values
cursoragent Oct 6, 2026
5fc6147
fix(tracing): serialize propagated metadata like JSON.stringify
cursoragent Oct 6, 2026
4cffec5
fix(observe): keep detecting legacy-marked coroutine functions
cursoragent Oct 7, 2026
aa14c25
Merge branch 'lfe-17080-python-sdk-v5-drop-openai-sdk-10-support' int…
cursoragent Oct 7, 2026
a5fe6eb
Merge branch 'lfe-17078-python-sdk-v5-remove-blocked_instrumentation_…
cursoragent Oct 7, 2026
90102f8
Merge branch 'lfe-17074-python-sdk-v5-send-x-langfuse-ingestion-versi…
cursoragent Oct 7, 2026
d1cb7a1
Merge branch 'lfe-16917-python-sdk-add-python-315-support-and-drop-31…
cursoragent Oct 7, 2026
846cc7f
test(otel): pin that a mixed-case ingestion-version override wins
cursoragent Oct 7, 2026
14da611
fix(tracing): keep the exact digits of large integer metadata values
cursoragent Oct 7, 2026
ab0e49d
Merge branch 'lfe-17074-python-sdk-v5-send-x-langfuse-ingestion-versi…
cursoragent Oct 7, 2026
01a0596
Merge branch 'lfe-17078-python-sdk-v5-remove-blocked_instrumentation_…
cursoragent Oct 7, 2026
5c4bb1f
Merge branch 'prepare-v5-release' into lfe-17080-python-sdk-v5-drop-o…
cursoragent Oct 7, 2026
3eea389
Merge branch 'lfe-17080-python-sdk-v5-drop-openai-sdk-10-support' int…
cursoragent Oct 7, 2026
6c0165d
Merge branch 'lfe-17074-python-sdk-v5-send-x-langfuse-ingestion-versi…
cursoragent Oct 7, 2026
2d45312
Merge branch 'lfe-17078-python-sdk-v5-remove-blocked_instrumentation_…
cursoragent Oct 7, 2026
5f4b9a4
fix(tracing): match JS for nested big ints and non-finite metadata
cursoragent Oct 7, 2026
ac1eeb0
Merge branch 'prepare-v5-release' into lfe-17078-python-sdk-v5-remove…
cursoragent Oct 7, 2026
c7c709a
feat(client): keep host and LANGFUSE_HOST working without a warning
cursoragent Oct 7, 2026
cfa2acc
Merge branch 'lfe-17078-python-sdk-v5-remove-blocked_instrumentation_…
cursoragent Oct 7, 2026
d0d6104
Merge branch 'prepare-v5-release' into lfe-17061-python-sdk-v5-json-s…
cursoragent Oct 7, 2026
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
61 changes: 56 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 math
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 @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
94 changes: 90 additions & 4 deletions tests/unit/test_propagate_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,17 +461,34 @@ 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 = {
"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, 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"):
Expand All @@ -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"):
Expand Down
Loading