From 75fa393140dc549c2e88cb33a67bc253ac01d66e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 30 Jun 2026 10:32:01 -0400 Subject: [PATCH 1/5] feat(server-utils): Restore caller context for callback tracing channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bindTracingChannelToSpan` only bound the span store on `start`, which covers the synchronous frame but not a callback the library dispatches from a detached async context (e.g. a socket data handler or a `setImmediate` drain). There, native async-context propagation no longer reaches the caller, so work issued inside the callback lost its parent. Stash the caller's store at `start` and re-bind it on `asyncStart`, so callback-style channels run their continuation in the caller's context — the same way a promise's `.then` does natively. It's inert for promise channels, which `publish` `asyncStart` rather than `runStores` it. Migrate the lru-memoizer subscriber onto the helper (`getSpan` returns `undefined`, so no span is created — it only needs the context rebind), dropping its hand-rolled callback re-wrapping. --- .../tracing-channel/lru-memoizer.ts | 45 +++++++------------ packages/server-utils/src/tracing-channel.ts | 24 +++++++++- .../server-utils/test/tracing-channel.test.ts | 45 +++++++++++++++++++ 3 files changed, 82 insertions(+), 32 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 1ca463b3b319..4e18c0b49348 100644 --- a/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts +++ b/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts @@ -1,14 +1,15 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn } from '@sentry/core'; -import { debug, defineIntegration, getCurrentScope, withScope } from '@sentry/core'; +import { debug, defineIntegration, waitForTracingChannelBinding } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; +import { bindTracingChannelToSpan } from '../../tracing-channel'; // Same name as the OTel integration by design — when enabled, the OTel // 'LruMemoizer' integration is omitted from the default set. const INTEGRATION_NAME = 'LruMemoizer' as const; -interface LruMemoizerChannelContext { +interface LruMemoizerLoadContext { arguments: unknown[]; } @@ -22,35 +23,19 @@ const _lruMemoizerChannelIntegration = (() => { } DEBUG_BUILD && debug.log(`[orchestrion:lru-memoizer] subscribing to channel "${CHANNELS.LRU_MEMOIZER_LOAD}"`); - const lruMemoizerCh = diagnosticsChannel.tracingChannel(CHANNELS.LRU_MEMOIZER_LOAD); - lruMemoizerCh.subscribe({ - start(rawCtx) { - const ctx = rawCtx as LruMemoizerChannelContext; - 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 withScope(scope, () => wrapped.apply(this, args)); - }; - }, - end() {}, - asyncStart() {}, - asyncEnd() {}, - error() {}, + // lru-memoizer creates no span: it queues the load callback and fires it later via + // `setImmediate`, from a detached context where the caller's scope is no longer active. + // Returning `undefined` from `getSpan` opts out of span creation entirely — the helper's + // `asyncStart` rebind still restores the caller's context for that callback, which is all this + // instrumentation needs (keeping memoized work parented to the caller). `bindTracingChannelToSpan` + // uses `bindStore`, which needs the async-context binding `initOpenTelemetry()` registers after + // integration `setupOnce`, so defer until it's available (matches the other channel subscribers). + waitForTracingChannelBinding(() => { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.LRU_MEMOIZER_LOAD), + () => undefined, + ); }); }, }; diff --git a/packages/server-utils/src/tracing-channel.ts b/packages/server-utils/src/tracing-channel.ts index 6fccf6820ec5..c8d5701dd11b 100644 --- a/packages/server-utils/src/tracing-channel.ts +++ b/packages/server-utils/src/tracing-channel.ts @@ -6,7 +6,15 @@ import { DEBUG_BUILD } from './debug-build'; import { ERROR_TYPE } from '@sentry/conventions/attributes'; export type TracingChannelPayloadWithSpan = TData & { + /** + * The current active span for the traced call. + */ _sentrySpan?: Span; + + /** + * The context's active store value, used to restore the context for asyncStart continuations for callback-based tracing. + */ + _sentryCallerStore?: unknown; }; /* @@ -158,21 +166,33 @@ function bindSpanToChannelStore( // 3. Read: inside the op, Sentry's scope machinery calls getScopes() → asyncStorage.getStore() on that same ALS, so getCurrentScope/getIsolationScope/getActiveSpan resolve to the scope carrying our span. // 4. Nest: any child span started in the traced op parents to that active span. channel.start.bindStore(asyncLocalStorage, (data: TracingChannelPayloadWithSpan) => { + // Stash the caller's store before we swap in the span store, so `asyncStart` can restore it for + // callback-style channels (see `_sentryCallerStore`). + data._sentryCallerStore = asyncLocalStorage.getStore(); + const span = getSpan(data); if (!span) { // Leave the active context untouched so nested operations keep parenting to the enclosing span. - return asyncLocalStorage.getStore() as TData; + return data._sentryCallerStore as TData; } data._sentrySpan = span; return binding.getStoreWithActiveSpan(span) as TData; }); + // Restore the caller's context for the async continuation. Only callback-style channels `runStores` + // `asyncStart` (so the callback runs inside this store); promise channels `publish` it, leaving this + // inert — their continuation already inherits the caller's context natively. + channel.asyncStart.bindStore(asyncLocalStorage, (data: TracingChannelPayloadWithSpan) => { + return (data._sentryCallerStore ?? asyncLocalStorage.getStore()) as TData; + }); + return { channel, unbind: () => { - // Removes the store + // Removes the stores channel.start.unbindStore(asyncLocalStorage); + channel.asyncStart.unbindStore(asyncLocalStorage); }, }; } diff --git a/packages/server-utils/test/tracing-channel.test.ts b/packages/server-utils/test/tracing-channel.test.ts index fdaeee36476f..0ae5093dd559 100644 --- a/packages/server-utils/test/tracing-channel.test.ts +++ b/packages/server-utils/test/tracing-channel.test.ts @@ -175,6 +175,51 @@ describe('bindTracingChannelToSpan', () => { expect(childParentSpanId).toBe(parent.spanContext().spanId); }); + it('restores the caller context in a callback dispatched from a detached context (asyncStart rebind)', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + + let channelSpanId: string | undefined; + const { channel } = bindTracingChannelToSpan( + tracingChannel<{ operation: string }>('test:asyncStart:caller-context'), + () => { + const span = startInactiveSpan({ name: 'channel-span' }); + channelSpanId = span.spanContext().spanId; + return span; + }, + ); + + let enclosingSpanId: string | undefined; + let childParentSpanId: string | undefined; + + await new Promise(done => { + startSpan({ forceTransaction: true, name: 'enclosing-span' }, enclosing => { + enclosingSpanId = enclosing.spanContext().spanId; + channel.traceCallback( + (cb: (err: Error | null, result?: string) => void) => { + // Fire the callback after the enclosing scope has exited, so it runs in a detached + // async context — the asyncStart rebind is the only thing that can restore the caller's. + setTimeout(() => cb(null, 'ok'), 1); + }, + 0, + { operation: 'read' }, + undefined, + () => { + startSpan({ name: 'child-span' }, child => { + childParentSpanId = spanToJSON(child).parent_span_id; + }); + done(); + }, + ); + }); + }); + + // A span started inside the callback parents to the caller (the enclosing span), not to the + // channel span — matching how a promise's `.then` continuation behaves. + expect(childParentSpanId).toBe(enclosingSpanId); + expect(childParentSpanId).not.toBe(channelSpanId); + }); + describe('auto lifecycle ending strategy', () => { // Returns a channel whose span we can observe, plus spies for `span.end` and `captureException`. function setup(name: string): { From 910fdfe17caf57db225388ad294adfb18f5cb0cc Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 30 Jun 2026 10:33:01 -0400 Subject: [PATCH 2/5] chore: Reword comment --- packages/server-utils/src/tracing-channel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server-utils/src/tracing-channel.ts b/packages/server-utils/src/tracing-channel.ts index c8d5701dd11b..9d2355b4fa0e 100644 --- a/packages/server-utils/src/tracing-channel.ts +++ b/packages/server-utils/src/tracing-channel.ts @@ -181,8 +181,8 @@ function bindSpanToChannelStore( }); // Restore the caller's context for the async continuation. Only callback-style channels `runStores` - // `asyncStart` (so the callback runs inside this store); promise channels `publish` it, leaving this - // inert — their continuation already inherits the caller's context natively. + // `asyncStart` (so the callback runs inside this store). promise channels `publish` it, leaving this + // inert, their continuation already inherits the caller's context natively. channel.asyncStart.bindStore(asyncLocalStorage, (data: TracingChannelPayloadWithSpan) => { return (data._sentryCallerStore ?? asyncLocalStorage.getStore()) as TData; }); From e70e4a85f1425b1a4944083db81bf49dbbb350c0 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 30 Jun 2026 15:19:14 -0400 Subject: [PATCH 3/5] fix(server-utils): Don't leak a foreign store into a callback with no caller context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The asyncStart producer used `data._sentryCallerStore ?? asyncLocalStorage.getStore()`. When the caller had no active store, `_sentryCallerStore` is `undefined` and the fallback bound whatever was ambient at callback time — for a callback dispatched from a pooled socket handler, that can be another request's store. `start` always runs first and sets `_sentryCallerStore`, so return it verbatim (no fallback), matching the synchronous path: no caller context restores to none, not a foreign one. --- packages/server-utils/src/tracing-channel.ts | 2 +- .../server-utils/test/tracing-channel.test.ts | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/server-utils/src/tracing-channel.ts b/packages/server-utils/src/tracing-channel.ts index 9d2355b4fa0e..bedc59d2e652 100644 --- a/packages/server-utils/src/tracing-channel.ts +++ b/packages/server-utils/src/tracing-channel.ts @@ -184,7 +184,7 @@ function bindSpanToChannelStore( // `asyncStart` (so the callback runs inside this store). promise channels `publish` it, leaving this // inert, their continuation already inherits the caller's context natively. channel.asyncStart.bindStore(asyncLocalStorage, (data: TracingChannelPayloadWithSpan) => { - return (data._sentryCallerStore ?? asyncLocalStorage.getStore()) as TData; + return data._sentryCallerStore as TData; }); return { diff --git a/packages/server-utils/test/tracing-channel.test.ts b/packages/server-utils/test/tracing-channel.test.ts index 0ae5093dd559..a6d26b3f5f3a 100644 --- a/packages/server-utils/test/tracing-channel.test.ts +++ b/packages/server-utils/test/tracing-channel.test.ts @@ -220,6 +220,36 @@ describe('bindTracingChannelToSpan', () => { expect(childParentSpanId).not.toBe(channelSpanId); }); + it('does not leak an unrelated active store into the callback when the caller had none', () => { + installTestAsyncContextStrategy(); + initTestClient(); + + const { channel } = bindTracingChannelToSpan(tracingChannel<{ operation: string }>('test:asyncStart:no-leak'), () => + startInactiveSpan({ name: 'channel-span' }), + ); + + // Caller issues the op with no active context, so the caller store is captured as `undefined`. + const ctx = { operation: 'read' }; + channel.start.runStores(ctx, () => undefined); + + let otherRequestSpanId: string | undefined; + let childParentSpanId: string | undefined; + + // The callback fires later, dispatched from *another* request's active context. + startSpan({ forceTransaction: true, name: 'other-request' }, other => { + otherRequestSpanId = other.spanContext().spanId; + channel.asyncStart.runStores(ctx, () => { + startSpan({ name: 'child-span' }, child => { + childParentSpanId = spanToJSON(child).parent_span_id; + }); + }); + }); + + // The caller had no context, so the callback must restore to none — not adopt the other request's. + expect(childParentSpanId).toBeUndefined(); + expect(childParentSpanId).not.toBe(otherRequestSpanId); + }); + describe('auto lifecycle ending strategy', () => { // Returns a channel whose span we can observe, plus spies for `span.end` and `captureException`. function setup(name: string): { From dea56c038d1e5c89d12a02a0bcf48feb5eb065ae Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 30 Jun 2026 15:22:38 -0400 Subject: [PATCH 4/5] chore: trim the yap --- .../src/integrations/tracing-channel/lru-memoizer.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 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 4e18c0b49348..2b9636abb8fd 100644 --- a/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts +++ b/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts @@ -24,13 +24,8 @@ const _lruMemoizerChannelIntegration = (() => { DEBUG_BUILD && debug.log(`[orchestrion:lru-memoizer] subscribing to channel "${CHANNELS.LRU_MEMOIZER_LOAD}"`); - // lru-memoizer creates no span: it queues the load callback and fires it later via - // `setImmediate`, from a detached context where the caller's scope is no longer active. - // Returning `undefined` from `getSpan` opts out of span creation entirely — the helper's - // `asyncStart` rebind still restores the caller's context for that callback, which is all this - // instrumentation needs (keeping memoized work parented to the caller). `bindTracingChannelToSpan` - // uses `bindStore`, which needs the async-context binding `initOpenTelemetry()` registers after - // integration `setupOnce`, so defer until it's available (matches the other channel subscribers). + // We only want the helper's caller-context restore for the + // callback lru-memoizer fires from a detached `setImmediate`. Defer until the bindStore binding exists. waitForTracingChannelBinding(() => { bindTracingChannelToSpan( diagnosticsChannel.tracingChannel(CHANNELS.LRU_MEMOIZER_LOAD), From 1fa066333d67ab662dbf70d28a4d1412fd9e8651 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 30 Jun 2026 15:24:35 -0400 Subject: [PATCH 5/5] chore: move comment to getSpan arg --- .../src/integrations/tracing-channel/lru-memoizer.ts | 3 +-- 1 file changed, 1 insertion(+), 2 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 2b9636abb8fd..982e5200b4ef 100644 --- a/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts +++ b/packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts @@ -24,11 +24,10 @@ const _lruMemoizerChannelIntegration = (() => { DEBUG_BUILD && debug.log(`[orchestrion:lru-memoizer] subscribing to channel "${CHANNELS.LRU_MEMOIZER_LOAD}"`); - // We only want the helper's caller-context restore for the - // callback lru-memoizer fires from a detached `setImmediate`. Defer until the bindStore binding exists. waitForTracingChannelBinding(() => { bindTracingChannelToSpan( diagnosticsChannel.tracingChannel(CHANNELS.LRU_MEMOIZER_LOAD), + // We only want the helper's caller-context restore for the callback lru-memoizer fires from a detached `setImmediate`. () => undefined, ); });