feat(api, context-async): add experimental attach/detach functionality - #6845
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6845 +/- ##
==========================================
- Coverage 95.09% 95.02% -0.07%
==========================================
Files 409 409
Lines 14259 14333 +74
Branches 3269 3280 +11
==========================================
+ Hits 13559 13620 +61
- Misses 700 713 +13
🚀 New features to boost your workflow:
|
4c69fe2 to
fd17995
Compare
| * @since 1.10.0 | ||
| * @experimental This API is experimental and may change in minor releases without prior notice. | ||
| */ | ||
| public attach(context: Context): Token { |
There was a problem hiding this comment.
I wonder if we should expose the using symbols (specifically Symbol.dispose) rather than attach/detach directly: https://nodejs.org/docs/latest/api/async_context.html#class-runscope
It has the same behavior but users are advised to use it with using, advising callers to call attach/detach in pair.
There was a problem hiding this comment.
Hmm, I did not consider that yet because it's only available from Node.js 25.9.0+. I'll try to come up with a feature-detection version of this that uses withScope() instead but falls back to enterWith() + wrapper on older Node.js versions.
That way we could possibly also stop having detach() at all but just reference the dispose() operation.
There was a problem hiding this comment.
I added a benchmark script to check the two approaches; this is the result (only tested using Node.js 26 - let's assume we expect performance-minded people to upgrade Node.js):
current proposal (no dispose):
marc.pichler@DT-CFCW2WRFQM opentelemetry-context-async-hooks % nvm use 26
Now using node v26.4.0 (npm v11.17.0)
marc.pichler@DT-CFCW2WRFQM opentelemetry-context-async-hooks % npm run test:bench
> @opentelemetry/context-async-hooks@2.8.0 test:bench
> node test/performance/benchmark/attach.js
attach + dispose (single) x 7,232,167 ops/sec ±0.15% (1095 runs sampled)
attach + dispose (nested 3 levels) x 2,455,910 ops/sec ±0.19% (1093 runs sampled)
with() (baseline, single) x 6,894,493 ops/sec ±0.68% (1095 runs sampled)
with() (baseline, nested 3 levels) x 2,295,737 ops/sec ±0.37% (1090 runs sampled)
alternative proposal (dispose):
marc.pichler@DT-CFCW2WRFQM opentelemetry-context-async-hooks % nvm use 26
Now using node v26.4.0 (npm v11.17.0)
marc.pichler@DT-CFCW2WRFQM opentelemetry-context-async-hooks % npm run test:bench
> @opentelemetry/context-async-hooks@2.8.0 test:bench
> node test/performance/benchmark/attach.js
attach + dispose (single) x 7,030,738 ops/sec ±0.13% (1093 runs sampled)
attach + dispose (nested 3 levels) x 2,393,938 ops/sec ±0.17% (1094 runs sampled)
with() (baseline, single) x 6,777,455 ops/sec ±0.30% (1095 runs sampled)
with() (baseline, nested 3 levels) x 2,339,307 ops/sec ±0.26% (1096 runs sampled)
The differences are fairly small; looking at GC performance, obtained via this comparision script on Node.js 26.4.0:
Details
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/
'use strict';
/*
* GC-focused benchmark comparing two `attach`/`detach` token strategies for the
* AsyncLocalStorage context manager on a hot path:
*
* - `zero-alloc` : attach() returns the previous Context cast as a Token
* (no allocation); detach() calls enterWith(previous).
* (feat/context-attach-detach-asl)
* - `native-withscope` : attach() delegates to AsyncLocalStorage.withScope(),
* which allocates a native RunScope per call; the caller
* restores via token.dispose().
* (feat/context-attach-detach-asl-disposable, Node >= 25.9)
*
* The two strategies are replicated verbatim here over a shared AsyncLocalStorage
* rather than imported from either branch, so the whole comparison lives in one
* file.
*
* GC is measured via V8's `--trace-gc` output rather than perf_hooks: the measured
* loop is fully synchronous and CPU-bound, and a PerformanceObserver 'gc' callback
* never gets a chance to run (nor reliably retains buffered entries) across a
* blocked event loop. Instead, each (variant, scenario) runs in its own freshly
* started `node --expose-gc --trace-gc` child process; the worker brackets the
* measured loop with MEASURE_START / MEASURE_END markers on stderr, and the driver
* counts the GC trace lines that fall between those markers.
*
* Usage: `node attach-gc.js` (driver; self-spawns instrumented children).
*/
const fs = require('fs');
const { spawnSync } = require('child_process');
const { AsyncLocalStorage } = require('async_hooks');
const { performance } = require('perf_hooks');
const { createContextKey, ROOT_CONTEXT } = require('@opentelemetry/api');
// Fixed iteration counts (per scenario) so GC counts are comparable across variants.
const ITERATIONS = {
single: 20_000_000,
nested: 7_000_000,
};
const N_WARMUP = 200_000;
const VARIANTS = ['zero-alloc', 'native-withscope'];
const SCENARIOS = ['single', 'nested'];
const MEASURE_START = 'MEASURE_START';
const MEASURE_END = 'MEASURE_END';
// --- variant token strategies ------------------------------------------------
function makeVariant(name, als) {
if (name === 'zero-alloc') {
return {
attach(context) {
const previousContext = als.getStore() ?? ROOT_CONTEXT;
als.enterWith(context);
return previousContext;
},
detach(token) {
als.enterWith(token);
},
};
}
if (name === 'native-withscope') {
if (typeof als.withScope !== 'function') {
throw new Error(
'AsyncLocalStorage.withScope() is not available on this Node.js version'
);
}
return {
attach(context) {
return als.withScope(context);
},
detach(token) {
token.dispose();
},
};
}
throw new Error(`unknown variant: ${name}`);
}
// --- worker: measure one (variant, scenario) in this process -----------------
function runWorker(variantName, scenario) {
if (typeof global.gc !== 'function') {
throw new Error('worker must be run with --expose-gc');
}
const als = new AsyncLocalStorage();
const { attach, detach } = makeVariant(variantName, als);
const key = createContextKey('benchmark-key');
const context1 = ROOT_CONTEXT.setValue(key, 'value1');
const context2 = ROOT_CONTEXT.setValue(key, 'value2');
const context3 = ROOT_CONTEXT.setValue(key, 'value3');
const single = () => {
const token = attach(context1);
detach(token);
};
const nested = () => {
const token1 = attach(context1);
const token2 = attach(context2);
const token3 = attach(context3);
detach(token3);
detach(token2);
detach(token1);
};
const op = scenario === 'single' ? single : nested;
const iterations = ITERATIONS[scenario];
// Warm up so V8 JIT stabilizes before measuring.
for (let i = 0; i < N_WARMUP; i++) {
op();
}
// Clean baseline: run GC twice so the measured window starts from a settled heap.
global.gc();
global.gc();
// Markers bracket the window the driver attributes GC trace lines to. V8's
// --trace-gc output goes to stdout (fd 1), so the markers must too, and they
// MUST be written synchronously (fs.writeSync) so they interleave in the
// correct order with the synchronous trace output; an async-buffered
// process.stdout.write() would flush after the loop, placing both markers
// past every GC line and yielding a false zero.
fs.writeSync(1, `${MEASURE_START}\n`);
const start = performance.now();
for (let i = 0; i < iterations; i++) {
op();
}
const wallMs = performance.now() - start;
fs.writeSync(1, `${MEASURE_END}\n`);
process.stdout.write(
`RESULT ${JSON.stringify({
variant: variantName,
scenario,
iterations,
wallMs,
opsPerSec: (iterations / wallMs) * 1000,
node: process.version,
})}\n`
);
}
// --- driver: spawn one instrumented child per (variant, scenario) ------------
function parseGcBetweenMarkers(stderr) {
const gc = { minor: 0, major: 0, pauseMs: 0 };
let measuring = false;
for (const line of stderr.split('\n')) {
if (line.includes(MEASURE_START)) {
measuring = true;
continue;
}
if (line.includes(MEASURE_END)) {
measuring = false;
continue;
}
if (!measuring) continue;
const isMinor = /Scavenge|Minor Mark/.test(line);
const isMajor = !isMinor && /Mark-Compact|Mark-sweep|Full/.test(line);
if (!isMinor && !isMajor) continue;
// "... MB, 0.24 / 0.00 ms (average mu = ...)" -> first number is the pause.
const m = line.match(/([\d.]+)\s*\/\s*[\d.]+\s*ms/);
if (m) gc.pauseMs += parseFloat(m[1]);
if (isMinor) gc.minor++;
else gc.major++;
}
return gc;
}
function runDriver() {
const rows = [];
for (const variant of VARIANTS) {
for (const scenario of SCENARIOS) {
process.stderr.write(`running ${variant} / ${scenario} ...\n`);
const child = spawnSync(
process.execPath,
['--expose-gc', '--trace-gc', __filename, variant, scenario],
{ encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }
);
if (child.status !== 0) {
process.stderr.write(child.stderr || '');
throw new Error(
`${variant}/${scenario} exited with status ${child.status}`
);
}
const resultLine = child.stdout
.split('\n')
.find(l => l.startsWith('RESULT '));
if (!resultLine) {
process.stderr.write(child.stdout || '');
throw new Error(`${variant}/${scenario} produced no RESULT`);
}
const timing = JSON.parse(resultLine.slice('RESULT '.length));
// --trace-gc and the markers are both on stdout; parse GC lines there.
const gc = parseGcBetweenMarkers(child.stdout);
const gcCount = gc.minor + gc.major;
rows.push({
...timing,
...gc,
gcPausePct: (gc.pauseMs / timing.wallMs) * 100,
avgPauseMs: gcCount > 0 ? gc.pauseMs / gcCount : 0,
});
}
}
printComparison(rows);
}
function printComparison(rows) {
const node = rows[0] && rows[0].node;
console.log(`\nGC comparison for attach/detach (${node})`);
console.log(
`single: ${ITERATIONS.single.toLocaleString()} iters, ` +
`nested: ${ITERATIONS.nested.toLocaleString()} iters\n`
);
const cols = [
['variant', r => r.variant, 18],
['ops/sec', r => Math.round(r.opsPerSec).toLocaleString(), 14],
['minor GC', r => String(r.minor), 10],
['major GC', r => String(r.major), 10],
['GC pause ms', r => r.pauseMs.toFixed(1), 13],
['GC pause %', r => r.gcPausePct.toFixed(2), 12],
['avg pause ms', r => r.avgPauseMs.toFixed(3), 13],
];
for (const scenario of SCENARIOS) {
console.log(`── ${scenario} ──`);
console.log(cols.map(([h, , w]) => h.padEnd(w)).join(''));
for (const row of rows.filter(r => r.scenario === scenario)) {
console.log(cols.map(([, fn, w]) => fn(row).padEnd(w)).join(''));
}
console.log('');
}
}
// --- entrypoint ---------------------------------------------------------------
const [variantArg, scenarioArg] = process.argv.slice(2);
if (variantArg) {
runWorker(variantArg, scenarioArg || 'single');
} else {
runDriver();
}it seems that the extra object does not make a lot of impact since both use AsyncLocalStorage.enterWith() seems to completely dominate allocations.
running zero-alloc / single ...
running zero-alloc / nested ...
running native-withscope / single ...
running native-withscope / nested ...
GC comparison for attach/detach (v26.4.0)
single: 20,000,000 iters, nested: 7,000,000 iters
── single ──
variant ops/sec minor GC major GC GC pause ms GC pause % avg pause ms
zero-alloc 6,873,961 7134 0 165.0 5.67 0.023
native-withscope 6,832,059 7598 0 179.4 6.13 0.024
── nested ──
variant ops/sec minor GC major GC GC pause ms GC pause % avg pause ms
zero-alloc 2,299,700 7490 0 175.3 5.76 0.023
native-withscope 2,224,195 7196 0 173.5 5.51 0.024
TL;DR: I think moving to withScope() makes sense, the perf trade-off is negligible. I'll push a commit tomorrow.
There was a problem hiding this comment.
Ah - still need to make sure that we bump the typescript version. I'll try my luck with that on a different PR:
#6888
There was a problem hiding this comment.
Symbol.dispose not being widely available massively complicates things and I needed to do a bunch of testing. I'll push the updates this week.
There was a problem hiding this comment.
@legendecas - unfortunately I had to undo the disposable implementation as we currently say that we support ES2022. If we include it, all consumers would have to pull in esnext.disposable in their tsconfigs if we were to use Symbol.disposable in our public API types, which can be a large breaking change for those with skipLibCheck: false.
Unfortunately it looks like Symbol.disposable will only be included in ES2026 (looking at https://github.com/tc39/proposal-explicit-resource-management); tc39/ecma262#3000 only merged in June this year.
I now structured the diff in a way that we can still add it later if we decide to change our support policies.
Pull request dashboard statusMerged · refreshed 2026-08-20 14:26 UTC Status above doesn't look right?
|
…nd make Token disposable
trentm
left a comment
There was a problem hiding this comment.
LGTM. Thanks, Marc!
I gather your subscriberWithContextManagement utility from https://github.com/open-telemetry/opentelemetry-js/pull/6387/changes#diff-516a26260144caf8cae79053916963fc7c5ca8aa24771ccc4d022b532c013b4c will need to be updated to use token.dispose().
Have you played with that?
| * @since 1.10.0 | ||
| * @experimental This API is experimental and may change in minor releases without prior notice. | ||
| */ | ||
| export interface Token { |
There was a problem hiding this comment.
I'm afraid that the name Token is too generic. Maybe like DisposableToken?
There was a problem hiding this comment.
I renamed it ContextManagementToken since I had to undo the disposable part. 😞
Makes it a bit less generic while not promising that it's disposable.
|
Hi @pichlermarc — just a friendly reminder that this pull request is waiting on you. There are still items that need your attention. See the dashboard status comment for the full list. You don't need to push a code change to hand it back — replying to move each discussion forward is enough, whether that's answering a question, explaining why no change is needed, or asking a follow-up. The dashboard then automatically routes it back to reviewers. If you believe this pull request is incorrectly routed as waiting on the author, comment |
Thanks, I did play with that and it still works - syntax for detach changes from |
| this._asyncLocalStorage = asyncLocalStorage; | ||
| } | ||
|
|
||
| dispose() { |
There was a problem hiding this comment.
One caveat with the example in the README.md is that using _ = new DisposeOnceToken(...) would throw, because it does not implement Symbol.dispose. Though, in Node.js versions where AsyncLocalStorage.withScope is available, this is not an issue.
There was a problem hiding this comment.
Good catch! I removed the example from the readme: 453f52b
There was a problem hiding this comment.
Thanks, hopefully https://github.com/nodejs/node/pull/65291 would port the feature to Node.js v24 soon.
e8d1927
Which problem is this PR solving?
Implements the spec-defined optional
attach/detachglobal context operations for@opentelemetry/apiandAsyncLocalStorageContextManager.The existing
with()/bind()APIs are scoped and restore context automatically and are the right tool for the vast majority of cases. However, some callback-based APIs (e.g. Node.jsdiagnostics_channel/TracingChannel) emit events at callback boundaries thatwith()cannot deal with.attach/detachexist specifically for those situations: the caller manually sets context at the start of an operation and restores it at the end.attach/detachare intentionally designed as a low-level, last-resort feature. Sinceattach/detachare low-level primitives, I made some design decisions to avoid introducing extra overhead / breaking changes for users:1. Token has a
dispose()method to implementdetachbehavior:The token returned by
attach()implementsdispose()to restore the previous context:The token deliberately does not implement
[Symbol.dispose]()- doing so would require adding"esnext.disposable"to the TypeScriptlibconfiguration, which violates the project's>=ES2022support policy.Originally the plan was a zero-alloc approach - returning the previous
Contextcast as aToken(no extra object allocation). After adding a GC benchmark to compare both approaches,AsyncLocalStorage.enterWith()turned out to completely dominate the allocation profile, making the extraTokenobject allocation negligible. Benchmark results for Node.js 26 (20M single / 7M nested iterations):By having this be an object, we can extend it later to implement
Disposableonce we've changed the project's support policies to includeSymbol.disposable, which means we're not fully locked out of supporting it in the future.2. Runtime-adaptive implementation in
AsyncLocalStorageContextManager:attach()delegates toAsyncLocalStorage.withScope(), which returns a nativeRunScopethat already implementsdispose().enterWith()with a manualDisposeOnceTokenwrapper. This fallback can be dropped once the package's minimum supported Node.js version reaches 25.9.3. optional
attachonContextManager:The method is marked optional (
attach?) for backward compatibility with existing custom context managers. TheContextAPIcompensates for that: if the active manager does not implementattach, it logs a warn-once diagnostic (via_attachUnsupportedWarned) rather than throwing.Ref (issue) #6088
Ref (draft PR including tracing channel example, zone and stack context manager impl) #6387
Type of change
How Has This Been Tested?
Additional notes:
I used Claude Opus 4.8 to generate some of the code/documentation but had to delete a bunch of it. It does struggle quite a bit with async context so it needs a lot of guidance. If you use an AI-Agent during the review, I recommend telling it to try and write tests to prove its points; I've had good success with that 🙂