From 86b7148513251f629c983d90cc7f1b8ef20f8772 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 25 Jun 2026 14:36:52 +0200 Subject: [PATCH 1/3] add integration tests --- .../lru-memoizer/instrument-orchestrion.mjs | 17 +++ .../suites/tracing/lru-memoizer/test.ts | 119 ++++++++++++------ 2 files changed, 97 insertions(+), 39 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/lru-memoizer/instrument-orchestrion.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/lru-memoizer/instrument-orchestrion.mjs b/dev-packages/node-integration-tests/suites/tracing/lru-memoizer/instrument-orchestrion.mjs new file mode 100644 index 000000000000..1565f28886ad --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/lru-memoizer/instrument-orchestrion.mjs @@ -0,0 +1,17 @@ +// Opting in via `experimentalUseDiagnosticsChannelInjection()` (before `init`) +// is all that's needed. +// +// `Sentry.init()` swaps the OTel `lru-memoizer` instrumentation +// for the diagnostics-channel one and synchronously +// installs the module hooks that inject the channels. +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.experimentalUseDiagnosticsChannelInjection(); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/lru-memoizer/test.ts b/dev-packages/node-integration-tests/suites/tracing/lru-memoizer/test.ts index 370c44be3842..958b3e119d59 100644 --- a/dev-packages/node-integration-tests/suites/tracing/lru-memoizer/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/lru-memoizer/test.ts @@ -7,51 +7,92 @@ describe('lru-memoizer', () => { cleanupChildProcesses(); }); - createEsmAndCjsTests( - __dirname, - 'scenario.mjs', - 'instrument.mjs', - (createTestRunner, test) => { - test('keeps outer context inside the memoized inner functions', async () => { - await createTestRunner() - .expect({ + // Each case maps to the OpenTelemetry default and the diagnostics-channel opt-in + // variants, mirroring the mysql suite. `flags` are extra Node CLI flags; the + // instrument file is always loaded via `--import` (esm) / `--require` (cjs). + // + // lru-memoizer creates no spans, so there's no `sentry.origin` to + // assert: the opt-in cases prove the channel ran because the opt-in removes the + // OTel integration via `replacedOtelIntegrationNames`. + const CASES = [ + // OpenTelemetry default — no opt-in, no injection. (OTel does not support ESM.) + { label: 'opentelemetry (default)', instrument: 'instrument.mjs', flags: [], failsOnEsm: true }, + // Opt-in via init only. `Sentry.init()` injects the channels synchronously. + { + label: 'diagnostics-channel (init opt-in)', + instrument: 'instrument-orchestrion.mjs', + flags: [], + failsOnEsm: false, + }, + // Opt-in and rely on `node --import @sentry/node/import`. + { + label: 'diagnostics-channel (--import @sentry/node/import opt-in)', + instrument: 'instrument-orchestrion.mjs', + flags: ['--import', '@sentry/node/import'], + failsOnEsm: false, + }, + // Without opt-in: channels are injected unconditionally but not subscribed to, + // so the OTel instrumentation does the work — proves injecting the channels has + // no downside. (OTel does not support ESM.) + { + label: 'opentelemetry (channels injected, no opt-in)', + instrument: 'instrument.mjs', + flags: ['--import', '@sentry/node/import'], + failsOnEsm: true, + }, + ] as const; + + for (const { label, instrument, flags, failsOnEsm } of CASES) { + describe(label, () => { + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + instrument, + (createTestRunner, test) => { + test('keeps outer context inside the memoized inner functions', async () => { + await createTestRunner() + .withFlags(...flags) + .expect({ + transaction: { + transaction: '', + contexts: { + trace: expect.objectContaining({ + op: 'run', + data: expect.objectContaining({ + 'sentry.op': 'run', + 'sentry.origin': 'manual', + 'memoized.context_preserved': true, + }), + }), + }, + }, + }) + .start() + .completed(); + }); + }, + { failsOnEsm }, + ); + + // CJS-only: the parallel scenario is flaky in ESM (see #21729). + createCjsTests(__dirname, 'scenario-parallel.mjs', instrument, (createTestRunner, test) => { + test('keeps each span context across parallel memoized requests', async () => { + // Each parallel request emits a transaction whose callback must have run in its own context. + // Two identical expectations keep this order-independent. + const expectation = { transaction: { - transaction: '', contexts: { trace: expect.objectContaining({ - op: 'run', - data: expect.objectContaining({ - 'sentry.op': 'run', - 'sentry.origin': 'manual', - 'memoized.context_preserved': true, - }), + op: expect.stringMatching(/^(first|second)$/), + data: expect.objectContaining({ 'memoized.context_preserved': true }), }), }, }, - }) - .start() - .completed(); - }); - }, - { failsOnEsm: true }, - ); - - createCjsTests(__dirname, 'scenario-parallel.mjs', 'instrument.mjs', (createTestRunner, test) => { - test('keeps each span context across parallel memoized requests', async () => { - // Each parallel request emits a transaction whose callback must have run in its own context. - // Two identical expectations keep this order-independent. - const expectation = { - transaction: { - contexts: { - trace: expect.objectContaining({ - op: expect.stringMatching(/^(first|second)$/), - data: expect.objectContaining({ 'memoized.context_preserved': true }), - }), - }, - }, - }; + }; - await createTestRunner().expect(expectation).expect(expectation).start().completed(); + await createTestRunner().withFlags(...flags).expect(expectation).expect(expectation).start().completed(); + }); + }); }); - }); + } }); From 783398f45e042d9989ac8d40b7da3abbd7c94b7c Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 25 Jun 2026 15:34:13 +0200 Subject: [PATCH 2/3] feat(node): Add diagnostics-channel instrumentation for lru-memoizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an experimental orchestrion (diagnostics-channel) integration for lru-memoizer, active only when `experimentalUseDiagnosticsChannelInjection()` is opted into. It rebinds the memoized callback to the caller's active span (the channel equivalent of the OTel `context.bind`) and creates no spans. Purely additive — the vendored OTel integration stays the default and is filtered out via `replacedOtelIntegrationNames` only when opted in. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../suites/tracing/lru-memoizer/test.ts | 7 +- ...erimentalUseDiagnosticsChannelInjection.ts | 10 ++- .../tracing-channel/lru-memoizer.ts | 65 +++++++++++++++++++ .../server-utils/src/orchestrion/channels.ts | 1 + .../server-utils/src/orchestrion/config.ts | 6 ++ .../server-utils/src/orchestrion/index.ts | 1 + 6 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/lru-memoizer/test.ts b/dev-packages/node-integration-tests/suites/tracing/lru-memoizer/test.ts index 958b3e119d59..71bf4fd50997 100644 --- a/dev-packages/node-integration-tests/suites/tracing/lru-memoizer/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/lru-memoizer/test.ts @@ -90,7 +90,12 @@ describe('lru-memoizer', () => { }, }; - await createTestRunner().withFlags(...flags).expect(expectation).expect(expectation).start().completed(); + await createTestRunner() + .withFlags(...flags) + .expect(expectation) + .expect(expectation) + .start() + .completed(); }); }); }); diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index d51f2d86a610..eebf3ac4941a 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -1,4 +1,8 @@ -import { mysqlChannelIntegration, detectOrchestrionSetup } from '@sentry/server-utils/orchestrion'; +import { + mysqlChannelIntegration, + lruMemoizerChannelIntegration, + detectOrchestrionSetup, +} from '@sentry/server-utils/orchestrion'; import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; import type { DiagnosticsChannelInjection } from './diagnosticsChannelInjection'; import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInjection'; @@ -38,8 +42,8 @@ import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInject export function experimentalUseDiagnosticsChannelInjection(): void { setDiagnosticsChannelInjectionLoader( (): DiagnosticsChannelInjection => ({ - integrations: [mysqlChannelIntegration()], - replacedOtelIntegrationNames: ['Mysql'], + integrations: [mysqlChannelIntegration(), lruMemoizerChannelIntegration()], + replacedOtelIntegrationNames: ['Mysql', 'LruMemoizer'], register: registerDiagnosticsChannelInjection, detect: detectOrchestrionSetup, }), diff --git a/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts b/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts new file mode 100644 index 000000000000..57ff54d6f84d --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts @@ -0,0 +1,65 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn } from '@sentry/core'; +import { debug, defineIntegration, getActiveSpan, withActiveSpan } from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import { CHANNELS } from '../../orchestrion/channels'; + +// Same name as the OTel integration by design — when enabled, the OTel +// 'LruMemoizer' integration is omitted from the default set. +const INTEGRATION_NAME = 'LruMemoizer'; + +interface LruMemoizerChannelContext { + arguments: unknown[]; +} + +const _lruMemoizerChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + // `tracingChannel` is unavailable before Node 18.19 — no-op instead of throwing (#21783). + if (!diagnosticsChannel.tracingChannel) { + return; + } + + DEBUG_BUILD && debug.log(`[orchestrion:lru-memoizer] subscribing to channel "${CHANNELS.LRU_MEMOIZER_LOAD}"`); + const lruMemoizerCh = diagnosticsChannel.tracingChannel(CHANNELS.LRU_MEMOIZER_LOAD); + + lruMemoizerCh.subscribe({ + // lru-memoizer queues the callback and fires it later via setImmediate, from a + // different async context. Rebind it to the caller's active span (still correct + // here, synchronously inside the memoized call) so nested spans parent correctly. + // This is the channel equivalent of the OTel version's `context.bind(context.active(), cb)`. + // orchestrion (kind: 'Callback') has already spliced its own wrapper into the last + // arg by the time `start` fires, and only publishes `start` when that arg is a function. + start(rawCtx) { + const ctx = rawCtx as LruMemoizerChannelContext; + const parentSpan = getActiveSpan(); + if (!parentSpan || ctx.arguments.length === 0) { + return; + } + const cbIdx = ctx.arguments.length - 1; + const orchestrionWrappedCb = ctx.arguments[cbIdx]; + if (typeof orchestrionWrappedCb !== 'function') { + return; + } + const wrapped = orchestrionWrappedCb as (...a: unknown[]) => unknown; + ctx.arguments[cbIdx] = function (this: unknown, ...args: unknown[]): unknown { + return withActiveSpan(parentSpan, () => wrapped.apply(this, args)); + }; + }, + end() {}, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * EXPERIMENTAL — orchestrion-driven lru-memoizer integration. Subscribes to + * `orchestrion:lru-memoizer:load` (injected into `lru-memoizer/lib/async.js`'s + * `memoizedFunction`). Creates no spans; only rebinds the memoized callback to the + * caller's active span. Requires the orchestrion runtime hook or bundler plugin. + */ +export const lruMemoizerChannelIntegration = defineIntegration(_lruMemoizerChannelIntegration); diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 28dcf0c33468..ad2d8ccdd4dd 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -13,6 +13,7 @@ */ export const CHANNELS = { MYSQL_QUERY: 'orchestrion:mysql:query', + LRU_MEMOIZER_LOAD: 'orchestrion:lru-memoizer:load', } as const; export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/server-utils/src/orchestrion/config.ts b/packages/server-utils/src/orchestrion/config.ts index 35b326fb8eb1..104df2185386 100644 --- a/packages/server-utils/src/orchestrion/config.ts +++ b/packages/server-utils/src/orchestrion/config.ts @@ -32,6 +32,12 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ // attach `'end'`/`'error'` listeners that finish the span. functionQuery: { expressionName: 'query', kind: 'Auto' }, }, + { + channelName: 'load', + // `>=2.1.0` only: the named `function memoizedFunction()` the selector targets exists from 2.1.0 + module: { name: 'lru-memoizer', versionRange: '>=2.1.0 <4', filePath: 'lib/async.js' }, + functionQuery: { functionName: 'memoizedFunction', kind: 'Callback' }, + }, ]; /** diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index dd3ecd0f8f19..4b182e51ec13 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -1,2 +1,3 @@ export { detectOrchestrionSetup } from './detect'; export { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; +export { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer'; From d14c87d4cf08dc94d32682d486a6b3a55ae2af36 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 26 Jun 2026 21:05:56 +0200 Subject: [PATCH 3/3] ref(server-utils): Capture full scope via withScope in lru-memoizer channel subscriber MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the lru-memoizer diagnostics-channel subscriber with the mysql one: capture `getCurrentScope()` and re-run the memoized callback via `withScope(scope, …)` instead of `withActiveSpan(getActiveSpan(), …)`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tracing-channel/lru-memoizer.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts b/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts index 57ff54d6f84d..1a25540257f6 100644 --- a/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts +++ b/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts @@ -1,6 +1,6 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn } from '@sentry/core'; -import { debug, defineIntegration, getActiveSpan, withActiveSpan } from '@sentry/core'; +import { debug, defineIntegration, getCurrentScope, withScope } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; @@ -25,26 +25,26 @@ const _lruMemoizerChannelIntegration = (() => { const lruMemoizerCh = diagnosticsChannel.tracingChannel(CHANNELS.LRU_MEMOIZER_LOAD); lruMemoizerCh.subscribe({ - // lru-memoizer queues the callback and fires it later via setImmediate, from a - // different async context. Rebind it to the caller's active span (still correct - // here, synchronously inside the memoized call) so nested spans parent correctly. - // This is the channel equivalent of the OTel version's `context.bind(context.active(), cb)`. - // orchestrion (kind: 'Callback') has already spliced its own wrapper into the last - // arg by the time `start` fires, and only publishes `start` when that arg is a function. start(rawCtx) { const ctx = rawCtx as LruMemoizerChannelContext; - const parentSpan = getActiveSpan(); - if (!parentSpan || ctx.arguments.length === 0) { + if (ctx.arguments.length === 0) { return; } + + // Capture the scope while we're still synchronously inside the memoized call. + // lru-memoizer queues the callback and fires it later via setImmediate, where the + // active scope no longer reflects the caller's context. + const scope = getCurrentScope(); const cbIdx = ctx.arguments.length - 1; const orchestrionWrappedCb = ctx.arguments[cbIdx]; + if (typeof orchestrionWrappedCb !== 'function') { return; } + const wrapped = orchestrionWrappedCb as (...a: unknown[]) => unknown; ctx.arguments[cbIdx] = function (this: unknown, ...args: unknown[]): unknown { - return withActiveSpan(parentSpan, () => wrapped.apply(this, args)); + return withScope(scope, () => wrapped.apply(this, args)); }; }, end() {}, @@ -59,7 +59,7 @@ const _lruMemoizerChannelIntegration = (() => { /** * EXPERIMENTAL — orchestrion-driven lru-memoizer integration. Subscribes to * `orchestrion:lru-memoizer:load` (injected into `lru-memoizer/lib/async.js`'s - * `memoizedFunction`). Creates no spans; only rebinds the memoized callback to the - * caller's active span. Requires the orchestrion runtime hook or bundler plugin. + * `memoizedFunction`). Creates no spans; only re-runs the memoized callback with the + * caller's scope. Requires the orchestrion runtime hook or bundler plugin. */ export const lruMemoizerChannelIntegration = defineIntegration(_lruMemoizerChannelIntegration);