From 426f1462528bdf3dea9f4588c28e004358ba4de7 Mon Sep 17 00:00:00 2001 From: David Traina <44659830+DavidTraina@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:01:18 -0500 Subject: [PATCH 1/3] perf(tracing): skip span attribute limit checks when the span has room Every SDK attribute write runs the span attribute limit guard, which did a membership check on the span attributes for each new and reserved key. On OTel's BoundedAttributes each miss raises and catches a KeyError. - Return early when existing + new + reserved keys fit, an upper bound of the exact count, so no drop decision changes. - On the slow path, snapshot the existing keys into a set once. - Precompute the span-like observation types. - Write type, environment and release in one guarded call. --- langfuse/_client/span.py | 68 ++++++++++----------- tests/unit/test_metadata_attribute_limit.py | 40 +++++++++++- tests/unit/test_otel.py | 24 ++++++++ 3 files changed, 95 insertions(+), 37 deletions(-) diff --git a/langfuse/_client/span.py b/langfuse/_client/span.py index ffdcd8c55..8db748fc7 100644 --- a/langfuse/_client/span.py +++ b/langfuse/_client/span.py @@ -103,11 +103,13 @@ def _is_metadata_key(key: str) -> bool: ) +_SPAN_LIKE_OR_EVENT_TYPES = frozenset( + (*get_observation_types_list(ObservationTypeSpanLike), "event") +) + + def _reserved_attribute_keys(observation_type: Optional[str]) -> Tuple[str, ...]: - if ( - observation_type in get_observation_types_list(ObservationTypeSpanLike) - or observation_type == "event" - ): + if observation_type in _SPAN_LIKE_OR_EVENT_TYPES: return _RESERVED_SPAN_ATTRIBUTE_KEYS return _RESERVED_OBSERVATION_ATTRIBUTE_KEYS @@ -147,23 +149,30 @@ def _drop_attributes_over_span_limit( return attributes existing = getattr(span, "attributes", None) or {} + # Upper bound of the exact count below: if it fits, nothing is dropped. + if len(existing) + len(attributes) + len(reserved_keys) <= max_attributes: + return attributes + + existing_keys = set(existing) new_keys = [ key for key, value in attributes.items() - if value is not None and key not in existing + if value is not None and key not in existing_keys ] new_key_set = set(new_keys) reserved_count = sum( - 1 for key in reserved_keys if key not in existing and key not in new_key_set + 1 + for key in reserved_keys + if key not in existing_keys and key not in new_key_set ) - if len(existing) + reserved_count + len(new_keys) <= max_attributes: + if len(existing_keys) + reserved_count + len(new_keys) <= max_attributes: return attributes new_metadata_keys = [key for key in new_keys if _is_metadata_key(key)] free_metadata_slots = max( 0, max_attributes - - len(existing) + - len(existing_keys) - reserved_count - (len(new_keys) - len(new_metadata_keys)), ) @@ -173,7 +182,7 @@ def _drop_attributes_over_span_limit( remaining_new_keys = [ key for key in new_keys if key not in dropped_metadata_set ] - capacity = max(0, max_attributes - len(existing)) + capacity = max(0, max_attributes - len(existing_keys)) dropped_other = remaining_new_keys[capacity:] # The metadata budget is never looser than the hard guard, so only # non-metadata keys can be dropped here. Count any metadata defensively. @@ -309,12 +318,6 @@ def __init__( prompt: Associated prompt template from Langfuse prompt management """ self._otel_span = otel_span - reserved_keys = _reserved_attribute_keys(as_type) - _set_span_attributes_within_limit( - self._otel_span, - {LangfuseOtelSpanAttributes.OBSERVATION_TYPE: as_type}, - reserved_keys, - ) self._langfuse_client = langfuse_client self._observation_type = as_type @@ -327,20 +330,19 @@ def __init__( self._environment = ( existing_environment or environment or self._langfuse_client._environment ) - if self._environment is not None: - _set_span_attributes_within_limit( - self._otel_span, - {LangfuseOtelSpanAttributes.ENVIRONMENT: self._environment}, - reserved_keys, - ) - self._release = release or self._langfuse_client._release - if self._release is not None: - _set_span_attributes_within_limit( - self._otel_span, - {LangfuseOtelSpanAttributes.RELEASE: self._release}, - reserved_keys, - ) + + initial_attributes = { + LangfuseOtelSpanAttributes.OBSERVATION_TYPE: as_type, + LangfuseOtelSpanAttributes.ENVIRONMENT: self._environment, + LangfuseOtelSpanAttributes.RELEASE: self._release, + } + reserved_keys = _reserved_attribute_keys(as_type) + _set_span_attributes_within_limit( + self._otel_span, + {k: v for k, v in initial_attributes.items() if v is not None}, + reserved_keys, + ) # Handle media only if span is sampled if self._otel_span.is_recording(): @@ -387,11 +389,7 @@ def __init__( status_message=status_message, observation_type=cast( Optional[Union[ObservationTypeSpanLike, Literal["event"]]], - as_type - if as_type - in get_observation_types_list(ObservationTypeSpanLike) - or as_type == "event" - else None, + as_type if as_type in _SPAN_LIKE_OR_EVENT_TYPES else None, ), ) @@ -896,9 +894,7 @@ def update( observation_type=cast( Optional[Union[ObservationTypeSpanLike, Literal["event"]]], self._observation_type - if self._observation_type - in get_observation_types_list(ObservationTypeSpanLike) - or self._observation_type == "event" + if self._observation_type in _SPAN_LIKE_OR_EVENT_TYPES else None, ), ) diff --git a/tests/unit/test_metadata_attribute_limit.py b/tests/unit/test_metadata_attribute_limit.py index 54bb919a4..1e6f77b81 100644 --- a/tests/unit/test_metadata_attribute_limit.py +++ b/tests/unit/test_metadata_attribute_limit.py @@ -514,9 +514,47 @@ def test_hard_guard_keeps_overwrites_and_drops_new_tail(caplog): assert "Dropped keys include: m, b_1, b_2" in message +def _metadata_attributes(count): + return {f"{METADATA_PREFIX}m_{i}": i for i in range(count)} + + +@pytest.mark.parametrize( + ("existing", "attributes", "dropped"), + [ + # 5 existing keys and 10 open reserved keys leave 5 metadata slots. + ({f"a_{i}": i for i in range(5)}, _metadata_attributes(5), []), + ( + {f"a_{i}": i for i in range(5)}, + _metadata_attributes(6), + [f"{METADATA_PREFIX}m_5"], + ), + # Overwrites and reserved keys already on the span or in the same + # write take no extra slot, which leaves 8 metadata slots. + ( + {"a_0": 0, "a_1": 1, "r_0": 0, "r_1": 1, "r_2": 2}, + {"a_0": "new", "a_1": "new", "r_3": 3, **_metadata_attributes(8)}, + [], + ), + ( + {"a_0": 0, "a_1": 1, "r_0": 0, "r_1": 1, "r_2": 2}, + {"a_0": "new", "a_1": "new", "r_3": 3, **_metadata_attributes(9)}, + [f"{METADATA_PREFIX}m_8"], + ), + ], +) +def test_metadata_budget_is_exact_at_the_limit(existing, attributes, dropped, caplog): + span = _fake_span(20, existing) + reserved_keys = tuple(f"r_{i}" for i in range(10)) + + result = _drop_attributes_over_span_limit(span, attributes, reserved_keys) + + assert result == {k: v for k, v in attributes.items() if k not in dropped} + assert len(_limit_warnings(caplog)) == (1 if dropped else 0) + + def test_hard_guard_never_raises(): class BrokenAttributes: - def __contains__(self, key): + def __iter__(self): raise RuntimeError("boom") def __len__(self): diff --git a/tests/unit/test_otel.py b/tests/unit/test_otel.py index dfe1336fc..4d4cd6cdf 100644 --- a/tests/unit/test_otel.py +++ b/tests/unit/test_otel.py @@ -311,6 +311,30 @@ def test_basic_span_creation(self, langfuse_client, memory_exporter): assert span.id == span_data["span_id"] assert span.trace_id == span_data["trace_id"] + def test_environment_and_release_are_set_only_when_configured( + self, langfuse_client, memory_exporter + ): + langfuse_client._environment = "staging" + langfuse_client._release = "v1" + langfuse_client.start_observation(name="configured", as_type="generation").end() + langfuse_client._environment = None + langfuse_client._release = None + langfuse_client.start_observation(name="unconfigured").end() + + configured = self.get_spans_by_name(memory_exporter, "configured")[0][ + "attributes" + ] + assert configured[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] == "generation" + assert configured[LangfuseOtelSpanAttributes.ENVIRONMENT] == "staging" + assert configured[LangfuseOtelSpanAttributes.RELEASE] == "v1" + + unconfigured = self.get_spans_by_name(memory_exporter, "unconfigured")[0][ + "attributes" + ] + assert unconfigured[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] == "span" + assert LangfuseOtelSpanAttributes.ENVIRONMENT not in unconfigured + assert LangfuseOtelSpanAttributes.RELEASE not in unconfigured + def test_span_hierarchy(self, langfuse_client, memory_exporter): """Test creating nested spans and verify their parent-child relationships.""" # Create parent span From ca04e1d2aee967419595eff8a4a4b1f963bb4b58 Mon Sep 17 00:00:00 2001 From: David Traina <44659830+DavidTraina@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:17:51 -0500 Subject: [PATCH 2/3] test: keep the observation type when environment and release do not fit --- tests/unit/test_metadata_attribute_limit.py | 30 +++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/unit/test_metadata_attribute_limit.py b/tests/unit/test_metadata_attribute_limit.py index 1e6f77b81..a3c9c79d1 100644 --- a/tests/unit/test_metadata_attribute_limit.py +++ b/tests/unit/test_metadata_attribute_limit.py @@ -14,6 +14,7 @@ from types import SimpleNamespace import pytest +from opentelemetry.sdk.trace import SpanLimits, TracerProvider from opentelemetry.trace import format_span_id from langfuse import propagate_attributes @@ -21,6 +22,7 @@ from langfuse._client.span import ( _RESERVED_OBSERVATION_ATTRIBUTE_KEYS, _RESERVED_SPAN_ATTRIBUTE_KEYS, + LangfuseSpan, _drop_attributes_over_span_limit, _reserved_attribute_keys, ) @@ -552,6 +554,34 @@ def test_metadata_budget_is_exact_at_the_limit(existing, attributes, dropped, ca assert len(_limit_warnings(caplog)) == (1 if dropped else 0) +def test_observation_type_is_kept_when_environment_and_release_do_not_fit( + langfuse_memory_client, caplog +): + tracer = TracerProvider(span_limits=SpanLimits(max_span_attributes=3)).get_tracer( + "test" + ) + otel_span = tracer.start_span("nearly-full", attributes={"a": 1, "b": 2}) + + LangfuseSpan( + otel_span=otel_span, + langfuse_client=langfuse_memory_client, + environment="staging", + release="v1", + ) + + assert dict(otel_span.attributes) == { + "a": 1, + "b": 2, + LangfuseOtelSpanAttributes.OBSERVATION_TYPE: "span", + } + warnings = _limit_warnings(caplog) + assert len(warnings) == 1 + assert ( + "Dropped keys include: langfuse.environment, langfuse.release" + in warnings[0].getMessage() + ) + + def test_hard_guard_never_raises(): class BrokenAttributes: def __iter__(self): From e9b7db78971194da7e12c9efa80b9141e37ed9ca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 08:41:10 +0000 Subject: [PATCH 3/3] perf(tracing): keep only the span attribute limit early return Drop the key-set snapshot, the precomputed span-like type set and the batched observation init write. The early return carries almost all of the gain, and without the batched write the warnings match main exactly. Co-authored-by: hassieb.pakzad --- langfuse/_client/span.py | 64 ++++++++++++--------- tests/unit/test_metadata_attribute_limit.py | 32 +---------- tests/unit/test_otel.py | 24 -------- 3 files changed, 37 insertions(+), 83 deletions(-) diff --git a/langfuse/_client/span.py b/langfuse/_client/span.py index 8db748fc7..917eee994 100644 --- a/langfuse/_client/span.py +++ b/langfuse/_client/span.py @@ -103,13 +103,11 @@ def _is_metadata_key(key: str) -> bool: ) -_SPAN_LIKE_OR_EVENT_TYPES = frozenset( - (*get_observation_types_list(ObservationTypeSpanLike), "event") -) - - def _reserved_attribute_keys(observation_type: Optional[str]) -> Tuple[str, ...]: - if observation_type in _SPAN_LIKE_OR_EVENT_TYPES: + if ( + observation_type in get_observation_types_list(ObservationTypeSpanLike) + or observation_type == "event" + ): return _RESERVED_SPAN_ATTRIBUTE_KEYS return _RESERVED_OBSERVATION_ATTRIBUTE_KEYS @@ -153,26 +151,23 @@ def _drop_attributes_over_span_limit( if len(existing) + len(attributes) + len(reserved_keys) <= max_attributes: return attributes - existing_keys = set(existing) new_keys = [ key for key, value in attributes.items() - if value is not None and key not in existing_keys + if value is not None and key not in existing ] new_key_set = set(new_keys) reserved_count = sum( - 1 - for key in reserved_keys - if key not in existing_keys and key not in new_key_set + 1 for key in reserved_keys if key not in existing and key not in new_key_set ) - if len(existing_keys) + reserved_count + len(new_keys) <= max_attributes: + if len(existing) + reserved_count + len(new_keys) <= max_attributes: return attributes new_metadata_keys = [key for key in new_keys if _is_metadata_key(key)] free_metadata_slots = max( 0, max_attributes - - len(existing_keys) + - len(existing) - reserved_count - (len(new_keys) - len(new_metadata_keys)), ) @@ -182,7 +177,7 @@ def _drop_attributes_over_span_limit( remaining_new_keys = [ key for key in new_keys if key not in dropped_metadata_set ] - capacity = max(0, max_attributes - len(existing_keys)) + capacity = max(0, max_attributes - len(existing)) dropped_other = remaining_new_keys[capacity:] # The metadata budget is never looser than the hard guard, so only # non-metadata keys can be dropped here. Count any metadata defensively. @@ -318,6 +313,12 @@ def __init__( prompt: Associated prompt template from Langfuse prompt management """ self._otel_span = otel_span + reserved_keys = _reserved_attribute_keys(as_type) + _set_span_attributes_within_limit( + self._otel_span, + {LangfuseOtelSpanAttributes.OBSERVATION_TYPE: as_type}, + reserved_keys, + ) self._langfuse_client = langfuse_client self._observation_type = as_type @@ -330,19 +331,20 @@ def __init__( self._environment = ( existing_environment or environment or self._langfuse_client._environment ) - self._release = release or self._langfuse_client._release + if self._environment is not None: + _set_span_attributes_within_limit( + self._otel_span, + {LangfuseOtelSpanAttributes.ENVIRONMENT: self._environment}, + reserved_keys, + ) - initial_attributes = { - LangfuseOtelSpanAttributes.OBSERVATION_TYPE: as_type, - LangfuseOtelSpanAttributes.ENVIRONMENT: self._environment, - LangfuseOtelSpanAttributes.RELEASE: self._release, - } - reserved_keys = _reserved_attribute_keys(as_type) - _set_span_attributes_within_limit( - self._otel_span, - {k: v for k, v in initial_attributes.items() if v is not None}, - reserved_keys, - ) + self._release = release or self._langfuse_client._release + if self._release is not None: + _set_span_attributes_within_limit( + self._otel_span, + {LangfuseOtelSpanAttributes.RELEASE: self._release}, + reserved_keys, + ) # Handle media only if span is sampled if self._otel_span.is_recording(): @@ -389,7 +391,11 @@ def __init__( status_message=status_message, observation_type=cast( Optional[Union[ObservationTypeSpanLike, Literal["event"]]], - as_type if as_type in _SPAN_LIKE_OR_EVENT_TYPES else None, + as_type + if as_type + in get_observation_types_list(ObservationTypeSpanLike) + or as_type == "event" + else None, ), ) @@ -894,7 +900,9 @@ def update( observation_type=cast( Optional[Union[ObservationTypeSpanLike, Literal["event"]]], self._observation_type - if self._observation_type in _SPAN_LIKE_OR_EVENT_TYPES + if self._observation_type + in get_observation_types_list(ObservationTypeSpanLike) + or self._observation_type == "event" else None, ), ) diff --git a/tests/unit/test_metadata_attribute_limit.py b/tests/unit/test_metadata_attribute_limit.py index a3c9c79d1..e508bd147 100644 --- a/tests/unit/test_metadata_attribute_limit.py +++ b/tests/unit/test_metadata_attribute_limit.py @@ -14,7 +14,6 @@ from types import SimpleNamespace import pytest -from opentelemetry.sdk.trace import SpanLimits, TracerProvider from opentelemetry.trace import format_span_id from langfuse import propagate_attributes @@ -22,7 +21,6 @@ from langfuse._client.span import ( _RESERVED_OBSERVATION_ATTRIBUTE_KEYS, _RESERVED_SPAN_ATTRIBUTE_KEYS, - LangfuseSpan, _drop_attributes_over_span_limit, _reserved_attribute_keys, ) @@ -554,37 +552,9 @@ def test_metadata_budget_is_exact_at_the_limit(existing, attributes, dropped, ca assert len(_limit_warnings(caplog)) == (1 if dropped else 0) -def test_observation_type_is_kept_when_environment_and_release_do_not_fit( - langfuse_memory_client, caplog -): - tracer = TracerProvider(span_limits=SpanLimits(max_span_attributes=3)).get_tracer( - "test" - ) - otel_span = tracer.start_span("nearly-full", attributes={"a": 1, "b": 2}) - - LangfuseSpan( - otel_span=otel_span, - langfuse_client=langfuse_memory_client, - environment="staging", - release="v1", - ) - - assert dict(otel_span.attributes) == { - "a": 1, - "b": 2, - LangfuseOtelSpanAttributes.OBSERVATION_TYPE: "span", - } - warnings = _limit_warnings(caplog) - assert len(warnings) == 1 - assert ( - "Dropped keys include: langfuse.environment, langfuse.release" - in warnings[0].getMessage() - ) - - def test_hard_guard_never_raises(): class BrokenAttributes: - def __iter__(self): + def __contains__(self, key): raise RuntimeError("boom") def __len__(self): diff --git a/tests/unit/test_otel.py b/tests/unit/test_otel.py index 4d4cd6cdf..dfe1336fc 100644 --- a/tests/unit/test_otel.py +++ b/tests/unit/test_otel.py @@ -311,30 +311,6 @@ def test_basic_span_creation(self, langfuse_client, memory_exporter): assert span.id == span_data["span_id"] assert span.trace_id == span_data["trace_id"] - def test_environment_and_release_are_set_only_when_configured( - self, langfuse_client, memory_exporter - ): - langfuse_client._environment = "staging" - langfuse_client._release = "v1" - langfuse_client.start_observation(name="configured", as_type="generation").end() - langfuse_client._environment = None - langfuse_client._release = None - langfuse_client.start_observation(name="unconfigured").end() - - configured = self.get_spans_by_name(memory_exporter, "configured")[0][ - "attributes" - ] - assert configured[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] == "generation" - assert configured[LangfuseOtelSpanAttributes.ENVIRONMENT] == "staging" - assert configured[LangfuseOtelSpanAttributes.RELEASE] == "v1" - - unconfigured = self.get_spans_by_name(memory_exporter, "unconfigured")[0][ - "attributes" - ] - assert unconfigured[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] == "span" - assert LangfuseOtelSpanAttributes.ENVIRONMENT not in unconfigured - assert LangfuseOtelSpanAttributes.RELEASE not in unconfigured - def test_span_hierarchy(self, langfuse_client, memory_exporter): """Test creating nested spans and verify their parent-child relationships.""" # Create parent span