Skip to content

Commit 08b9cbb

Browse files
logaretmclaude
andcommitted
feat(server-utils): Support opting payloads out of bindTracingChannelToSpan + captureError callback
`getSpan` may now return `undefined` to opt a channel payload out entirely: nothing is bound, no span is tracked, and the active context is left untouched so nested operations keep parenting to the enclosing span. This lets a single channel carry events that should reuse the enclosing span (e.g. an agent loop's per-step events) without ending it prematurely. The `error` handler likewise no-ops when no span was bound. Also folds in the `captureError` callback form: pass a function to set the ExclusiveEventHintOrCaptureContext on the captured error, so integrations can supply their own mechanism. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e34e28a commit 08b9cbb

2 files changed

Lines changed: 189 additions & 12 deletions

File tree

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

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { TracingChannel, TracingChannelSubscribers } from 'node:diagnostics_channel';
22
import type { AsyncLocalStorage } from 'node:async_hooks';
3-
import type { Span } from '@sentry/core';
3+
import type { ExclusiveEventHintOrCaptureContext, Span } from '@sentry/core';
44
import { _INTERNAL_getTracingChannelBinding, debug, captureException, SPAN_STATUS_ERROR } from '@sentry/core';
55
import { DEBUG_BUILD } from './debug-build';
66

@@ -39,11 +39,11 @@ interface TracingChannelAutoBindingOptions<TData extends object = object> {
3939
beforeSpanEnd?: (span: Span, data: TracingChannelPayloadWithSpan<TData>) => void;
4040

4141
/**
42-
* Whether a thrown error is captured as a Sentry event. The span is always marked with error
43-
* status regardless. Defaults to `true`.
42+
* Whether a thrown error is captured as a Sentry event. The span is always marked with error status regardless. Defaults to `true`.
43+
* You can alternatively pass a function that sets the ExclusiveEventHintOrCaptureContext on the captured error.
4444
* Set `false` for instrumentation that only annotates the span and lets the error be captured at the boundary that owns it (e.g. db spans).
4545
*/
46-
captureError?: boolean;
46+
captureError?: boolean | ((e: unknown) => ExclusiveEventHintOrCaptureContext);
4747
}
4848

4949
export type TracingChannelBindingOptions<TData extends object = object> =
@@ -63,9 +63,18 @@ export interface TracingChannelBindingHandle<TData extends object = object> {
6363

6464
const NOOP = (): void => {};
6565

66+
/**
67+
* Bind a span (and, in `auto` mode, its lifecycle) to a tracing channel so the span becomes the
68+
* active async context for the traced operation.
69+
*
70+
* `getSpan` may return `undefined` to opt a payload out entirely: nothing is bound, no span is
71+
* tracked, and the active context is left untouched. Use it for events that ride the same channel
72+
* but should reuse the enclosing span instead of opening (and ending) their own — e.g. an agent
73+
* loop's per-step events, where ending a freshly opened span would close the parent prematurely.
74+
*/
6675
export function bindTracingChannelToSpan<TData extends object>(
6776
channel: TracingChannel<TData, TData>,
68-
getSpan: (data: TracingChannelPayloadWithSpan<TData>) => Span,
77+
getSpan: (data: TracingChannelPayloadWithSpan<TData>) => Span | undefined,
6978
opts?: TracingChannelBindingOptions<TData>,
7079
): TracingChannelBindingHandle<TData> {
7180
const sentryChannel = channel as SentryTracingChannel<TData>;
@@ -80,6 +89,10 @@ export function bindTracingChannelToSpan<TData extends object>(
8089

8190
channel.start.bindStore(asyncLocalStorage, (data: TracingChannelPayloadWithSpan<TData>) => {
8291
const span = getSpan(data);
92+
if (!span) {
93+
// Leave the active context untouched so nested operations keep parenting to the enclosing span.
94+
return asyncLocalStorage.getStore() as TData;
95+
}
8396
data._sentrySpan = span;
8497

8598
return binding.getStoreWithActiveSpan(span) as TData;
@@ -95,6 +108,19 @@ export function bindTracingChannelToSpan<TData extends object>(
95108

96109
const beforeSpanEnd = opts?.beforeSpanEnd;
97110

111+
const getErrorHint = (e: unknown): ExclusiveEventHintOrCaptureContext => {
112+
if (typeof opts?.captureError === 'function') {
113+
return opts.captureError(e);
114+
}
115+
116+
return {
117+
mechanism: {
118+
type: 'auto.diagnostic_channels.bind_span',
119+
handled: false,
120+
},
121+
};
122+
};
123+
98124
const subscribers: Partial<TracingChannelSubscribers<TracingChannelPayloadWithSpan<TData>>> = {
99125
start: NOOP,
100126
asyncStart: NOOP,
@@ -106,15 +132,18 @@ export function bindTracingChannelToSpan<TData extends object>(
106132
}
107133
},
108134
error(data) {
135+
// No span was bound for this payload (`getSpan` returned undefined), so there is nothing to
136+
// annotate and no instrumentation that owns capturing this error.
137+
const span = data._sentrySpan;
138+
if (!span) {
139+
return;
140+
}
141+
109142
if (opts?.captureError !== false) {
110-
captureException(data.error, {
111-
mechanism: {
112-
type: 'auto.diagnostic_channels.bind_span',
113-
handled: false,
114-
},
115-
});
143+
captureException(data.error, getErrorHint(data.error));
116144
}
117-
data._sentrySpan?.setStatus({ code: SPAN_STATUS_ERROR, message: getErrorMessage(data.error) });
145+
146+
span.setStatus({ code: SPAN_STATUS_ERROR, message: getErrorMessage(data.error) });
118147
},
119148
asyncEnd(data) {
120149
endBoundSpan(data, beforeSpanEnd);

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

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,94 @@ describe('bindTracingChannelToSpan', () => {
407407
expect(spanToJSON(span).status).toBe('db-down');
408408
expect(spanToJSON(span).timestamp).toBeDefined();
409409
});
410+
411+
it('captures the exception with the default mechanism when `captureError` is true', async () => {
412+
installTestAsyncContextStrategy();
413+
initTestClient();
414+
const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id');
415+
416+
const span = startInactiveSpan({ name: 'channel-span' });
417+
const error = new Error('boom');
418+
const { channel } = bindTracingChannelToSpan(
419+
tracingChannel<{ operation: string }>('test:captureError:true'),
420+
() => span,
421+
{ captureError: true },
422+
);
423+
424+
await expect(
425+
channel.tracePromise(
426+
async () => {
427+
throw error;
428+
},
429+
{ operation: 'read' },
430+
),
431+
).rejects.toThrow(error);
432+
433+
expect(captureExceptionSpy).toHaveBeenCalledTimes(1);
434+
expect(captureExceptionSpy).toHaveBeenCalledWith(error, {
435+
mechanism: { type: 'auto.diagnostic_channels.bind_span', handled: false },
436+
});
437+
expect(spanToJSON(span).status).toBe('boom');
438+
});
439+
440+
it('captures the exception with the hint returned by a `captureError` function, passing it the thrown error', async () => {
441+
installTestAsyncContextStrategy();
442+
initTestClient();
443+
const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id');
444+
445+
const span = startInactiveSpan({ name: 'channel-span' });
446+
const error = new Error('boom');
447+
const captureError = vi.fn(() => ({ mechanism: { type: 'auto.http.custom', handled: false } }));
448+
const { channel } = bindTracingChannelToSpan(
449+
tracingChannel<{ operation: string }>('test:captureError:fn'),
450+
() => span,
451+
{ captureError },
452+
);
453+
454+
await expect(
455+
channel.tracePromise(
456+
async () => {
457+
throw error;
458+
},
459+
{ operation: 'read' },
460+
),
461+
).rejects.toThrow(error);
462+
463+
expect(captureError).toHaveBeenCalledTimes(1);
464+
expect(captureError).toHaveBeenCalledWith(error);
465+
expect(captureExceptionSpy).toHaveBeenCalledTimes(1);
466+
expect(captureExceptionSpy).toHaveBeenCalledWith(error, {
467+
mechanism: { type: 'auto.http.custom', handled: false },
468+
});
469+
expect(spanToJSON(span).status).toBe('boom');
470+
});
471+
472+
it('uses the default mechanism when `captureError` is a function on the synchronous error path', () => {
473+
installTestAsyncContextStrategy();
474+
initTestClient();
475+
const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id');
476+
477+
const span = startInactiveSpan({ name: 'channel-span' });
478+
const error = new Error('sync-boom');
479+
const captureError = vi.fn((e: unknown) => ({ extra: { caught: e instanceof Error } }));
480+
const { channel } = bindTracingChannelToSpan(
481+
tracingChannel<{ operation: string }>('test:captureError:fn-sync'),
482+
() => span,
483+
{ captureError },
484+
);
485+
486+
expect(() =>
487+
channel.traceSync(
488+
() => {
489+
throw error;
490+
},
491+
{ operation: 'read' },
492+
),
493+
).toThrow(error);
494+
495+
expect(captureError).toHaveBeenCalledWith(error);
496+
expect(captureExceptionSpy).toHaveBeenCalledWith(error, { extra: { caught: true } });
497+
});
410498
});
411499

412500
describe('beforeSpanEnd', () => {
@@ -566,4 +654,64 @@ describe('bindTracingChannelToSpan', () => {
566654
// Idempotent.
567655
expect(() => unbind()).not.toThrow();
568656
});
657+
658+
describe('getSpan returns undefined', () => {
659+
it('skips binding and lifecycle, leaving the enclosing span as the active parent', () => {
660+
installTestAsyncContextStrategy();
661+
initTestClient();
662+
663+
const getSpan = vi.fn(() => undefined);
664+
const { channel } = bindTracingChannelToSpan(tracingChannel<{ operation: string }>('test:skip:active'), getSpan);
665+
666+
let dataSpan: Span | undefined;
667+
channel.subscribe({
668+
end: data => {
669+
dataSpan = data._sentrySpan;
670+
},
671+
});
672+
673+
let enclosingSpanId: string | undefined;
674+
let childParentSpanId: string | undefined;
675+
startSpan({ forceTransaction: true, name: 'enclosing-span' }, enclosing => {
676+
enclosingSpanId = enclosing.spanContext().spanId;
677+
channel.traceSync(
678+
() => {
679+
startSpan({ name: 'child-span' }, child => {
680+
childParentSpanId = spanToJSON(child).parent_span_id;
681+
});
682+
},
683+
{ operation: 'read' },
684+
);
685+
});
686+
687+
expect(getSpan).toHaveBeenCalledTimes(1);
688+
// No span was stamped onto the payload, so the lifecycle handlers have nothing to end.
689+
expect(dataSpan).toBeUndefined();
690+
// The context is left untouched, so children still parent to the enclosing span.
691+
expect(childParentSpanId).toBe(enclosingSpanId);
692+
});
693+
694+
it('does not capture the exception on the error path when no span was bound', async () => {
695+
installTestAsyncContextStrategy();
696+
initTestClient();
697+
const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id');
698+
699+
const { channel } = bindTracingChannelToSpan(
700+
tracingChannel<{ operation: string }>('test:skip:error'),
701+
() => undefined,
702+
);
703+
704+
const error = new Error('boom');
705+
await expect(
706+
channel.tracePromise(
707+
async () => {
708+
throw error;
709+
},
710+
{ operation: 'read' },
711+
),
712+
).rejects.toThrow(error);
713+
714+
expect(captureExceptionSpy).not.toHaveBeenCalled();
715+
});
716+
});
569717
});

0 commit comments

Comments
 (0)