Repository navigation
perf(tracing): skip span attribute limit checks when the span has room - #2007
Merged
hassiebp merged 4 commits intoOct 16, 2026
Merged
Conversation
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.
DavidTraina
marked this pull request as ready for review
October 15, 2026 18:43
Co-authored-by: hassieb.pakzad <hassieb.pakzad@clickhouse.com>
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 <hassieb.pakzad@clickhouse.com>
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Follow-up to #1957.
Almost every SDK attribute write runs
_drop_attributes_over_span_limit. That covers observation creation,update(),propagate_attributes,LangfuseSpanProcessor.on_startfor every span with propagated attributes, and the root markers. Each call checkedkey not in span.attributesfor every new key and every reserved key (7 or 14).span.attributesis aMappingProxyTypeover OTel'sBoundedAttributes, which has no__contains__. Each miss goes throughMapping.__contains__, which raises and catches aKeyError. This ran on every write, even on spans far below the 128-attribute limit, and on the caller's thread, which is the event loop in async apps.Changes, all in
langfuse/_client/span.py:len(existing) + len(attributes) + len(reserved_keys) <= max_attributes. This is an upper bound of the exact count the guard already computes, so it cannot change a drop decision.Literalon every call.LangfuseObservationWrapper.__init__in one guarded call instead of three. The kept keys and their order are the same.Final span attributes, drop order, warning text and the never-raises contract are unchanged. Log output can differ only for the batched write in
__init__on a span with fewer than three free slots. There it logs at most one limit warning, where the three separate writes logged one per dropped key. On a span that has already ended, which ignores the write anyway, it can log one limit warning wheremainlogged none.Benchmark
Thread CPU time per operation on the caller thread, with a null exporter and no network. Each value is the median over 12 runs per checkout, alternating which checkout runs first. Environment: Apple M5 Max, Python 3.14.5, OTel SDK 1.45.0, client configured with
environmentandrelease. The machine was under load, so absolute numbers are inflated. The direction of each change was consistent across runs.start_observation(input, 15 metadata keys)+update(output)+endpropagate_attributes+ child generation withupdatestart_observation(input, 118 metadata keys)+update(output)+end(slow path, nothing dropped)_drop_attributes_over_span_limit, 1 key, span with 20 attributesupdate(output)on a span with 124 attributes_drop_attributes_over_span_limit, 1 key, span with 124 attributesWithout
environmentandreleaseconfigured, the first two scenarios improved by 33% and 23% over 8 runs.The trade-off is on spans close to the limit. A small write takes the slow path once the span is within about 8 attributes of the limit for span-like observations, or about 15 for generations. The slow path now copies the existing keys into a set. For a small write such as
update(output), that costs about 1 us more than probing a few keys. For a large write near the limit, it saves much more, as the 118-key row shows.Benchmark script
Run it from outside the repo with
PYTHONPATH=<checkout> python bench_attr_limit.py, solangfuseis imported from that checkout.Type of change
Verification
List the main commands you ran:
New tests:
test_metadata_budget_is_exact_at_the_limitpins the metadata budget at the exact limit. It includes overwrites and reserved keys that are already on the span or in the same write. An early return that is too loose by one fails it, and no existing test catches that.test_environment_and_release_are_set_only_when_configuredchecks that type, environment and release land on the span, and that unset environment and release stay absent. OTel 1.45 storesNoneattribute values, so passing them into the batched write would fail this test.test_observation_type_is_kept_when_environment_and_release_do_not_fitbuilds an observation on a span with one free slot. It checks that the observation type is kept and that a single warning names environment and release. A different key order in the batched write fails it.span.pyfrommain. The third gets the same attributes onmainbut fails on the warning count, sincemainlogs two warnings there.test_hard_guard_never_raisesnow raises from__iter__instead of__contains__, because the slow path iterates the attributes once instead of probing them.Not run:
tests/e2eandtests/live_provider, which need a server and provider keys. This change has no network behavior.Checklist
code_review.md..env.templateif needed. (n/a, internal-only change)

Confidence Score: 5/5
The PR appears safe to merge; no actionable issues were found.Summary
This PR reduces work when writing span attributes.
No actionable issues found. Tests were inspected but not run during this review.
DavidTraina explicitly acknowledged fewer warnings for batched drops and a possible extra limit warning on an ended span. These intentional differences were not reported.
Reviews (1) · Last reviewed commit: "test: keep the observation type when env..." · Reviewed by Greptile
What does this PR do?
Follow-up to #1957. Original analysis, fix, tests and benchmark by @DavidTraina.
Almost every SDK attribute write runs
_drop_attributes_over_span_limit. That covers observation creation,update(),propagate_attributes,LangfuseSpanProcessor.on_startfor every span with propagated attributes, and the root markers. Each call checkedkey not in span.attributesfor every new key and every reserved key (7 or 14).span.attributesis aMappingProxyTypeover OTel'sBoundedAttributes, which has no__contains__. So each miss goes throughMapping.__contains__, which raises and catches aKeyError. This ran on every write, even on spans far below the 128-attribute limit, and on the caller's thread, which is the event loop in async apps.The change is in
langfuse/_client/span.py. It returns early when the span clearly has room:Each term is at least as large as its counterpart in the exact count the guard already computes. That count covers new non-
Nonekeys not on the span, and reserved keys neither on the span nor in the write. So the early return can't change a drop decision. Spans near the limit take the existing path unchanged. Final attributes, drop order, warning text, warning count and the never-raises contract are identical tomain.The first version of this PR also snapshotted the existing keys into a set on the slow path, precomputed the span-like observation types, and batched the type, environment and release writes in
LangfuseObservationWrapper.__init__. These were dropped for simplicity. The early return carries almost all of the gain on common paths, and without the batched write the log output matchesmainexactly. The extras mainly helped metadata-heavy spans near the limit (about −14% vs −4% in the 118-key row), plus about 7 µs per observation whenenvironmentandreleaseare configured.Benchmark
Thread CPU time per operation on the caller thread, with a null exporter and no network, using David's script below. Linux VM, Python 3.13, OTel SDK 1.45.0. Each value is the median over 8 alternating runs per checkout, with the client configured with
environmentandrelease.start_observation(input, 15 metadata keys)+update(output)+endpropagate_attributes+ child generation withupdatestart_observation(input, 118 metadata keys)+update(output)+end_drop_attributes_over_span_limit, 1 key, span with 20 attributesupdate(output)on a span with 124 attributes_drop_attributes_over_span_limit, 1 key, span with 124 attributesWithout
environmentandrelease(BENCH_NO_ENV=1, 3 runs), the first two scenarios improved by 17% and 24%. The two rows near the limit are within noise ofmain, as expected, since they take the same path.Benchmark script (by @DavidTraina)
Run it from outside the repo with
PYTHONPATH=<checkout> python bench_attr_limit.py, solangfuseis imported from that checkout.Type of change
Verification
New test:
test_metadata_budget_is_exact_at_the_limitpins the metadata budget at the exact limit. It includes overwrites and reserved keys that are already on the span or in the same write. An early return that is too loose by one fails it (checked by loosening the bound tomax_attributes + 1), and no existing test catches that.Not run:
tests/e2eandtests/live_provider, which need a server and provider keys. This change has no network behavior.Checklist
code_review.md..env.templateif needed. (n/a, internal-only change)