-
Notifications
You must be signed in to change notification settings - Fork 8
fix(otel-thread-ctx): feature-detect AsyncContextFrame #397
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| /* | ||
| * Copyright 2026 Datadog, Inc | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| import {AsyncLocalStorage} from 'node:async_hooks'; | ||
|
|
||
| let active: boolean | undefined; | ||
|
|
||
| /** | ||
| * Whether this process's `AsyncLocalStorage` is backed by AsyncContextFrame, | ||
| * which is what puts the active value in the isolate's | ||
| * ContinuationPreservedEmbedderData slot that this addon reads. | ||
| * | ||
| * Feature-detected rather than inferred from the Node version plus | ||
| * `process.execArgv`, because the two disagree in both directions and each | ||
| * combination is reachable today: | ||
| * | ||
| * - `NODE_OPTIONS=--experimental-async-context-frame` is accepted on Node 22 | ||
| * and 23 and turns ACF on without appearing in `execArgv`. Inferring "off" | ||
| * there makes callers refuse to run in a process that would have worked. | ||
| * - `NODE_OPTIONS=--no-async-context-frame` is accepted on Node 24 and turns | ||
| * ACF off without appearing in `execArgv`. Inferring "on" there is the worse | ||
| * error: the CPED slot is never written, so a writer that starts anyway keeps | ||
| * looking healthy from JS — `getStore()` still works — while every | ||
| * out-of-process reader sees a record that nothing ever updates. | ||
| * - A worker thread created with an explicit `execArgv` doesn't inherit the | ||
| * main thread's command line either, and tooling sometimes rewrites | ||
| * `process.execArgv` outright. | ||
| * | ||
| * With ACF, `run()` is implemented in terms of `enterWith()`; without it, it | ||
| * isn't. Memoized: the answer is fixed for the life of the thread. | ||
| */ | ||
| export function isAsyncContextFrameActive(): boolean { | ||
| if (active === undefined) { | ||
| const probe = new AsyncLocalStorage<number>(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: why not retrieve CPED natively from addon and then check it from JS instead of relying on
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I just lifted the approach used in dd-trace-js, which has no native add-ons so it uses the best it can observe from pure JS. You're right that here we could do a native check for the real behavior. I might follow up with a change for it.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @nsavoire let me pick your brains about approaches I'm thinking of. We already have a check in the setter path that's: auto cped = isolate->GetContinuationPreservedEmbedderData();
// No Node AsyncContextFrame in this continuation yet
if (!cped->IsMap()) return;Do you think "is CPED a Map" would be sufficient to reproduce as a separate little native helper? Then the check would look like: const probe = new AsyncLocalStorage<number>();
let cpedIsMap = false;
probe.run(0, () => { cpedIsMap = pprof.cpedIsMap(); });
probe.disable();
active = cpedIsMap;or do we want to have to check that the right value gets bound? We could have a native method that returns CPED so we can check if it's a map and also check the values. const probe = new AsyncLocalStorage<number>();
let acfWorks = false;
const value = {};
probe.run(value, () => {
const acf = pprof.getCped();
acfWorks = acf instanceof Map && acf.get(probe) === value;
});
probe.disable();
active = acfWorks;If that looks too dangerous of a method to expose, we can also do a more constrained pure query const probe = new AsyncLocalStorage<number>();
let acfWorks = false;
const value = {};
probe.run(value, () => { acfWorks = pprof.cpedMapContains(probe, value); });
probe.disable();
active = acfWorks;So basically, the choice is between |
||
| let delegated = false; | ||
| probe.enterWith = () => { | ||
| delegated = true; | ||
| }; | ||
| probe.run(0, () => {}); | ||
| probe.disable(); | ||
| active = delegated; | ||
| } | ||
| return active; | ||
| } | ||
|
|
||
| /** | ||
| * How to turn AsyncContextFrame on, for the error message of whatever declined | ||
| * to run without it. | ||
| * | ||
| * Advisory text only — never decide availability from this. That is what | ||
| * {@link isAsyncContextFrameActive} is for. | ||
| */ | ||
| export function asyncContextFrameHint(): string { | ||
| const version = process.versions.node; | ||
| const major = Number(version.split('.')[0]); | ||
| if (major < 22) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: cut off is < 22.7.0 |
||
| return `Node ${version} does not support it at all; Node 24 and later enable it by default`; | ||
| } | ||
| if (major < 24) { | ||
| return `Node ${version} needs --experimental-async-context-frame, on the command line or in NODE_OPTIONS; Node 24 and later enable it by default`; | ||
| } | ||
| return `Node ${version} enables it by default, so something turned it off — look for --no-async-context-frame on the command line, in NODE_OPTIONS, or in this worker's execArgv`; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| /* | ||
| * Copyright 2026 Datadog, Inc | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| // Reports how this process sees AsyncContextFrame, for test-async-context-frame | ||
| // to compare against how the flags reached it. Also reports execArgv, so a | ||
| // failure shows whether the flag was visible there at all. | ||
|
|
||
| import {isAsyncContextFrameActive} from '../src/async-context-frame'; | ||
|
|
||
| process.send?.({ | ||
| active: isAsyncContextFrameActive(), | ||
| execArgv: process.execArgv, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| /* | ||
| * Copyright 2026 Datadog, Inc | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| import {strict as assert} from 'assert'; | ||
| import {fork} from 'node:child_process'; | ||
| import {join} from 'node:path'; | ||
|
|
||
| import {isAsyncContextFrameActive} from '../src/async-context-frame'; | ||
|
|
||
| const CHILD = join(__dirname, 'async-context-frame-child.js'); | ||
|
|
||
| const major = Number(process.versions.node.split('.')[0]); | ||
|
|
||
| interface ChildReport { | ||
| active: boolean; | ||
| execArgv: string[]; | ||
| } | ||
|
|
||
| // Runs the probe in a child process configured the way the test wants, since | ||
| // AsyncContextFrame is decided at process start and can't be toggled in-process. | ||
| function probeChild( | ||
| options: {execArgv?: string[]; nodeOptions?: string} = {}, | ||
| ): Promise<ChildReport> { | ||
| return new Promise((resolve, reject) => { | ||
| const child = fork(CHILD, [], { | ||
| execArgv: options.execArgv ?? [], | ||
| env: options.nodeOptions | ||
| ? {...process.env, NODE_OPTIONS: options.nodeOptions} | ||
| : {...process.env, NODE_OPTIONS: ''}, | ||
| stdio: ['ignore', 'ignore', 'pipe', 'ipc'], | ||
| }); | ||
| let report: ChildReport | undefined; | ||
| let stderr = ''; | ||
| child.stderr?.on('data', chunk => { | ||
| stderr += chunk; | ||
| }); | ||
| child.on('message', message => { | ||
| report = message as ChildReport; | ||
| }); | ||
| child.on('error', reject); | ||
| child.on('exit', code => { | ||
| if (report === undefined) { | ||
| reject( | ||
| new Error( | ||
| `child exited with ${code} and no report; stderr: ${stderr}`, | ||
| ), | ||
| ); | ||
| return; | ||
| } | ||
| resolve(report); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| describe('isAsyncContextFrameActive', () => { | ||
| it('gives the same answer on every call', () => { | ||
| const first = isAsyncContextFrameActive(); | ||
| assert.equal(typeof first, 'boolean'); | ||
| assert.equal(isAsyncContextFrameActive(), first); | ||
| }); | ||
|
|
||
| it('reports it active when Node enables it by default', async function () { | ||
| if (major < 24) return this.skip(); | ||
| const {active} = await probeChild(); | ||
| assert.equal(active, true); | ||
| }); | ||
|
|
||
| it('reports it inactive when Node has no support for it', async function () { | ||
| if (major >= 22) return this.skip(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: cut off is |
||
| const {active} = await probeChild(); | ||
| assert.equal(active, false); | ||
| }); | ||
|
|
||
| it('reports it inactive when the command line turns it off', async function () { | ||
| // The flag only exists from Node 24, where ACF is the default. | ||
| if (major < 24) return this.skip(); | ||
| const {active} = await probeChild({ | ||
| execArgv: ['--no-async-context-frame'], | ||
| }); | ||
| assert.equal(active, false); | ||
| }); | ||
|
|
||
| it('reports it inactive when NODE_OPTIONS turns it off', async function () { | ||
| // The regression this detection exists for: Node 24 accepts the flag in | ||
| // NODE_OPTIONS, where it does not reach execArgv, so inferring from execArgv | ||
| // concludes ACF is on. It is off, the CPED slot is never written, and a | ||
| // caller that trusted the inference would emit records nothing updates. | ||
| if (major < 24) return this.skip(); | ||
| const {active, execArgv} = await probeChild({ | ||
| nodeOptions: '--no-async-context-frame', | ||
| }); | ||
| assert.deepEqual(execArgv, []); | ||
| assert.equal(active, false); | ||
| }); | ||
|
|
||
| it('reports it active when NODE_OPTIONS turns it on', async function () { | ||
| // The mirror image, on the other Node line: 22 and 23 accept the flag in | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 22 -> 22.7.0 |
||
| // NODE_OPTIONS (24 rejects it outright), again without it reaching execArgv, | ||
| // so inferring from execArgv concludes ACF is off when it is on — and the | ||
| // caller refuses to run in a process that would have worked. | ||
| if (major < 22 || major >= 24) return this.skip(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: cut off is |
||
| const {active, execArgv} = await probeChild({ | ||
| nodeOptions: '--experimental-async-context-frame', | ||
| }); | ||
| assert.deepEqual(execArgv, []); | ||
| assert.equal(active, true); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -31,14 +31,13 @@ import {join} from 'path'; | |||||
| import {AsyncLocalStorage} from 'async_hooks'; | ||||||
| import {satisfies} from 'semver'; | ||||||
|
|
||||||
| import {isAsyncContextFrameActive} from '../src/async-context-frame'; | ||||||
|
|
||||||
| const findBinding = require('node-gyp-build'); | ||||||
| const profiler = findBinding(join(__dirname, '..', '..')); | ||||||
|
|
||||||
| const useCPED = | ||||||
| (satisfies(process.versions.node, '>=24.0.0') && | ||||||
| !process.execArgv.includes('--no-async-context-frame')) || | ||||||
| (satisfies(process.versions.node, '>=22.7.0') && | ||||||
| process.execArgv.includes('--experimental-async-context-frame')); | ||||||
| isAsyncContextFrameActive() && satisfies(process.versions.node, '>=22.7.0'); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
|
||||||
| const supportedPlatform = | ||||||
| process.platform === 'darwin' || process.platform === 'linux'; | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,6 +30,7 @@ import {fork, spawnSync} from 'node:child_process'; | |
| import {existsSync} from 'node:fs'; | ||
| import {join} from 'node:path'; | ||
|
|
||
| import {isAsyncContextFrameActive} from '../src/async-context-frame'; | ||
| import { | ||
| ThreadContext, | ||
| getContext, | ||
|
|
@@ -61,21 +62,12 @@ function tcIsTruncated(): boolean { | |
| } | ||
|
|
||
| const isLinux = process.platform === 'linux'; | ||
| // AsyncContextFrame (the writer's discovery substrate) is opt-in on Node | ||
| // 22/23 (via --experimental-async-context-frame) and on by default in | ||
| // Node 24+ (disable-able via --no-async-context-frame). The TS layer | ||
| // refuses to install the hook when ACF isn't available, so the entire | ||
| // describe block is skipped in that case. Mirrors the source-side | ||
| // asyncContextFrameError logic. | ||
| const isAsyncContextFrameAvailable = (() => { | ||
| if (process.execArgv.includes('--no-async-context-frame')) return false; | ||
| const major = Number(process.versions.node.split('.')[0]); | ||
| if (major >= 24) return true; | ||
| if (major >= 22) { | ||
| return process.execArgv.includes('--experimental-async-context-frame'); | ||
| } | ||
| return false; | ||
| })(); | ||
| // AsyncContextFrame is the writer's discovery substrate: opt-in on Node 22/23 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 22 -> 22.7.0 |
||
| // (via --experimental-async-context-frame) and on by default from Node 24 | ||
| // (disable-able via --no-async-context-frame). The TS layer refuses to install | ||
| // the hook when it isn't active, so the entire describe block is skipped then. | ||
| // Asks the same question the source side asks, the same way. | ||
| const isAsyncContextFrameAvailable = isAsyncContextFrameActive(); | ||
|
|
||
| // Returns a plain Uint8Array (not a Buffer) so assert.deepStrictEqual against | ||
| // other Uint8Arrays — including the one the addon returns — succeeds. | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -15,6 +15,7 @@ | |||||
| */ | ||||||
|
|
||||||
| import * as sinon from 'sinon'; | ||||||
| import {isAsyncContextFrameActive} from '../src/async-context-frame'; | ||||||
| import {time, getNativeThreadId} from '../src'; | ||||||
| import {profileV2, stopV2} from '../src/time-profiler'; | ||||||
| import * as v8TimeProfiler from '../src/time-profiler-bindings'; | ||||||
|
|
@@ -32,10 +33,7 @@ import {fork} from 'child_process'; | |||||
| import assert from 'assert'; | ||||||
|
|
||||||
| const useCPED = | ||||||
| (satisfies(process.versions.node, '>=24.0.0') && | ||||||
| !process.execArgv.includes('--no-async-context-frame')) || | ||||||
| (satisfies(process.versions.node, '>=22.7.0') && | ||||||
| process.execArgv.includes('--experimental-async-context-frame')); | ||||||
| isAsyncContextFrameActive() && satisfies(process.versions.node, '>=22.7.0'); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
|
||||||
| const collectAsyncId = satisfies(process.versions.node, '>=24.0.0'); | ||||||
|
|
||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,6 +4,7 @@ import {time} from '../src/index'; | |||||||
| import {Profile, ValueType} from 'pprof-format'; | ||||||||
| import {getAndVerifyPresence, getAndVerifyString} from './profiles-for-tests'; | ||||||||
| import {satisfies} from 'semver'; | ||||||||
| import {isAsyncContextFrameActive} from '../src/async-context-frame'; | ||||||||
|
|
||||||||
| import assert from 'assert'; | ||||||||
|
|
||||||||
|
|
@@ -13,10 +14,8 @@ const withContexts = | |||||||
| process.platform === 'darwin' || process.platform === 'linux'; | ||||||||
| const useCPED = | ||||||||
| withContexts && | ||||||||
| ((satisfies(process.versions.node, '>=24.0.0') && | ||||||||
| !process.execArgv.includes('--no-async-context-frame')) || | ||||||||
| (satisfies(process.versions.node, '>=22.7.0') && | ||||||||
| process.execArgv.includes('--experimental-async-context-frame'))); | ||||||||
| isAsyncContextFrameActive() && | ||||||||
| satisfies(process.versions.node, '>=22.7.0'); | ||||||||
|
Comment on lines
+17
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
| const collectAsyncId = | ||||||||
| withContexts && satisfies(process.versions.node, '>=24.0.0'); | ||||||||
|
|
||||||||
|
|
||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cut off is Node 22.7.0