From 354d73611656e3d1ee10259cf47c9a3c0a4bfe89 Mon Sep 17 00:00:00 2001 From: Fredrik Larsson Date: Fri, 9 Oct 2026 16:39:11 +0200 Subject: [PATCH 1/5] feat: add opt-in async-context labels Use pprof's native scoped contexts to keep labels attached to overlapping asynchronous workloads without per-await JavaScript hooks. Enable this through wall.asyncContext or PYROSCOPE_WALL_ASYNC_CONTEXT, and reject unsupported runtimes instead of silently losing attribution. Preserve contexts across profile flushes, reject unscoped label mutation in async mode, and retain synchronous labeling by default. Return callback results with their inferred types and restore default-mode labels even when callbacks throw. --- README.md | 2 + docs/async-labels.md | 85 ++++++++++ src/environment.ts | 1 + src/index.ts | 10 +- src/profilers/heap-profiler.ts | 2 +- src/profilers/profiler.ts | 8 +- src/profilers/pyroscope-profiler.ts | 2 +- src/profilers/wall-profiler.ts | 103 ++++++++--- src/pyroscope-config.ts | 1 + src/utils/check-pyroscope-config.ts | 4 +- src/utils/get-env.ts | 3 + src/utils/process-config.ts | 1 + test/fixtures/async-context.ts | 253 ++++++++++++++++++++++++++++ test/labels.test.ts | 108 ++++++++++++ test/profiler.test.ts | 39 +++++ tools/benchmark-labels.mjs | 63 +++++++ 16 files changed, 646 insertions(+), 39 deletions(-) create mode 100644 docs/async-labels.md create mode 100644 test/fixtures/async-context.ts create mode 100644 test/labels.test.ts create mode 100644 tools/benchmark-labels.mjs diff --git a/README.md b/README.md index d71a5a0..909e5d4 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ Visit the [npm package][releases] to find the latest version of this package. Visit [docs](https://grafana.com/docs/pyroscope/latest/configure-client/language-sdks/nodejs/) page for usage and configuration documentation. +For opt-in request-scoped labels that survive `await`, see [async labels](docs/async-labels.md). + [Grafana Pyroscope]: https://grafana.com/oss/pyroscope/ [@datadog/pprof]: https://github.com/DataDog/pprof-nodejs [v8-prof]: https://v8.dev/docs/profile diff --git a/docs/async-labels.md b/docs/async-labels.md new file mode 100644 index 0000000..05b4794 --- /dev/null +++ b/docs/async-labels.md @@ -0,0 +1,85 @@ +# Attribute CPU and wall profiles to async work + +Enable `wall.asyncContext` to keep `wrapWithLabels` labels across promises, +timers and other async descendants. Concurrent scopes remain isolated. The +option defaults to `false`; heap profiles are unaffected. + +## Enable native async contexts + +Use Node.js 24 or newer with its default AsyncContextFrame implementation, or +start Node.js 22 with: + +```sh +node --experimental-async-context-frame app.js +``` + +Configure the SDK before starting profiling: + +```js +import Pyroscope from '@pyroscope/nodejs'; + +Pyroscope.init({ + appName: 'web', + serverAddress: 'http://localhost:4040', + wall: { asyncContext: true }, +}); +Pyroscope.start(); +``` + +Alternatively, set `PYROSCOPE_WALL_ASYNC_CONTEXT=true` (or `1`). An explicit +`wall.asyncContext` value takes precedence over the environment variable. + +Startup checks that the native profiler can read a scoped context. Unsupported +runtimes, including Node.js 20, Node.js 22 without the flag, and Node.js 24+ +with `--no-async-context-frame`, throw an error rather than silently losing +labels. A failed capability check stops the newly started wall profiler; an +application can reinitialize with `wall.asyncContext: false` if it deliberately +chooses synchronous-only labeling. + +## Wrap the work, including its async continuations + +Wrap the application handler before it schedules the work to attribute: + +```js +const result = await Pyroscope.wrapWithLabels( + { page: 'item', navigation: 'document' }, + async () => { + const item = await loadItem(); + return renderItem(item); + } +); +``` + +Here `loadItem` and `renderItem` are application functions. Labels are attached +to samples collected while this scope or its async descendants execute. Shared +framework/library functions inherit the labels too. Profiling does not +reconstruct an async call stack or turn an await duration into CPU time. + +- Nested scopes merge labels; an inner value overrides the same outer key. + Returning or throwing restores the caller's context. +- The wrapper forwards arguments and returns the callback's exact result, + including the original promise. Exceptions and rejections propagate unchanged. +- `getLabels()` reads the active scope. Outside a scope it returns `{}`. +- `setLabels()` and the deprecated `setWallLabels()` throw in async-context mode. + Use a nested `wrapWithLabels` scope instead of mutating shared labels. +- Regular profile flushes preserve in-flight scopes. Start profiling before + opening scopes, and finish the work before fully stopping profiling. Scopes + do not survive a full stop/start cycle. +- Context follows async-resource creation, not the HTTP response lifetime. + Detached tasks can retain labels after a response completes; a preexisting + `AsyncResource` retains its creation context. Shared schedulers and connection + pools need integration testing in the application. + +Use bounded labels such as page types or route templates. Do not use raw URLs, +item IDs, user IDs or other high-cardinality/private values. Use `tags` in SDK +configuration for static application labels. Do not run another CPU/wall +profiler alongside this SDK: the native profiler is process-global. Stop the +SDK before calling `init` again. + +## Without async-context mode + +The default `wrapWithLabels` scope remains synchronous: it restores labels as +soon as the callback returns, even if the returned value is a promise. It now +also restores labels when a callback throws and returns the callback's result. +`setLabels` retains its process-global behavior in this mode and is not safe +for overlapping async request lifetimes. diff --git a/src/environment.ts b/src/environment.ts index 0576bd7..24ce3c1 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -11,6 +11,7 @@ export interface Environment { wallSamplingDurationMs: number | undefined; wallSamplingIntervalMicros: number | undefined; wallCollectCpuTime: boolean | undefined; + wallAsyncContext: boolean | undefined; stripFilenames: StripFilenamesMode | undefined; shortenPaths: boolean | undefined; } diff --git a/src/index.ts b/src/index.ts index 1bf754e..095f08e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,12 +42,12 @@ function setLabels(labels: Record): void { getProfiler().wallProfiler.profiler.setLabels(labels); } -export function wrapWithLabels( +export function wrapWithLabels( lbls: Record, - fn: () => void, - ...args: unknown[] -): void { - getProfiler().wallProfiler.profiler.wrapWithLabels(lbls, fn, ...args); + fn: (...args: TArgs) => R, + ...args: TArgs +): R { + return getProfiler().wallProfiler.profiler.wrapWithLabels(lbls, fn, ...args); } function startWallProfiling(): void { diff --git a/src/profilers/heap-profiler.ts b/src/profilers/heap-profiler.ts index 03ef1fa..ec361c6 100644 --- a/src/profilers/heap-profiler.ts +++ b/src/profilers/heap-profiler.ts @@ -25,7 +25,7 @@ export class HeapProfiler implements Profiler { throw new Error("heap profiler doesn't support labels"); } - public wrapWithLabels(): void { + public wrapWithLabels(): never { throw new Error("heap profiler doesn't support labels"); } diff --git a/src/profilers/profiler.ts b/src/profilers/profiler.ts index 4c57e2f..58d65d4 100644 --- a/src/profilers/profiler.ts +++ b/src/profilers/profiler.ts @@ -5,11 +5,11 @@ export interface Profiler { setLabels(labels: Record): void; - wrapWithLabels( + wrapWithLabels( labels: Record, - fn: () => void, - ...args: unknown[] - ): void; + fn: (...args: TArgs) => R, + ...args: TArgs + ): R; start(args: TStartArgs): void; diff --git a/src/profilers/pyroscope-profiler.ts b/src/profilers/pyroscope-profiler.ts index 65ec089..4b75813 100644 --- a/src/profilers/pyroscope-profiler.ts +++ b/src/profilers/pyroscope-profiler.ts @@ -107,7 +107,7 @@ export class PyroscopeProfiler { return new ContinuousProfiler({ exporter, flushIntervalMs: flushIntervalMs, - profiler: new WallProfiler(), + profiler: new WallProfiler(config.wall?.asyncContext ?? false), startArgs: { sourceMapper: this.toDDSourceMapper(config.sourceMapper), samplingDurationMs: diff --git a/src/profilers/wall-profiler.ts b/src/profilers/wall-profiler.ts index 643592e..63e32d5 100644 --- a/src/profilers/wall-profiler.ts +++ b/src/profilers/wall-profiler.ts @@ -32,20 +32,23 @@ export interface ProfilerContext { labels?: LabelSet; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnyWrappedCallback = any; - export class WallProfiler implements Profiler { private lastProfiledAt: Date; private lastContext: ProfilerContext; private lastSamplingIntervalMicros!: number; - constructor() { + constructor(private readonly asyncContext = false) { this.lastContext = {}; this.lastProfiledAt = new Date(); } public getLabels(): LabelSet { + if (this.asyncContext) { + const context: ProfilerContext | undefined = time.isStarted() + ? time.getContext() + : undefined; + return context?.labels ?? {}; + } return this.lastContext.labels ?? {}; } @@ -54,43 +57,87 @@ export class WallProfiler implements Profiler { return this.innerProfile(true); } - public wrapWithLabels( + public wrapWithLabels( lbls: LabelSet, - fn: () => void, - ...args: unknown[] - ): void { + fn: (...args: TArgs) => R, + ...args: TArgs + ): R { const oldLabels = this.getLabels(); - this.setLabels({ - ...oldLabels, - ...lbls, - }); - (fn as AnyWrappedCallback)(...args); - this.setLabels({ - ...oldLabels, - }); + const labels = { ...oldLabels, ...lbls }; + if (this.asyncContext) { + return time.runWithContext({ labels }, fn, ...args); + } + this.setLabels(labels); + try { + return fn(...args); + } finally { + this.setLabels({ ...oldLabels }); + } } public setLabels(labels: LabelSet): void { + if (this.asyncContext) { + throw new Error( + 'setLabels is not supported with wall.asyncContext; use wrapWithLabels instead' + ); + } this.newContext({ labels: labels, }); } public start(args: WallProfilerStartArgs): void { - if (!time.isStarted()) { + if (time.isStarted()) { + if (this.asyncContext) { + this.checkAsyncContext(); + } + } else { log('start'); this.lastProfiledAt = new Date(); this.lastSamplingIntervalMicros = args.samplingDurationMs; - time.start({ - sourceMapper: args.sourceMapper, - durationMillis: args.samplingDurationMs, - intervalMicros: args.samplingIntervalMicros, - withContexts: true, - workaroundV8Bug: true, - collectCpuTime: args.collectCpuTime, - }); - this.newContext({}); + try { + time.start({ + sourceMapper: args.sourceMapper, + durationMillis: args.samplingDurationMs, + intervalMicros: args.samplingIntervalMicros, + withContexts: true, + useCPED: this.asyncContext, + workaroundV8Bug: true, + collectCpuTime: args.collectCpuTime, + }); + if (this.asyncContext) { + this.checkAsyncContext(); + } else { + this.newContext({}); + } + } catch (error) { + if (time.isStarted()) { + time.stop(); + } + if (this.asyncContext) { + throw new Error( + 'wall.asyncContext requires native AsyncContextFrame support. ' + + 'On Node.js 22, enable --experimental-async-context-frame; ' + + 'on Node.js 24+, do not disable AsyncContextFrame. ' + + 'Otherwise leave wall.asyncContext disabled.', + { cause: error } + ); + } + throw error; + } + } + } + + private checkAsyncContext(): void { + // useCPED can start successfully even when V8 cannot read its contexts. + const context: ProfilerContext = {}; + const supported = time.runWithContext( + context, + () => time.getContext() === context + ); + if (!supported) { + throw new Error('The native profiler could not read the scoped context'); } } @@ -110,7 +157,9 @@ export class WallProfiler implements Profiler { } private innerProfile(restart: boolean): ProfileExport { - this.newContext({}); + if (!this.asyncContext) { + this.newContext({}); + } const profile: Profile = time.stop(restart, this.generateLabels); const lastProfileStartedAt: Date = this.lastProfiledAt; diff --git a/src/pyroscope-config.ts b/src/pyroscope-config.ts index 024f395..c6923d9 100644 --- a/src/pyroscope-config.ts +++ b/src/pyroscope-config.ts @@ -27,6 +27,7 @@ export interface PyroscopeConfig { export type StripFilenamesMode = 'all' | 'dependencies'; export interface PyroscopeWallConfig { + asyncContext?: boolean | undefined; samplingDurationMs?: number | undefined; samplingIntervalMicros?: number | undefined; collectCpuTime?: boolean | undefined; diff --git a/src/utils/check-pyroscope-config.ts b/src/utils/check-pyroscope-config.ts index db1ec15..526a147 100644 --- a/src/utils/check-pyroscope-config.ts +++ b/src/utils/check-pyroscope-config.ts @@ -253,7 +253,9 @@ function hasValidWall(config: Record): boolean { return ( hasValidSamplingDurationMs(wallConfig) && - hasValidSamplingIntervalMicros(wallConfig) + hasValidSamplingIntervalMicros(wallConfig) && + (wallConfig.asyncContext === undefined || + typeof wallConfig.asyncContext === 'boolean') ); } diff --git a/src/utils/get-env.ts b/src/utils/get-env.ts index 11eacb2..f138bcf 100644 --- a/src/utils/get-env.ts +++ b/src/utils/get-env.ts @@ -20,6 +20,9 @@ export function getEnv(): Environment { wallSamplingIntervalMicros: parseNumericEnv( process.env['PYROSCOPE_WALL_SAMPLING_INTERVAL_MICROS'] ), + wallAsyncContext: parseBooleanEnv( + process.env['PYROSCOPE_WALL_ASYNC_CONTEXT'] + ), wallCollectCpuTime: parseBooleanEnv( process.env['PYROSCOPE_WALL_COLLECT_CPU_TIME'] ), diff --git a/src/utils/process-config.ts b/src/utils/process-config.ts index c3a00a8..2636734 100644 --- a/src/utils/process-config.ts +++ b/src/utils/process-config.ts @@ -18,6 +18,7 @@ export function processConfig( env.adhocServerAddress ?? config.serverAddress ?? env.serverAddress, tags: config.tags, wall: { + asyncContext: config.wall?.asyncContext ?? env.wallAsyncContext, samplingDurationMs: config.wall?.samplingDurationMs ?? env.wallSamplingDurationMs, samplingIntervalMicros: diff --git a/test/fixtures/async-context.ts b/test/fixtures/async-context.ts new file mode 100644 index 0000000..a7f67ae --- /dev/null +++ b/test/fixtures/async-context.ts @@ -0,0 +1,253 @@ +import { strict as assert } from 'node:assert'; +import { AsyncLocalStorage, AsyncResource } from 'node:async_hooks'; +import { createServer } from 'node:http'; +import { setImmediate, setTimeout } from 'node:timers/promises'; +import { gunzipSync } from 'node:zlib'; +import { time } from '@datadog/pprof'; +import { Profile } from 'pprof-format'; +import Pyroscope, { wrapWithLabels } from '../../src/index.js'; +import { getProfiler } from '../../src/utils/pyroscope-profiler.js'; + +const profiles: Profile[] = []; +let uploaded!: () => void; +const firstUpload = new Promise((resolve) => { + uploaded = resolve; +}); +const server = createServer(async (req, res) => { + assert.ok(req.url?.includes('format=pprof')); + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk); + profiles.push(Profile.decode(gunzipSync(Buffer.concat(chunks)))); + res.end('ok'); + uploaded(); +}); +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const address = server.address(); +assert.ok(address && typeof address !== 'string'); +const config = { + appName: 'async-label-test', + serverAddress: `http://127.0.0.1:${address.port}`, + flushIntervalMs: 40, + wall: { + asyncContext: true, + samplingIntervalMicros: 1000, + collectCpuTime: true, + }, +}; + +try { + Pyroscope.init(config); + if (process.argv[2] === 'unsupported') { + assert.throws( + () => Pyroscope.start(), + /wall.asyncContext requires native AsyncContextFrame/ + ); + assert.equal(time.isStarted(), false); + Pyroscope.init({ + ...config, + wall: { ...config.wall, asyncContext: false }, + }); + Pyroscope.startWallProfiling(); + assert.equal( + wrapWithLabels({ page: 'item' }, () => 42), + 42 + ); + } else { + Pyroscope.startWallProfiling(); + await verifyContract(); + await verifySamples(); + } +} finally { + await Pyroscope.stop(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); +} + +async function verifyContract() { + assert.deepEqual(Pyroscope.getLabels(), {}); + assert.throws( + () => Pyroscope.setLabels({ page: 'item' }), + /use wrapWithLabels/ + ); + assert.throws( + () => Pyroscope.setWallLabels({ page: 'item' }), + /use wrapWithLabels/ + ); + const result: number = wrapWithLabels( + { page: 'item' }, + (a: number, b: number) => a + b, + 2, + 3 + ); + assert.equal(result, 5); + const original = Promise.resolve(8); + const returned: Promise = wrapWithLabels({}, () => original); + assert.equal(returned, original); + assert.equal(await returned, 8); + + const error = new Error('callback failure'); + assert.throws( + () => + wrapWithLabels({ page: 'item' }, () => { + throw error; + }), + (caught) => caught === error + ); + assert.deepEqual(Pyroscope.getLabels(), {}); + const tracing = new AsyncLocalStorage(); + await tracing.run('trace-id', () => + wrapWithLabels({ page: 'item', shard: 3 }, async () => { + await setTimeout(1); + assert.equal(tracing.getStore(), 'trace-id'); + await assert.rejects( + wrapWithLabels({ page: 'search' }, async () => { + await Promise.resolve(); + assert.deepEqual(Pyroscope.getLabels(), { page: 'search', shard: 3 }); + throw error; + }), + (caught) => caught === error + ); + assert.deepEqual(Pyroscope.getWallLabels(), { page: 'item', shard: 3 }); + assert.throws( + () => + wrapWithLabels({ page: 'search' }, () => { + throw error; + }), + (caught) => caught === error + ); + assert.deepEqual(Pyroscope.getLabels(), { page: 'item', shard: 3 }); + + getProfiler().wallProfiler.profiler.profile(); + assert.deepEqual(Pyroscope.getLabels(), { page: 'item', shard: 3 }); + await setImmediate(); + assert.deepEqual(Pyroscope.getLabels(), { page: 'item', shard: 3 }); + }) + ); + assert.equal(tracing.getStore(), undefined); + assert.deepEqual(Pyroscope.getLabels(), {}); + + const resource = wrapWithLabels( + { page: 'search' }, + () => new AsyncResource('scoped-work') + ); + try { + await wrapWithLabels({ page: 'item' }, async () => { + await setImmediate(); + resource.runInAsyncScope(() => + assert.deepEqual(Pyroscope.getLabels(), { page: 'search' }) + ); + assert.deepEqual(Pyroscope.getLabels(), { page: 'item' }); + }); + } finally { + resource.emitDestroy(); + } +} + +function burn() { + const end = performance.now() + 120; + while (performance.now() < end) Math.sqrt(Math.random()); +} +function itemBeforeAwait() { + burn(); +} +function itemAfterAwait() { + burn(); +} +function itemAfterFlush() { + burn(); +} +function searchAfterAwait() { + burn(); +} +function nestedSearch() { + burn(); +} +function itemAfterNested() { + burn(); +} +function unrelatedWork() { + burn(); +} + +async function verifySamples() { + await Promise.all([ + wrapWithLabels({ page: 'item', shard: 3 }, async () => { + itemBeforeAwait(); + await Promise.resolve(); + assert.deepEqual(Pyroscope.getLabels(), { page: 'item', shard: 3 }); + itemAfterAwait(); + await firstUpload; + assert.deepEqual(Pyroscope.getLabels(), { page: 'item', shard: 3 }); + itemAfterFlush(); + await wrapWithLabels({ page: 'search' }, async () => { + await setImmediate(); + nestedSearch(); + }); + itemAfterNested(); + }), + wrapWithLabels({ page: 'search' }, async () => { + await setTimeout(1); + assert.deepEqual(Pyroscope.getLabels(), { page: 'search' }); + searchAfterAwait(); + }), + (async () => { + await setImmediate(); + assert.deepEqual(Pyroscope.getLabels(), {}); + unrelatedWork(); + })(), + ]); + await Pyroscope.stopWallProfiling(); + assert.ok( + profiles.length >= 2, + 'must include a periodic export and final flush' + ); + + const expected: Record> = { + itemBeforeAwait: { page: 'item', shard: 3 }, + itemAfterAwait: { page: 'item', shard: 3 }, + itemAfterFlush: { page: 'item', shard: 3 }, + searchAfterAwait: { page: 'search' }, + nestedSearch: { page: 'search', shard: 3 }, + itemAfterNested: { page: 'item', shard: 3 }, + unrelatedWork: {}, + }; + const seen = new Set(); + for (const profile of profiles) { + const string = (id: number | bigint) => + profile.stringTable.strings[Number(id)]; + assert.ok(profile.sampleType.some((type) => string(type.type) === 'cpu')); + const functions = new Map( + profile.function.map((fn) => [Number(fn.id), string(fn.name)]) + ); + const locations = new Map( + profile.location.map((loc) => [ + Number(loc.id), + loc.line.map((line) => functions.get(Number(line.functionId)) ?? ''), + ]) + ); + for (const sample of profile.sample) { + const names = sample.locationId.flatMap( + (id) => locations.get(Number(id)) ?? [] + ); + const work = Object.keys(expected).find((name) => + names.some((frame) => frame.includes(`:${name}:`)) + ); + if (!work) continue; + const labels = Object.fromEntries( + sample.label.map((label) => [ + string(label.key), + Number(label.str) === 0 ? Number(label.num) : string(label.str), + ]) + ); + assert.deepEqual(labels, expected[work], `${work} sample attribution`); + assert.ok(Number(sample.value[0]) > 0); + seen.add(work); + } + } + assert.deepEqual( + [...seen].sort(), + Object.keys(expected).sort(), + 'every named workload must have real samples' + ); +} diff --git a/test/labels.test.ts b/test/labels.test.ts new file mode 100644 index 0000000..7de7ceb --- /dev/null +++ b/test/labels.test.ts @@ -0,0 +1,108 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { time } from '@datadog/pprof'; +import Pyroscope, { wrapWithLabels } from '../src/index.js'; +import { getProfiler } from '../src/utils/pyroscope-profiler.js'; +import { checkPyroscopeConfig } from '../src/utils/check-pyroscope-config.js'; +import { processConfig } from '../src/utils/process-config.js'; +import { getEnv } from '../src/utils/get-env.js'; + +function start() { + Pyroscope.init(); + const { profiler, startArgs } = getProfiler().wallProfiler; + profiler.start(startArgs); +} + +describe('synchronous labels (default mode)', () => { + it('returns the callback result and forwards typed arguments', (t) => { + start(); + t.after(() => time.stop()); + const result: number = wrapWithLabels( + { page: 'item' }, + (a: number, b: number) => { + assert.deepEqual(Pyroscope.getLabels(), { page: 'item' }); + return a + b; + }, + 2, + 3 + ); + assert.equal(result, 5); + assert.deepEqual(Pyroscope.getLabels(), {}); + }); + + it('restores parent labels on nested synchronous exceptions', (t) => { + start(); + t.after(() => time.stop()); + Pyroscope.setLabels({ region: 'eu' }); + const error = new Error('callback failure'); + Pyroscope.wrapWithLabels({ page: 'item' }, () => { + assert.throws( + () => + Pyroscope.wrapWithLabels({ page: 'search' }, () => { + throw error; + }), + (caught) => caught === error + ); + assert.deepEqual(Pyroscope.getLabels(), { region: 'eu', page: 'item' }); + }); + assert.deepEqual(Pyroscope.getLabels(), { region: 'eu' }); + }); + + it('preserves promise identity without extending the synchronous scope', async (t) => { + start(); + t.after(() => time.stop()); + let original!: Promise; + const returned: Promise = Pyroscope.wrapWithLabels( + { page: 'item' }, + () => { + original = (async () => { + assert.deepEqual(Pyroscope.getLabels(), { page: 'item' }); + await Promise.resolve(); + assert.deepEqual(Pyroscope.getLabels(), {}); + return 7; + })(); + return original; + } + ); + assert.equal(returned, original); + assert.equal(await returned, 7); + }); +}); + +describe('async-context configuration', () => { + for (const value of ['true', 1, null, {}, []]) { + it(`rejects non-boolean asyncContext ${JSON.stringify(value)}`, () => { + assert.throws( + () => checkPyroscopeConfig({ wall: { asyncContext: value } }), + /valid wall options/ + ); + }); + } + + it('allows explicit false to override environment opt-in', () => { + const previous = process.env.PYROSCOPE_WALL_ASYNC_CONTEXT; + try { + for (const value of ['true', '1']) { + process.env.PYROSCOPE_WALL_ASYNC_CONTEXT = value; + assert.equal(processConfig({}, getEnv()).wall?.asyncContext, true); + assert.equal( + processConfig({ wall: { asyncContext: false } }, getEnv()).wall + ?.asyncContext, + false + ); + } + process.env.PYROSCOPE_WALL_ASYNC_CONTEXT = 'false'; + assert.equal( + processConfig({ wall: { asyncContext: true } }, getEnv()).wall + ?.asyncContext, + true + ); + delete process.env.PYROSCOPE_WALL_ASYNC_CONTEXT; + assert.equal(processConfig({}, getEnv()).wall?.asyncContext, undefined); + } finally { + if (previous === undefined) + delete process.env.PYROSCOPE_WALL_ASYNC_CONTEXT; + else process.env.PYROSCOPE_WALL_ASYNC_CONTEXT = previous; + } + }); +}); diff --git a/test/profiler.test.ts b/test/profiler.test.ts index 77c3a56..7022466 100644 --- a/test/profiler.test.ts +++ b/test/profiler.test.ts @@ -1,6 +1,8 @@ import { describe, it, type TestContext } from 'node:test'; import { strict as assert } from 'node:assert'; import process from 'node:process'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import Pyroscope from '../src/index.js'; import { VERSION } from '../src/version.js'; @@ -430,3 +432,40 @@ describe('common behaviour of profilers', () => { assert.strictEqual(req.headers['x-scope-orgid'], 'my-tenant-id'); }); }); + +describe('async-context labels', () => { + const fixture = fileURLToPath( + new URL('./fixtures/async-context.js', import.meta.url) + ); + const major = Number(process.versions.node.split('.')[0]); + + function run(flags: string[], mode: string) { + execFileSync(process.execPath, [...flags, fixture, mode], { + timeout: 30_000, + env: { + ...process.env, + NODE_OPTIONS: '', + PYROSCOPE_WALL_ASYNC_CONTEXT: '', + }, + stdio: 'pipe', + }); + } + + if (major >= 24) { + it('propagates async labels and exports correctly attributed native samples', () => + run([], 'supported')); + it('rejects disabled AsyncContextFrame and permits a subsequent default-mode start', () => + run(['--no-async-context-frame'], 'unsupported')); + } else { + it('rejects unavailable AsyncContextFrame and permits a subsequent default-mode start', () => + run([], 'unsupported')); + if ( + process.allowedNodeEnvironmentFlags.has( + '--experimental-async-context-frame' + ) + ) { + it('propagates async labels and exports correctly attributed native samples with the runtime flag', () => + run(['--experimental-async-context-frame'], 'supported')); + } + } +}); diff --git a/tools/benchmark-labels.mjs b/tools/benchmark-labels.mjs new file mode 100644 index 0000000..c86cec3 --- /dev/null +++ b/tools/benchmark-labels.mjs @@ -0,0 +1,63 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const mode = process.argv[2] ?? 'async'; +if (!['plain', 'sync', 'async'].includes(mode)) { + throw new Error('Usage: node tools/benchmark-labels.mjs [plain|sync|async]'); +} +const sdkRoot = process.env.SDK_ROOT ?? resolve('dist/cjs'); +const { WallProfiler } = await import( + pathToFileURL(resolve(sdkRoot, 'profilers/wall-profiler.js')).href +); +const profiler = new WallProfiler(mode === 'async'); +const tracing = new AsyncLocalStorage(); +const concurrency = 8; +const requests = 80_000; +const awaitsPerRequest = 200; + +async function run(count) { + await Promise.all( + Array.from({ length: concurrency }, async () => { + for (let i = 0; i < count / concurrency; i++) { + let pending; + tracing.run('trace', () => { + const work = () => { + pending = (async () => { + for (let j = 0; j < awaitsPerRequest; j++) + await Promise.resolve(); + })(); + }; + if (mode === 'plain') work(); + else profiler.wrapWithLabels({ page: 'item' }, work); + }); + await pending; + } + }) + ); +} + +profiler.start({ + samplingDurationMs: 60_000, + samplingIntervalMicros: 40_000, + collectCpuTime: true, + sourceMapper: undefined, +}); +try { + await run(800); + const start = performance.now(); + await run(requests); + console.log( + JSON.stringify({ + node: process.version, + mode, + requests, + concurrency, + awaitsPerRequest, + elapsedMs: performance.now() - start, + rssMiB: process.memoryUsage().rss / 1024 / 1024, + }) + ); +} finally { + profiler.stop(); +} From 19c35ec8b1ba25c73ff9528457034aaf0dafa05a Mon Sep 17 00:00:00 2001 From: Fredrik Larsson Date: Fri, 9 Oct 2026 16:43:35 +0200 Subject: [PATCH 2/5] docs: keep async-label guidance in the README Keep activation and compatibility requirements next to the existing usage link instead of maintaining a separate SDK guide. The canonical usage documentation lives on the Pyroscope documentation site. --- README.md | 6 +++- docs/async-labels.md | 85 -------------------------------------------- 2 files changed, 5 insertions(+), 86 deletions(-) delete mode 100644 docs/async-labels.md diff --git a/README.md b/README.md index 909e5d4..aa31cce 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,11 @@ Visit the [npm package][releases] to find the latest version of this package. Visit [docs](https://grafana.com/docs/pyroscope/latest/configure-client/language-sdks/nodejs/) page for usage and configuration documentation. -For opt-in request-scoped labels that survive `await`, see [async labels](docs/async-labels.md). +Async labels are opt-in: configure `wall: { asyncContext: true }`, then use +`wrapWithLabels` to scope work across `await`. Requires Node.js 24+ with +AsyncContextFrame enabled, or Node.js 22 with +`--experimental-async-context-frame`. `setLabels` is unsupported in this mode; +heap profiles are unchanged. [Grafana Pyroscope]: https://grafana.com/oss/pyroscope/ [@datadog/pprof]: https://github.com/DataDog/pprof-nodejs diff --git a/docs/async-labels.md b/docs/async-labels.md deleted file mode 100644 index 05b4794..0000000 --- a/docs/async-labels.md +++ /dev/null @@ -1,85 +0,0 @@ -# Attribute CPU and wall profiles to async work - -Enable `wall.asyncContext` to keep `wrapWithLabels` labels across promises, -timers and other async descendants. Concurrent scopes remain isolated. The -option defaults to `false`; heap profiles are unaffected. - -## Enable native async contexts - -Use Node.js 24 or newer with its default AsyncContextFrame implementation, or -start Node.js 22 with: - -```sh -node --experimental-async-context-frame app.js -``` - -Configure the SDK before starting profiling: - -```js -import Pyroscope from '@pyroscope/nodejs'; - -Pyroscope.init({ - appName: 'web', - serverAddress: 'http://localhost:4040', - wall: { asyncContext: true }, -}); -Pyroscope.start(); -``` - -Alternatively, set `PYROSCOPE_WALL_ASYNC_CONTEXT=true` (or `1`). An explicit -`wall.asyncContext` value takes precedence over the environment variable. - -Startup checks that the native profiler can read a scoped context. Unsupported -runtimes, including Node.js 20, Node.js 22 without the flag, and Node.js 24+ -with `--no-async-context-frame`, throw an error rather than silently losing -labels. A failed capability check stops the newly started wall profiler; an -application can reinitialize with `wall.asyncContext: false` if it deliberately -chooses synchronous-only labeling. - -## Wrap the work, including its async continuations - -Wrap the application handler before it schedules the work to attribute: - -```js -const result = await Pyroscope.wrapWithLabels( - { page: 'item', navigation: 'document' }, - async () => { - const item = await loadItem(); - return renderItem(item); - } -); -``` - -Here `loadItem` and `renderItem` are application functions. Labels are attached -to samples collected while this scope or its async descendants execute. Shared -framework/library functions inherit the labels too. Profiling does not -reconstruct an async call stack or turn an await duration into CPU time. - -- Nested scopes merge labels; an inner value overrides the same outer key. - Returning or throwing restores the caller's context. -- The wrapper forwards arguments and returns the callback's exact result, - including the original promise. Exceptions and rejections propagate unchanged. -- `getLabels()` reads the active scope. Outside a scope it returns `{}`. -- `setLabels()` and the deprecated `setWallLabels()` throw in async-context mode. - Use a nested `wrapWithLabels` scope instead of mutating shared labels. -- Regular profile flushes preserve in-flight scopes. Start profiling before - opening scopes, and finish the work before fully stopping profiling. Scopes - do not survive a full stop/start cycle. -- Context follows async-resource creation, not the HTTP response lifetime. - Detached tasks can retain labels after a response completes; a preexisting - `AsyncResource` retains its creation context. Shared schedulers and connection - pools need integration testing in the application. - -Use bounded labels such as page types or route templates. Do not use raw URLs, -item IDs, user IDs or other high-cardinality/private values. Use `tags` in SDK -configuration for static application labels. Do not run another CPU/wall -profiler alongside this SDK: the native profiler is process-global. Stop the -SDK before calling `init` again. - -## Without async-context mode - -The default `wrapWithLabels` scope remains synchronous: it restores labels as -soon as the callback returns, even if the returned value is a promise. It now -also restores labels when a callback throws and returns the callback's result. -`setLabels` retains its process-global behavior in this mode and is not safe -for overlapping async request lifetimes. From 294a58310a8d4ae5d038a41a56f5c2c673db8f8d Mon Sep 17 00:00:00 2001 From: Fredrik Larsson Date: Fri, 9 Oct 2026 17:16:13 +0200 Subject: [PATCH 3/5] feat: support persistent labels in async contexts Create a fresh async frame before calling the native setter, which mutates the current frame's map in place. Existing sibling continuations retain their captured labels while setLabels persists within the current continuation. Keep default process-wide behavior unchanged. Exercise isolation, historical samples, flushes and restart through native node:test cases with test-managed cleanup. --- README.md | 5 +- src/profilers/wall-profiler.ts | 21 +- ...async-context.ts => async-context.test.ts} | 191 +++++++++++++++--- test/labels.test.ts | 17 ++ test/profiler.test.ts | 3 +- 5 files changed, 196 insertions(+), 41 deletions(-) rename test/fixtures/{async-context.ts => async-context.test.ts} (57%) diff --git a/README.md b/README.md index aa31cce..7c76790 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,9 @@ Visit [docs](https://grafana.com/docs/pyroscope/latest/configure-client/language Async labels are opt-in: configure `wall: { asyncContext: true }`, then use `wrapWithLabels` to scope work across `await`. Requires Node.js 24+ with AsyncContextFrame enabled, or Node.js 22 with -`--experimental-async-context-frame`. `setLabels` is unsupported in this mode; -heap profiles are unchanged. +`--experimental-async-context-frame`. In this mode, `setLabels` replaces labels +in the current async context without changing sibling contexts. Heap profiles +are unchanged. [Grafana Pyroscope]: https://grafana.com/oss/pyroscope/ [@datadog/pprof]: https://github.com/DataDog/pprof-nodejs diff --git a/src/profilers/wall-profiler.ts b/src/profilers/wall-profiler.ts index 63e32d5..a7bae92 100644 --- a/src/profilers/wall-profiler.ts +++ b/src/profilers/wall-profiler.ts @@ -1,5 +1,6 @@ import { time, SourceMapper, LabelSet, TimeProfileNode } from '@datadog/pprof'; import { Profile } from 'pprof-format'; +import { AsyncLocalStorage } from 'node:async_hooks'; import { ProfileExport } from '../profile-exporter.js'; import { Profiler } from './profiler.js'; @@ -36,6 +37,7 @@ export class WallProfiler implements Profiler { private lastProfiledAt: Date; private lastContext: ProfilerContext; private lastSamplingIntervalMicros!: number; + private readonly contextBoundary = new AsyncLocalStorage(); constructor(private readonly asyncContext = false) { this.lastContext = {}; @@ -76,14 +78,18 @@ export class WallProfiler implements Profiler { } public setLabels(labels: LabelSet): void { + const context = { labels }; if (this.asyncContext) { - throw new Error( - 'setLabels is not supported with wall.asyncContext; use wrapWithLabels instead' - ); + if (!time.isStarted()) { + throw new Error('Wall profiler is not started'); + } + // The native setter mutates a shared frame in place; copy it first so + // already-created async work retains its previous labels. + this.contextBoundary.enterWith(context); + time.setContext(context); + } else { + this.newContext(context); } - this.newContext({ - labels: labels, - }); } public start(args: WallProfilerStartArgs): void { @@ -161,6 +167,9 @@ export class WallProfiler implements Profiler { this.newContext({}); } const profile: Profile = time.stop(restart, this.generateLabels); + if (!restart) { + this.contextBoundary.disable(); + } const lastProfileStartedAt: Date = this.lastProfiledAt; this.lastProfiledAt = new Date(); diff --git a/test/fixtures/async-context.ts b/test/fixtures/async-context.test.ts similarity index 57% rename from test/fixtures/async-context.ts rename to test/fixtures/async-context.test.ts index a7f67ae..74dd2dd 100644 --- a/test/fixtures/async-context.ts +++ b/test/fixtures/async-context.test.ts @@ -1,6 +1,7 @@ import { strict as assert } from 'node:assert'; import { AsyncLocalStorage, AsyncResource } from 'node:async_hooks'; import { createServer } from 'node:http'; +import { it } from 'node:test'; import { setImmediate, setTimeout } from 'node:timers/promises'; import { gunzipSync } from 'node:zlib'; import { time } from '@datadog/pprof'; @@ -13,29 +14,60 @@ let uploaded!: () => void; const firstUpload = new Promise((resolve) => { uploaded = resolve; }); +let setterUploaded!: () => void; +const setterUpload = new Promise((resolve) => { + setterUploaded = resolve; +}); const server = createServer(async (req, res) => { assert.ok(req.url?.includes('format=pprof')); const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk); - profiles.push(Profile.decode(gunzipSync(Buffer.concat(chunks)))); + const profile = Profile.decode(gunzipSync(Buffer.concat(chunks))); + profiles.push(profile); res.end('ok'); - uploaded(); + if ( + profile.stringTable.strings.some((name) => + name.includes(':itemBeforeAwait:') + ) + ) { + uploaded(); + } + if ( + profile.stringTable.strings.some((name) => name.includes(':setterSibling:')) + ) { + setterUploaded(); + } }); -await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); -const address = server.address(); -assert.ok(address && typeof address !== 'string'); -const config = { - appName: 'async-label-test', - serverAddress: `http://127.0.0.1:${address.port}`, - flushIntervalMs: 40, - wall: { - asyncContext: true, - samplingIntervalMicros: 1000, - collectCpuTime: true, - }, -}; +it('native async-context lifecycle and sample attribution', async (t) => { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + t.after(async () => { + try { + await Pyroscope.stop(); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); + } + }); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + const config = { + appName: 'async-label-test', + serverAddress: `http://127.0.0.1:${address.port}`, + flushIntervalMs: 40, + wall: { + asyncContext: true, + samplingIntervalMicros: 1000, + collectCpuTime: true, + }, + }; -try { Pyroscope.init(config); if (process.argv[2] === 'unsupported') { assert.throws( @@ -53,27 +85,49 @@ try { 42 ); } else { + assert.throws(() => Pyroscope.setLabels({ phase: 'early' }), /not started/); Pyroscope.startWallProfiling(); - await verifyContract(); - await verifySamples(); + await t.test( + 'preserves callback results, nested scopes and tracing', + verifyContract + ); + await t.test( + 'isolates persistent setters from existing async work', + verifySetLabels + ); + Pyroscope.setLabels({}); + await t.test( + 'exports historical and concurrent labels across flushes', + verifySamples + ); + await t.test( + 'clears labels on stop and supports setters after restart', + async () => { + assert.deepEqual(Pyroscope.getLabels(), {}); + assert.throws( + () => Pyroscope.setLabels({ phase: 'late' }), + /not started/ + ); + Pyroscope.startWallProfiling(); + assert.deepEqual(Pyroscope.getLabels(), {}); + Pyroscope.setLabels({ phase: 'restarted' }); + await setImmediate(); + assert.deepEqual(Pyroscope.getLabels(), { phase: 'restarted' }); + await wrapWithLabels({ page: 'new' }, async () => { + await setImmediate(); + assert.deepEqual(Pyroscope.getLabels(), { + phase: 'restarted', + page: 'new', + }); + }); + assert.deepEqual(Pyroscope.getLabels(), { phase: 'restarted' }); + } + ); } -} finally { - await Pyroscope.stop(); - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())) - ); -} +}); async function verifyContract() { assert.deepEqual(Pyroscope.getLabels(), {}); - assert.throws( - () => Pyroscope.setLabels({ page: 'item' }), - /use wrapWithLabels/ - ); - assert.throws( - () => Pyroscope.setWallLabels({ page: 'item' }), - /use wrapWithLabels/ - ); const result: number = wrapWithLabels( { page: 'item' }, (a: number, b: number) => a + b, @@ -144,6 +198,75 @@ async function verifyContract() { } } +async function verifySetLabels() { + Pyroscope.setLabels({ phase: 'root' }); + assert.deepEqual(Pyroscope.getLabels(), { phase: 'root' }); + await setImmediate(); + assert.deepEqual(Pyroscope.getLabels(), { phase: 'root' }); + const tracing = new AsyncLocalStorage(); + await tracing.run('setter-trace', () => + wrapWithLabels({ page: 'item' }, async () => { + const parent = { phase: 'root', page: 'item' }; + beforeSetter(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const writer = (async () => { + await gate; + Pyroscope.setLabels({ phase: 'first' }); + firstSetter(); + Pyroscope.setWallLabels({ phase: 'second' }); + secondSetter(); + await setImmediate(); + assert.deepEqual(Pyroscope.getLabels(), { phase: 'second' }); + assert.equal(tracing.getStore(), 'setter-trace'); + await assert.rejects( + wrapWithLabels({ page: 'nested' }, async () => { + assert.deepEqual(Pyroscope.getLabels(), { + phase: 'second', + page: 'nested', + }); + Pyroscope.setLabels({ phase: 'nested-replaced' }); + await setImmediate(); + assert.deepEqual(Pyroscope.getLabels(), { + phase: 'nested-replaced', + }); + throw new Error('nested setter'); + }), + /nested setter/ + ); + assert.deepEqual(Pyroscope.getLabels(), { phase: 'second' }); + })(); + const sibling = (async () => { + await gate; + await setImmediate(); + assert.deepEqual(Pyroscope.getLabels(), parent); + setterSibling(); + })(); + release(); + await Promise.all([writer, sibling]); + assert.deepEqual(Pyroscope.getLabels(), parent); + }) + ); + assert.deepEqual(Pyroscope.getLabels(), { phase: 'root' }); + await setterUpload; + assert.deepEqual(Pyroscope.getLabels(), { phase: 'root' }); +} + +function beforeSetter() { + burn(); +} +function firstSetter() { + burn(); +} +function secondSetter() { + burn(); +} +function setterSibling() { + burn(); +} + function burn() { const end = performance.now() + 120; while (performance.now() < end) Math.sqrt(Math.random()); @@ -211,6 +334,10 @@ async function verifySamples() { nestedSearch: { page: 'search', shard: 3 }, itemAfterNested: { page: 'item', shard: 3 }, unrelatedWork: {}, + beforeSetter: { phase: 'root', page: 'item' }, + firstSetter: { phase: 'first' }, + secondSetter: { phase: 'second' }, + setterSibling: { phase: 'root', page: 'item' }, }; const seen = new Set(); for (const profile of profiles) { diff --git a/test/labels.test.ts b/test/labels.test.ts index 7de7ceb..f278df2 100644 --- a/test/labels.test.ts +++ b/test/labels.test.ts @@ -48,6 +48,23 @@ describe('synchronous labels (default mode)', () => { assert.deepEqual(Pyroscope.getLabels(), { region: 'eu' }); }); + it('keeps setLabels process-wide for existing async work', async (t) => { + start(); + t.after(() => time.stop()); + Pyroscope.setLabels({ phase: 'before' }); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const reader = (async () => { + await gate; + assert.deepEqual(Pyroscope.getLabels(), { phase: 'after' }); + })(); + Pyroscope.setLabels({ phase: 'after' }); + release(); + await reader; + }); + it('preserves promise identity without extending the synchronous scope', async (t) => { start(); t.after(() => time.stop()); diff --git a/test/profiler.test.ts b/test/profiler.test.ts index 7022466..effa42a 100644 --- a/test/profiler.test.ts +++ b/test/profiler.test.ts @@ -435,7 +435,7 @@ describe('common behaviour of profilers', () => { describe('async-context labels', () => { const fixture = fileURLToPath( - new URL('./fixtures/async-context.js', import.meta.url) + new URL('./fixtures/async-context.test.js', import.meta.url) ); const major = Number(process.versions.node.split('.')[0]); @@ -445,6 +445,7 @@ describe('async-context labels', () => { env: { ...process.env, NODE_OPTIONS: '', + NODE_TEST_CONTEXT: undefined, PYROSCOPE_WALL_ASYNC_CONTEXT: '', }, stdio: 'pipe', From 9a5fe0b89e87b672bd8968b4718a3416e8284cb2 Mon Sep 17 00:00:00 2001 From: Fredrik Larsson Date: Fri, 9 Oct 2026 17:16:29 +0200 Subject: [PATCH 4/5] chore: remove standalone async-label benchmark Keep the contribution aligned with the existing test and tooling layout, which has no benchmark harness. Performance experiments remain local rather than adding a new maintained utility. --- tools/benchmark-labels.mjs | 63 -------------------------------------- 1 file changed, 63 deletions(-) delete mode 100644 tools/benchmark-labels.mjs diff --git a/tools/benchmark-labels.mjs b/tools/benchmark-labels.mjs deleted file mode 100644 index c86cec3..0000000 --- a/tools/benchmark-labels.mjs +++ /dev/null @@ -1,63 +0,0 @@ -import { AsyncLocalStorage } from 'node:async_hooks'; -import { resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; - -const mode = process.argv[2] ?? 'async'; -if (!['plain', 'sync', 'async'].includes(mode)) { - throw new Error('Usage: node tools/benchmark-labels.mjs [plain|sync|async]'); -} -const sdkRoot = process.env.SDK_ROOT ?? resolve('dist/cjs'); -const { WallProfiler } = await import( - pathToFileURL(resolve(sdkRoot, 'profilers/wall-profiler.js')).href -); -const profiler = new WallProfiler(mode === 'async'); -const tracing = new AsyncLocalStorage(); -const concurrency = 8; -const requests = 80_000; -const awaitsPerRequest = 200; - -async function run(count) { - await Promise.all( - Array.from({ length: concurrency }, async () => { - for (let i = 0; i < count / concurrency; i++) { - let pending; - tracing.run('trace', () => { - const work = () => { - pending = (async () => { - for (let j = 0; j < awaitsPerRequest; j++) - await Promise.resolve(); - })(); - }; - if (mode === 'plain') work(); - else profiler.wrapWithLabels({ page: 'item' }, work); - }); - await pending; - } - }) - ); -} - -profiler.start({ - samplingDurationMs: 60_000, - samplingIntervalMicros: 40_000, - collectCpuTime: true, - sourceMapper: undefined, -}); -try { - await run(800); - const start = performance.now(); - await run(requests); - console.log( - JSON.stringify({ - node: process.version, - mode, - requests, - concurrency, - awaitsPerRequest, - elapsedMs: performance.now() - start, - rssMiB: process.memoryUsage().rss / 1024 / 1024, - }) - ); -} finally { - profiler.stop(); -} From c47a917b69fbcdb3604e4a6534dba1d103f788a0 Mon Sep 17 00:00:00 2001 From: Fredrik Larsson Date: Fri, 9 Oct 2026 17:26:51 +0200 Subject: [PATCH 5/5] fix: preserve permissive callback argument compatibility The generic wrapper inferred an empty argument tuple for callbacks without declared parameters, rejecting calls that previously accepted extra arguments even with async mode disabled. Retain the typed overload and restore the legacy call form through a compatibility overload. Verify argument forwarding and promise identity in both profiling modes. --- src/index.ts | 10 ++++++++++ test/fixtures/async-context.test.ts | 2 +- test/labels.test.ts | 9 +++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 095f08e..a1bfb85 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,16 @@ function setLabels(labels: Record): void { getProfiler().wallProfiler.profiler.setLabels(labels); } +export function wrapWithLabels( + lbls: Record, + fn: (...args: TArgs) => R, + ...args: TArgs +): R; +export function wrapWithLabels( + lbls: Record, + fn: () => R, + ...args: unknown[] +): R; export function wrapWithLabels( lbls: Record, fn: (...args: TArgs) => R, diff --git a/test/fixtures/async-context.test.ts b/test/fixtures/async-context.test.ts index 74dd2dd..e1005c6 100644 --- a/test/fixtures/async-context.test.ts +++ b/test/fixtures/async-context.test.ts @@ -136,7 +136,7 @@ async function verifyContract() { ); assert.equal(result, 5); const original = Promise.resolve(8); - const returned: Promise = wrapWithLabels({}, () => original); + const returned: Promise = wrapWithLabels({}, () => original, 'extra'); assert.equal(returned, original); assert.equal(await returned, 8); diff --git a/test/labels.test.ts b/test/labels.test.ts index f278df2..16880b3 100644 --- a/test/labels.test.ts +++ b/test/labels.test.ts @@ -30,6 +30,15 @@ describe('synchronous labels (default mode)', () => { assert.deepEqual(Pyroscope.getLabels(), {}); }); + it('forwards extra arguments to callbacks without declared parameters', (t) => { + start(); + t.after(() => time.stop()); + const callback = t.mock.fn(() => 42); + const result: number = wrapWithLabels({}, callback, 'extra'); + assert.equal(result, 42); + assert.deepEqual(callback.mock.calls[0].arguments, ['extra']); + }); + it('restores parent labels on nested synchronous exceptions', (t) => { start(); t.after(() => time.stop());