MEDIUM: otel: fast path for non-recording (sampled-out) streams - #8
Open
ygkat wants to merge 1 commit into
Open
Conversation
When the sampler decides not to record a trace, the root span is a
non-recording span and everything attached to it is discarded by the SDK.
The filter nevertheless kept doing all of its per-scope work for the rest
of the stream: evaluating attribute/event/baggage/link/status samples,
creating and finishing every child span, recording exceptions. Only the
export was saved, so a sampled-out stream cost almost as much as a fully
recorded one: with a 9-span scope set (HAProxy 3.4.2, h2load -c 64,
nbthread 4, loopback origin) 'trace_id_ratio_based' ratio 0.0 -- nothing
exported at all -- still cut the maximum throughput from 108k to 25k req/s.
'rate-limit' bounds the filter's cost on the first hop, but it does not
cover the sampler path: a stream it leaves out emits no 'traceparent' at
all, and every downstream HAProxy that extracts a parent context with
sampled=0 goes through the non-recording path whatever its own rate-limit
is.
The C wrapper already exposes 'is_recording' for this purpose, so use it:
- the runtime context remembers whether the root span is recording
('flag_norec') and keeps a pointer to the root span;
- flt_otel_scope_run_span() sets the flag right after a root span has
been created and turns out not to be recording;
- flt_otel_scope_run() then skips the spans that do not inject a
context and evaluates no attribute, event, baggage, link or status
sample for the spans it still creates; exceptions are skipped as well;
- flt_otel_scope_span_init() resolves a skipped parent to the root span,
so that the injecting span is still created (non-recording, sampled
flag cleared) and 'traceparent' keeps reaching the downstream
services, which therefore follow the sampling decision.
With the same benchmark ratio 0.0 goes from 25.1k to 52.3k req/s (2.08x)
and ratio 0.1 from 22.4k to 38.2k (1.7x); ratio 1.0 is unchanged as the
fast path is never taken. The remaining cost is the single non-recording
root span itself (about 7 us per request, mostly in the C wrapper), which
cannot be avoided as long as the sampling decision is made inside
StartSpan.
'finish' of a skipped span only triggers the existing debug warning.
'rate-limit', 'otel-stop' and 'require-context' are unaffected.
ygkat
force-pushed
the
norec-fast-path
branch
from
September 4, 2026 02:03
8f0cc70 to
37ddbcd
Compare
Member
|
Hello @ygkat, Thanks for taking the time to open this PR and address this issue! I reviewed the approach and decided to implement it a bit differently, so I’ve added my own commit to address the same problem. I really appreciate you taking the initiative to look into this and put together a solution. Even though I went with a different implementation, your PR helped highlight the issue and get it on my radar. I’ve also pushed my alternative implementation to the pr-8 branch b3a8ae5 if you’d like to take a look at it. Feel free to compare the approaches and let me know what you think! Thanks again for the contribution and for taking the time to improve the project! |
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.
Summary
When the sampler decides not to record a trace, the root span is a non-recording span and everything attached to it is discarded by the SDK. The filter nevertheless keeps doing all of its per-scope work for the rest of the stream: it evaluates attribute/event/baggage/link/status samples, creates and finishes every child span and records exceptions. Only the export is saved, so a sampled-out stream costs almost as much as a fully recorded one.
rate-limitis the documented way to bound the filter's cost, and it works well for the first hop. It does not cover the sampler path, though: a stream left out byrate-limitemits notraceparentat all (downstream services start their own traces), and every downstream HAProxy that extracts a parent context withsampled=0goes through the non-recording path regardless of its ownrate-limit. This PR makes that path cheap by usingis_recording, which the C wrapper already exposes for exactly this purpose.Measurements
HAProxy 3.4.2, filter v2.2.0, wrapper v3.3.0, SDK 1.28.0,
nbthread 4,h2load --h1 -c 64 -n 100000, loopback origin (http-request return), 9-span scope set with header injection.Changes
include/scope.h:flt_otel_runtime_contextgainsflag_norecandroot_span.src/event.cflt_otel_scope_run_span(): after a root span is created, queryis_recording; if it is not recording, setflag_norecand remember the root.flt_otel_scope_run(): withflag_norecset, spans that do not inject a context are skipped entirely, and no attribute/event/baggage/link/status sample is evaluated for the spans that are still created; exceptions are skipped as well.src/scope.cflt_otel_scope_span_init(): withflag_norecset, a parent that was skipped resolves to the root span instead of failing.Context propagation is preserved: injecting spans are still created (non-recording, sampled flag cleared), so
traceparentkeeps reaching downstream services and they follow the sampling decision.finishof a skipped span only triggers the existing debug warning.rate-limit,otel-stopandrequire-contextare unaffected.Verification
http-after-response otel-groupscopes), downstream receivestraceparentwithsampled=0, the collector receives no spans.Remaining cost and possible follow-ups
A filter that is attached but creates no span runs at the no-filter baseline (101k), while a single non-recording root span costs about 7 µs per request (60.7k). That root span is unavoidable as long as the sampling decision is made inside
StartSpan;perfattributes most of its cost to the C wrapper (span-handle table lookups,shared_ptrcopies of Tracer/Span/Context, amake_shared<Context>per StartSpan) rather than to the SDK. Two follow-ups would close the remaining gap, both out of scope here: