Skip to content

perf: skip export-only work on unsampled spans - #40

Open
bengl wants to merge 1 commit into
nodejs:mainfrom
bengl:bengl/cheap-unsampled
Open

bengl wants to merge 1 commit into
nodejs:mainfrom
bengl:bengl/cheap-unsampled

Conversation

@bengl

@bengl bengl commented Nov 3, 2026

Copy link
Copy Markdown
Member

A trace that isn't sampled is dropped as a whole, but microtel still creates a span for every request in it, so the trace context reaches the services downstream and spans made while handling the request have a parent. Those spans were never exported, so the attributes, timestamps, and listeners they collected were wasted.

What changed. Each handler checks the span once, right after creating it, and an unsampled span skips everything only the export reads:

  • the attribute work: URL parsing, header reads, and setAttribute() calls, for HTTP/1 servers and clients, HTTP/2 servers, and undici
  • the attributes object, and the wall-clock and monotonic timestamps in the Span constructor and in end()
  • the span stashed on the response, request, or stream, so the response listeners, status, exception events, and error handling that only record onto the span never run

What propagation and parenting need stays: trace and span IDs, trace flags, tracestate and baggage, removing a stale tracestate from outgoing requests, making the span current for the request handler, and the self-trace check. A sampled span exports the same fields as before, behind one more branch, though it now reads its start time just after its trace context is set up rather than just before. A Span.isSampled getter exposes the decision.

Performance. On a quiet machine, against a copy of main, on Node 26.12.1. microtel's own CPU time in profiles of 50k traced requests at sampleRatio: 0, over 7 interleaved runs:

Client main This PR Change
HTTP 95.6 ms 63.8 ms -33%
fetch 81.7 ms 55.1 ms -33%

Sampled requests didn't change: 193.1 ms on main against 191.4 ms, over 8 interleaved runs at the default ratio.

Throughput of the new unsampled benchmarks, over 8 interleaved node:bench runs:

Benchmark main This PR Change
http request (traced, unsampled) 26.45k ops/s 27.54k ops/s +4.1%
fetch request (traced, unsampled) 19.77k ops/s 20.35k ops/s +2.9%
Raw samples
  • HTTP profile, main: 101.5, 111.5, 93.8, 95.6, 90.1, 88.6, 126.0 ms
  • HTTP profile, this PR: 84.0, 57.6, 63.8, 61.4, 63.1, 74.5, 80.8 ms
  • fetch profile, main: 81.7, 87.2, 81.9, 77.3, 75.2, 81.7, 77.8 ms
  • fetch profile, this PR: 53.5, 49.2, 52.3, 60.3, 55.1, 64.3, 55.1 ms
  • sampled HTTP profile, main: 191.6, 195.5, 191.0, 187.4, 201.5, 194.7, 202.2, 185.8 ms
  • sampled HTTP profile, this PR: 190.8, 192.1, 185.7, 198.7, 184.5, 185.5, 201.0, 195.7 ms
  • http unsampled throughput, main: 26.47k, 26.05k, 27.94k, 26.19k, 26.44k, 26.07k, 28.64k, 28.53k
  • http unsampled throughput, this PR: 28.65k, 29.57k, 27.20k, 27.55k, 27.52k, 27.42k, 30.80k, 26.66k
  • fetch unsampled throughput, main: 19.20k, 20.78k, 19.71k, 19.81k, 20.20k, 19.64k, 19.74k, 20.81k
  • fetch unsampled throughput, this PR: 20.42k, 19.86k, 20.04k, 21.26k, 20.62k, 20.28k, 18.57k, 20.96k

benchmark/http-unsampled.bench.js and benchmark/fetch-unsampled.bench.js are new, so the CI benchmark comparison tracks this configuration from now on. The shared benchmark helper now passes sampleRatio through to init().

Not taken. Lazy attribute or timestamp storage. An unsampled span simply has none of the state the export reads, behind one undefined check in setAttribute() and addEvent(), and every span keeps the same shape.

Tests. A child process at sampleRatio: 0 drives sampled and unsampled requests through an HTTP frontend, fetch, and HTTP/2, and reports what each hop saw. An unsampled request leaves no microtel listener on its responses, the same counts plain Node has, and the HTTP/2 stream and the fetch hold no span for the export-only handlers. A direct test checks that an unsampled span gets no timestamps, attributes, or events, and that end() only marks it ended. Its traceparent, tracestate, and baggage still propagate, replacing a caller's stale ones the same way a sampled request does, and only the request that arrived sampled exports anything.

Previously, a request whose trace was not sampled still got a full
span: attributes, wall-clock and monotonic timestamps, response
listeners, and status and exception recording. None of it could be
exported, since Span.end() buffers only sampled spans, so all of it was
wasted work that only propagation and parenting actually needed.

Now the handlers check the span once, right after it is created, and
skip everything from there that only serves the export: the attribute
work (URL parsing, header reads, setAttribute calls) for HTTP/1 servers
and clients, HTTP/2 servers, and undici, the timestamps in the Span
constructor and end(), and the response 'close' and 'end' listeners,
the stashed span, status, exception events, and error handling that
records onto the span. What propagation needs stays: trace and span
IDs, trace flags, tracestate and baggage, removing a stale tracestate
from outgoing requests, making the span current for the request
handler, and the self-trace suppression. A sampled span exports the
same fields as before, though it now reads its start time just after
its trace context is set up rather than just before.

An unsampled traced request now costs about a third less of
microtel's own time, for HTTP and fetch alike, in CPU profiles of 50k
requests. Sampled requests are unchanged.

Signed-off-by: Bryan English <bryan@bryanenglish.com>
@bengl
bengl marked this pull request as ready for review November 3, 2026 19:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant