Repository navigation
Description
Now that npm run bench exists (#16), the cost of attribution is a number rather than a claim:
node v24 · 20,000 queries × 9 samples (median)
detector disabled 20 ns/query — the floor
captureStack: false 1488 ns/query +1468 ns detection without attribution
captureStack: true (default) 12476 ns/query +12456 ns stackDepth 30, the shipped default
captureStack, stackDepth: 8 9869 ns/query +9849 ns a shallower walk
instrumentDrizzle, 4-call chain 48579 ns/query +48559 ns one capture per chained call
Attribution is roughly 90% of what the detector costs, and instrumentDrizzle pays it four times over because the #12 fix captures on every chained call rather than once.
None of this is a crisis — it is development-only, and fifty queries in a request still costs under a millisecond. But an order of magnitude is worth attacking, and the numbers point at something specific.
Where the time goes
captureCallSite() reads new Error().stack, and that getter formats every frame into a string before we parse it back apart with a regex. V8 can hand over structured frames instead, via Error.prepareStackTrace.
I measured that, because it is the obvious first idea:
Error().stack (string) depth 30 10158 ns
prepareStackTrace (structured) d30 5752 ns
prepareStackTrace, read 1 app frame 6848 ns
About a third off, not the order of magnitude the trick is usually credited with. Worth having, and worth knowing the ceiling before anyone spends a weekend on it.
The catch, which is the real design problem
Error.prepareStackTrace is a global. source-map-support, Sentry and several test runners install their own, and that is how a TypeScript project gets original .ts line numbers out of compiled .js. Swapping it out for the duration of a capture — even synchronously, even restoring it afterwards — bypasses theirs, so the reported line would become the compiled position.
For this library that trade is close to unacceptable: pointing at dist/routes/orders.js:412 instead of src/routes/orders.ts:38 gives back in confusion more than it saves in microseconds. Any PR here has to say what it does about that. Detecting whether another prepareStackTrace is installed and falling back to the string path is one answer; there may be better ones.
Other angles, possibly better
- Cache by call site. A loop hits the same line thousands of times. If a cheap key could identify "same place as last time" without a full walk, the capture could be skipped — but finding that key without walking the stack is the whole problem.
- Reduce the chain captures.
instrumentDrizzlecaptures per chained call because the last one before execution is the useful one. Capturing only the first and the last, or capturing lazily only when a finding is actually created, might get the same answer for a quarter of the cost. Note that a finding is created after the fact, when the stack is long gone — so "lazily" needs an idea. - Lower
stackDepth. The table shows depth 8 saves ~20%. It is already the chain default (CHAIN_DEPTH); making it the global default would break attribution for deeply-wrapped drivers.
Acceptance
- A measured improvement, shown with
npm run benchbefore and after on the same machine - Source-mapped line numbers still correct in a TypeScript project — a test or a demonstration, not an assurance
- No change to which line is reported in any existing test
Please post numbers before writing much code. It is entirely possible the honest outcome here is "measured, not worth the complexity", and that is a useful result too.
Activity
Release 0.7.0: value sampling, a benchmark, and Kysely measured (#28)