diff --git a/doc/api/diagnostics_channel.md b/doc/api/diagnostics_channel.md index fb1bf6e93f2948..741c2b42321b0a 100644 --- a/doc/api/diagnostics_channel.md +++ b/doc/api/diagnostics_channel.md @@ -72,6 +72,13 @@ if (channel.hasSubscribers) { // Unsubscribe from the channel diagnostics_channel.unsubscribe('my-channel', onMessage); + +// Use bypass() to skip opted-in subscribers during internal calls +const kMyTool = Symbol('my-tool'); +diagnostics_channel.channel('my-channel').subscribe(onMessage, { bypassId: kMyTool }); +diagnostics_channel.bypass(kMyTool, () => { + // onMessage will NOT be called for publishes inside here +}); ``` ```cjs @@ -97,6 +104,13 @@ if (channel.hasSubscribers) { // Unsubscribe from the channel diagnostics_channel.unsubscribe('my-channel', onMessage); + +// Use bypass() to skip opted-in subscribers during internal calls +const kMyTool = Symbol('my-tool'); +diagnostics_channel.channel('my-channel').subscribe(onMessage, { bypassId: kMyTool }); +diagnostics_channel.bypass(kMyTool, () => { + // onMessage will NOT be called for publishes inside here +}); ``` #### `diagnostics_channel.hasSubscribers(name)` @@ -231,6 +245,151 @@ diagnostics_channel.subscribe('my-channel', onMessage); diagnostics_channel.unsubscribe('my-channel', onMessage); ``` +#### `diagnostics_channel.bypass(key, fn[, thisArg[, ...args]])` + + + +* `key` {symbol|Object} The bypass identity token. Must match the + `bypassId` used when subscribing. +* `fn` {Function} The function to run with bypass active. +* `thisArg` {any} The receiver to use for the function call. +* `...args` {any} Optional arguments to pass to the function. +* Returns: {any} The return value of `fn`. + +Runs `fn` with the given `key` active in the current async context. +Any channel subscribers or bound stores that were registered with +`{ bypassId: key }` will be skipped for the duration of `fn` and +any async continuations (Promises, timers, microtasks) within it. + +This is designed for observability tooling (APM agents, tracers) +that need to prevent recursive instrumentation when their own +internal code calls into an instrumented library. + +```mjs +import diagnostics_channel from 'node:diagnostics_channel'; +import { request } from 'node:http'; + +const { channel, bypass } = diagnostics_channel; + +// A unique token identifying this APM tool +const kMyTracer = Symbol('my-tracer'); + +// Subscribe to HTTP requests, but opt into bypass +channel('http.client.request').subscribe((message) => { + console.log('HTTP request:', message.url); +}, { bypassId: kMyTracer }); + +// When exporting traces internally, use bypass() so the +// above subscriber is NOT triggered for this internal request +function exportTraces(data) { + bypass(kMyTracer, () => { + // This HTTP request will NOT trigger the subscriber above + const req = request('https://my-apm-backend.example.com/traces', { + method: 'POST', + }); + req.end(); + }); +} +``` + +```cjs +const diagnostics_channel = require('node:diagnostics_channel'); +const { request } = require('node:http'); + +const { channel, bypass } = diagnostics_channel; + +// A unique token identifying this APM tool +const kMyTracer = Symbol('my-tracer'); + +// Subscribe to HTTP requests, but opt into bypass +channel('http.client.request').subscribe((message) => { + console.log('HTTP request:', message.url); +}, { bypassId: kMyTracer }); + +// When exporting traces internally, use bypass() so the +// above subscriber is NOT triggered for this internal request +function exportTraces(data) { + bypass(kMyTracer, () => { + // This HTTP request will NOT trigger the subscriber above + const req = request('https://my-apm-backend.example.com/traces', { + method: 'POST', + }); + req.end(); + }); +} +``` + +Without `bypass()`, the internal `exportTraces()` HTTP request +would trigger the subscriber, which would try to export a trace +of the export, causing infinite recursion. + +The bypass context propagates across async boundaries: + +```mjs +// bypass() works across Promise boundaries +await bypass(kMyTracer, async () => { + await someAsyncOperation(); // still bypassed + channel('http.client.request').publish({}); // subscriber skipped +}); + +// And across timers +bypass(kMyTracer, () => { + setImmediate(() => { + // Still bypassed here + channel('http.client.request').publish({}); + }); +}); +``` + +```cjs +(async () => { + await bypass(kMyTracer, async () => { + await someAsyncOperation(); + channel('http.client.request').publish({}); + }); + bypass(kMyTracer, () => { + setImmediate(() => { + channel('http.client.request').publish({}); + }); + }); +})(); +``` + +Multiple tools can each use their own `bypassId` without +interfering with each other: + +```mjs +const kToolA = Symbol('tool-a'); +const kToolB = Symbol('tool-b'); + +channel('http.client.request').subscribe(handlerA, { bypassId: kToolA }); +channel('http.client.request').subscribe(handlerB, { bypassId: kToolB }); + +// Only handlerA is skipped, handlerB still fires +bypass(kToolA, () => { + channel('http.client.request').publish({}); +}); +``` + +```cjs +const diagnostics_channel = require('node:diagnostics_channel'); + +const { channel, bypass } = diagnostics_channel; + +const kToolA = Symbol('tool-a'); +const kToolB = Symbol('tool-b'); + +channel('http.client.request').subscribe(handlerA, { bypassId: kToolA }); +channel('http.client.request').subscribe(handlerB, { bypassId: kToolB }); + +// Only handlerA is skipped, handlerB still fires +bypass(kToolA, () => { + channel('http.client.request').publish({}); +}); +``` + #### `diagnostics_channel.tracingChannel(nameOrChannels)` > Stability: 1 - Experimental * `store` {AsyncLocalStorage} The store to which to bind the context data * `transform` {Function} Transform context data before setting the store context +* `options` {Object} + * `bypassId` {symbol|Object} An optional identity token. When + provided, this bound store will be skipped while + [`diagnostics_channel.bypass()`][] is active with the same key. When [`channel.runStores(context, ...)`][] is called, the given context data will be applied to any store bound to the channel. If the store has already been @@ -565,6 +767,38 @@ channel.bindStore(store, (data) => { }); ``` +To opt this bound store into bypass behavior: + +```mjs +import diagnostics_channel from 'node:diagnostics_channel'; +import { AsyncLocalStorage } from 'node:async_hooks'; + +const { channel, bypass } = diagnostics_channel; +const kMyTool = Symbol('my-tool'); +const ch = channel('http.client.request'); +const store = new AsyncLocalStorage(); + +// This store will NOT be entered when bypass(kMyTool, fn) is active +ch.bindStore(store, (data) => ({ requestId: data.id }), { + bypassId: kMyTool, +}); +``` + +```cjs +const diagnostics_channel = require('node:diagnostics_channel'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +const { channel, bypass } = diagnostics_channel; +const kMyTool = Symbol('my-tool'); +const ch = channel('http.client.request'); +const store = new AsyncLocalStorage(); + +// This store will NOT be entered when bypass(kMyTool, fn) is active +ch.bindStore(store, (data) => ({ requestId: data.id }), { + bypassId: kMyTool, +}); +``` + #### `channel.unbindStore(store)` * `subscribers` {Object} Set of [TracingChannel Channels][] subscribers @@ -770,6 +1008,11 @@ added: * `asyncStart` {Function} The [`asyncStart` event][] subscriber * `asyncEnd` {Function} The [`asyncEnd` event][] subscriber * `error` {Function} The [`error` event][] subscriber +* `options` {Object} + * `bypassId` {symbol|Object} An optional identity token. When + provided, ALL handlers (start, end, asyncStart, asyncEnd, error) + will be skipped while [`diagnostics_channel.bypass()`][] is + active with the same key. Helper to subscribe a collection of functions to the corresponding channels. This is the same as calling [`channel.subscribe(onMessage)`][] on each channel @@ -823,6 +1066,52 @@ channels.subscribe({ }); ``` +To opt all TracingChannel handlers into bypass behavior: + +```mjs +import diagnostics_channel from 'node:diagnostics_channel'; + +const { tracingChannel, bypass } = diagnostics_channel; +const kMyTool = Symbol('my-tool'); +const tc = tracingChannel('my-operation'); + +// All TracingChannel events skipped when bypass(kMyTool) is active +tc.subscribe({ + start(message) { /* ... */ }, + end(message) { /* ... */ }, + asyncStart(message) { /* ... */ }, + asyncEnd(message) { /* ... */ }, +}, { bypassId: kMyTool }); + +bypass(kMyTool, () => { + tc.traceSync(() => { + // Start and end handlers are NOT called + }); +}); +``` + +```cjs +const diagnostics_channel = require('node:diagnostics_channel'); + +const { tracingChannel, bypass } = diagnostics_channel; +const kMyTool = Symbol('my-tool'); +const tc = tracingChannel('my-operation'); + +// All TracingChannel events skipped when bypass(kMyTool) is active +tc.subscribe({ + start(message) { /* ... */ }, + end(message) { /* ... */ }, + asyncStart(message) { /* ... */ }, + asyncEnd(message) { /* ... */ }, +}, { bypassId: kMyTool }); + +bypass(kMyTool, () => { + tc.traceSync(() => { + // Start and end handlers are NOT called + }); +}); +``` + #### `tracingChannel.unsubscribe(subscribers)`