Skip to content

Commit 75fa393

Browse files
committed
feat(server-utils): Restore caller context for callback tracing channels
`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.
1 parent 00e3ebb commit 75fa393

3 files changed

Lines changed: 82 additions & 32 deletions

File tree

packages/server-utils/src/integrations/tracing-channel/lru-memoizer.ts

Lines changed: 15 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import * as diagnosticsChannel from 'node:diagnostics_channel';
22
import type { IntegrationFn } from '@sentry/core';
3-
import { debug, defineIntegration, getCurrentScope, withScope } from '@sentry/core';
3+
import { debug, defineIntegration, waitForTracingChannelBinding } from '@sentry/core';
44
import { DEBUG_BUILD } from '../../debug-build';
55
import { CHANNELS } from '../../orchestrion/channels';
6+
import { bindTracingChannelToSpan } from '../../tracing-channel';
67

78
// Same name as the OTel integration by design — when enabled, the OTel
89
// 'LruMemoizer' integration is omitted from the default set.
910
const INTEGRATION_NAME = 'LruMemoizer' as const;
1011

11-
interface LruMemoizerChannelContext {
12+
interface LruMemoizerLoadContext {
1213
arguments: unknown[];
1314
}
1415

@@ -22,35 +23,19 @@ const _lruMemoizerChannelIntegration = (() => {
2223
}
2324

2425
DEBUG_BUILD && debug.log(`[orchestrion:lru-memoizer] subscribing to channel "${CHANNELS.LRU_MEMOIZER_LOAD}"`);
25-
const lruMemoizerCh = diagnosticsChannel.tracingChannel(CHANNELS.LRU_MEMOIZER_LOAD);
2626

27-
lruMemoizerCh.subscribe({
28-
start(rawCtx) {
29-
const ctx = rawCtx as LruMemoizerChannelContext;
30-
if (ctx.arguments.length === 0) {
31-
return;
32-
}
33-
34-
// Capture the scope while we're still synchronously inside the memoized call.
35-
// lru-memoizer queues the callback and fires it later via setImmediate, where the
36-
// active scope no longer reflects the caller's context.
37-
const scope = getCurrentScope();
38-
const cbIdx = ctx.arguments.length - 1;
39-
const orchestrionWrappedCb = ctx.arguments[cbIdx];
40-
41-
if (typeof orchestrionWrappedCb !== 'function') {
42-
return;
43-
}
44-
45-
const wrapped = orchestrionWrappedCb as (...a: unknown[]) => unknown;
46-
ctx.arguments[cbIdx] = function (this: unknown, ...args: unknown[]): unknown {
47-
return withScope(scope, () => wrapped.apply(this, args));
48-
};
49-
},
50-
end() {},
51-
asyncStart() {},
52-
asyncEnd() {},
53-
error() {},
27+
// lru-memoizer creates no span: it queues the load callback and fires it later via
28+
// `setImmediate`, from a detached context where the caller's scope is no longer active.
29+
// Returning `undefined` from `getSpan` opts out of span creation entirely — the helper's
30+
// `asyncStart` rebind still restores the caller's context for that callback, which is all this
31+
// instrumentation needs (keeping memoized work parented to the caller). `bindTracingChannelToSpan`
32+
// uses `bindStore`, which needs the async-context binding `initOpenTelemetry()` registers after
33+
// integration `setupOnce`, so defer until it's available (matches the other channel subscribers).
34+
waitForTracingChannelBinding(() => {
35+
bindTracingChannelToSpan(
36+
diagnosticsChannel.tracingChannel<LruMemoizerLoadContext>(CHANNELS.LRU_MEMOIZER_LOAD),
37+
() => undefined,
38+
);
5439
});
5540
},
5641
};

packages/server-utils/src/tracing-channel.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,15 @@ import { DEBUG_BUILD } from './debug-build';
66
import { ERROR_TYPE } from '@sentry/conventions/attributes';
77

88
export type TracingChannelPayloadWithSpan<TData extends object> = TData & {
9+
/**
10+
* The current active span for the traced call.
11+
*/
912
_sentrySpan?: Span;
13+
14+
/**
15+
* The context's active store value, used to restore the context for asyncStart continuations for callback-based tracing.
16+
*/
17+
_sentryCallerStore?: unknown;
1018
};
1119

1220
/*
@@ -158,21 +166,33 @@ function bindSpanToChannelStore<TData extends object>(
158166
// 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.
159167
// 4. Nest: any child span started in the traced op parents to that active span.
160168
channel.start.bindStore(asyncLocalStorage, (data: TracingChannelPayloadWithSpan<TData>) => {
169+
// Stash the caller's store before we swap in the span store, so `asyncStart` can restore it for
170+
// callback-style channels (see `_sentryCallerStore`).
171+
data._sentryCallerStore = asyncLocalStorage.getStore();
172+
161173
const span = getSpan(data);
162174
if (!span) {
163175
// Leave the active context untouched so nested operations keep parenting to the enclosing span.
164-
return asyncLocalStorage.getStore() as TData;
176+
return data._sentryCallerStore as TData;
165177
}
166178
data._sentrySpan = span;
167179

168180
return binding.getStoreWithActiveSpan(span) as TData;
169181
});
170182

183+
// Restore the caller's context for the async continuation. Only callback-style channels `runStores`
184+
// `asyncStart` (so the callback runs inside this store); promise channels `publish` it, leaving this
185+
// inert — their continuation already inherits the caller's context natively.
186+
channel.asyncStart.bindStore(asyncLocalStorage, (data: TracingChannelPayloadWithSpan<TData>) => {
187+
return (data._sentryCallerStore ?? asyncLocalStorage.getStore()) as TData;
188+
});
189+
171190
return {
172191
channel,
173192
unbind: () => {
174-
// Removes the store
193+
// Removes the stores
175194
channel.start.unbindStore(asyncLocalStorage);
195+
channel.asyncStart.unbindStore(asyncLocalStorage);
176196
},
177197
};
178198
}

packages/server-utils/test/tracing-channel.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,51 @@ describe('bindTracingChannelToSpan', () => {
175175
expect(childParentSpanId).toBe(parent.spanContext().spanId);
176176
});
177177

178+
it('restores the caller context in a callback dispatched from a detached context (asyncStart rebind)', async () => {
179+
installTestAsyncContextStrategy();
180+
initTestClient();
181+
182+
let channelSpanId: string | undefined;
183+
const { channel } = bindTracingChannelToSpan(
184+
tracingChannel<{ operation: string }>('test:asyncStart:caller-context'),
185+
() => {
186+
const span = startInactiveSpan({ name: 'channel-span' });
187+
channelSpanId = span.spanContext().spanId;
188+
return span;
189+
},
190+
);
191+
192+
let enclosingSpanId: string | undefined;
193+
let childParentSpanId: string | undefined;
194+
195+
await new Promise<void>(done => {
196+
startSpan({ forceTransaction: true, name: 'enclosing-span' }, enclosing => {
197+
enclosingSpanId = enclosing.spanContext().spanId;
198+
channel.traceCallback(
199+
(cb: (err: Error | null, result?: string) => void) => {
200+
// Fire the callback after the enclosing scope has exited, so it runs in a detached
201+
// async context — the asyncStart rebind is the only thing that can restore the caller's.
202+
setTimeout(() => cb(null, 'ok'), 1);
203+
},
204+
0,
205+
{ operation: 'read' },
206+
undefined,
207+
() => {
208+
startSpan({ name: 'child-span' }, child => {
209+
childParentSpanId = spanToJSON(child).parent_span_id;
210+
});
211+
done();
212+
},
213+
);
214+
});
215+
});
216+
217+
// A span started inside the callback parents to the caller (the enclosing span), not to the
218+
// channel span — matching how a promise's `.then` continuation behaves.
219+
expect(childParentSpanId).toBe(enclosingSpanId);
220+
expect(childParentSpanId).not.toBe(channelSpanId);
221+
});
222+
178223
describe('auto lifecycle ending strategy', () => {
179224
// Returns a channel whose span we can observe, plus spies for `span.end` and `captureException`.
180225
function setup(name: string): {

0 commit comments

Comments
 (0)