Skip to content

feat(api, context-async): add experimental attach/detach functionality - #6845

Merged
pichlermarc merged 17 commits into
open-telemetry:mainfrom
dynatrace-oss-contrib:feat/context-attach-detach-asl
Aug 20, 2026
Merged

feat(api, context-async): add experimental attach/detach functionality#6845
pichlermarc merged 17 commits into
open-telemetry:mainfrom
dynatrace-oss-contrib:feat/context-attach-detach-asl

Conversation

@pichlermarc

@pichlermarc pichlermarc commented Jun 24, 2026

Copy link
Copy Markdown
Member

Which problem is this PR solving?

Implements the spec-defined optional attach/detach global context operations for @opentelemetry/api and AsyncLocalStorageContextManager.

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.js diagnostics_channel / TracingChannel) emit events at callback boundaries that with() cannot deal with. attach/detach exist specifically for those situations: the caller manually sets context at the start of an operation and restores it at the end.

attach/detach are intentionally designed as a low-level, last-resort feature. Since attach/detach are low-level primitives, I made some design decisions to avoid introducing extra overhead / breaking changes for users:

1. Token has a dispose() method to implement detach behavior:

The token returned by attach() implements dispose() to restore the previous context:

const token = context.attach(ctx);
try {
  // ...
} finally {
  token.dispose(); // restores the previous context
}

The token deliberately does not implement [Symbol.dispose]() - doing so would require adding "esnext.disposable" to the TypeScript lib configuration, which violates the project's >=ES2022 support policy.

Originally the plan was a zero-alloc approach - returning the previous Context cast as a Token (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 extra Token object allocation negligible. Benchmark results for Node.js 26 (20M single / 7M nested iterations):

── single ──
variant           ops/sec       minor GC  major GC  GC pause ms  GC pause %
zero-alloc        6,873,961     7134      0         165.0        5.67
native-withscope  6,832,059     7598      0         179.4        6.13

── nested ──
variant           ops/sec       minor GC  major GC  GC pause ms  GC pause %
zero-alloc        2,299,700     7490      0         175.3        5.76
native-withscope  2,224,195     7196      0         173.5        5.51

By having this be an object, we can extend it later to implement Disposable once we've changed the project's support policies to include Symbol.disposable, which means we're not fully locked out of supporting it in the future.

2. Runtime-adaptive implementation in AsyncLocalStorageContextManager:

  • Node.js 25.9+: attach() delegates to AsyncLocalStorage.withScope(), which returns a native RunScope that already implements dispose().
  • Older Node.js: falls back to enterWith() with a manual DisposeOnceToken wrapper. This fallback can be dropped once the package's minimum supported Node.js version reaches 25.9.

3. optional attach on ContextManager:

The method is marked optional (attach?) for backward compatibility with existing custom context managers. The ContextAPI compensates for that: if the active manager does not implement attach, 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

  • New feature (non-breaking change which adds functionality)

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 🙂

@codecov

codecov Bot commented Jun 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.10345% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.02%. Comparing base (f278e3b) to head (453f52b).
⚠️ Report is 25 commits behind head on main.

Files with missing lines Patch % Lines
api/src/api/context.ts 90.90% 1 Missing ⚠️
...async-hooks/src/AsyncLocalStorageContextManager.ts 93.75% 1 Missing ⚠️
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     
Files with missing lines Coverage Δ
api/src/context/NoopContextManager.ts 100.00% <100.00%> (ø)
api/src/api/context.ts 91.42% <90.90%> (-0.24%) ⬇️
...async-hooks/src/AsyncLocalStorageContextManager.ts 96.96% <93.75%> (-3.04%) ⬇️

... and 8 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pichlermarc
pichlermarc force-pushed the feat/context-attach-detach-asl branch from 4c69fe2 to fd17995 Compare June 24, 2026 10:49
@pichlermarc
pichlermarc marked this pull request as ready for review June 24, 2026 10:50
@pichlermarc
pichlermarc requested review from a team as code owners June 24, 2026 10:50
Comment thread api/src/api/context.ts Outdated
* @since 1.10.0
* @experimental This API is experimental and may change in minor releases without prior notice.
*/
public attach(context: Context): Token {

@legendecas legendecas Jun 24, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@pichlermarc pichlermarc Jul 6, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah - still need to make sure that we bump the typescript version. I'll try my luck with that on a different PR:
#6888

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in d085acd

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

@opentelemetry-pr-dashboard

opentelemetry-pr-dashboard Bot commented Jul 6, 2026

Copy link
Copy Markdown

Pull request dashboard status

Merged · refreshed 2026-08-20 14:26 UTC

Status above doesn't look right?
  • Anything look wrong? Report it with what you expected; it helps us improve the dashboard.

@trentm trentm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread packages/opentelemetry-context-async-hooks/README.md Outdated
Comment thread packages/opentelemetry-context-async-hooks/README.md
Comment thread api/src/context/types.ts Outdated
* @since 1.10.0
* @experimental This API is experimental and may change in minor releases without prior notice.
*/
export interface Token {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm afraid that the name Token is too generic. Maybe like DisposableToken?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@opentelemetry-pr-dashboard

opentelemetry-pr-dashboard Bot commented Jul 29, 2026

Copy link
Copy Markdown

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 /dashboard route:reviewers to route it from waiting on the author to waiting on reviewers.

@pichlermarc

Copy link
Copy Markdown
Member Author

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?

Thanks, I did play with that and it still works - syntax for detach changes from context.detach(token) to token.dispose(), but semantics stay the same. :)

this._asyncLocalStorage = asyncLocalStorage;
}

dispose() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! I removed the example from the readme: 453f52b

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, hopefully https://github.com/nodejs/node/pull/65291 would port the feature to Node.js v24 soon.

@pichlermarc
pichlermarc added this pull request to the merge queue Aug 20, 2026
Merged via the queue into open-telemetry:main with commit e8d1927 Aug 20, 2026
29 of 31 checks passed
@pichlermarc
pichlermarc deleted the feat/context-attach-detach-asl branch August 20, 2026 14:12
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.

3 participants