From e68b15ba6b4c566cbe210d8a8985b776b38955b0 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 18 Jun 2026 12:19:26 -0400 Subject: [PATCH 1/5] feat(server-utils): Add tracingChannel-to-span binding Add `bindTracingChannelToSpan` to generalize Node `diagnostics_channel` TracingChannel instrumentation onto Sentry spans. It binds the active span into async context on `start` and, in the default `auto` lifecycle, ends the span on the canonical terminal event: `end` for synchronous calls (detected via presence of `result`/`error` on the context object) and `asyncEnd` for async calls, recording exceptions on `error`. Backed by a new `getTracingChannelBinding` async context strategy hook wired through core, node-core, opentelemetry, cloudflare, and deno. --- packages/cloudflare/src/async.ts | 16 +- packages/core/src/asyncContext/index.ts | 9 +- packages/core/src/asyncContext/types.ts | 9 + packages/core/src/shared-exports.ts | 7 +- packages/deno/src/async.ts | 16 +- .../src/light/asyncLocalStorageStrategy.ts | 10 + .../opentelemetry/src/asyncContextStrategy.ts | 23 +- packages/server-utils/src/index.ts | 2 + packages/server-utils/src/tracing-channel.ts | 80 ++++ .../server-utils/test/tracing-channel.test.ts | 424 ++++++++++++++++++ 10 files changed, 590 insertions(+), 6 deletions(-) create mode 100644 packages/server-utils/src/tracing-channel.ts create mode 100644 packages/server-utils/test/tracing-channel.test.ts diff --git a/packages/cloudflare/src/async.ts b/packages/cloudflare/src/async.ts index 66f2d439a3ce..af57c7b54a9c 100644 --- a/packages/cloudflare/src/async.ts +++ b/packages/cloudflare/src/async.ts @@ -2,7 +2,12 @@ // Note: Because we are using node:async_hooks, we need to set `node_compat` in the wrangler.toml import { AsyncLocalStorage } from 'node:async_hooks'; import type { Scope } from '@sentry/core'; -import { getDefaultCurrentScope, getDefaultIsolationScope, setAsyncContextStrategy } from '@sentry/core'; +import { + _INTERNAL_setSpanForScope, + getDefaultCurrentScope, + getDefaultIsolationScope, + setAsyncContextStrategy, +} from '@sentry/core'; /** * Sets the async context strategy to use AsyncLocalStorage. @@ -80,5 +85,14 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void { withSetIsolationScope, getCurrentScope: () => getScopes().scope, getIsolationScope: () => getScopes().isolationScope, + getTracingChannelBinding: () => ({ + asyncLocalStorage: asyncStorage, + getStoreWithActiveSpan: span => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + _INTERNAL_setSpanForScope(scope, span); + return { scope, isolationScope }; + }, + }), }); } diff --git a/packages/core/src/asyncContext/index.ts b/packages/core/src/asyncContext/index.ts index d13874bd854b..3c3579416b93 100644 --- a/packages/core/src/asyncContext/index.ts +++ b/packages/core/src/asyncContext/index.ts @@ -1,7 +1,7 @@ import type { Carrier } from './../carrier'; import { getMainCarrier, getSentryCarrier } from './../carrier'; import { getStackAsyncContextStrategy } from './stackStrategy'; -import type { AsyncContextStrategy } from './types'; +import type { AsyncContextStrategy, TracingChannelBinding } from './types'; /** * @private Private API with no semver guarantees! @@ -29,3 +29,10 @@ export function getAsyncContextStrategy(carrier: Carrier): AsyncContextStrategy // Otherwise, use the default one (stack) return getStackAsyncContextStrategy(); } + +/** + * Get the runtime binding needed to connect tracing channels to async context. + */ +export function getTracingChannelBinding(): TracingChannelBinding | undefined { + return getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.(); +} diff --git a/packages/core/src/asyncContext/types.ts b/packages/core/src/asyncContext/types.ts index be1ea92a7736..1a352d62294c 100644 --- a/packages/core/src/asyncContext/types.ts +++ b/packages/core/src/asyncContext/types.ts @@ -1,4 +1,5 @@ import type { Scope } from '../scope'; +import type { Span } from '../types/span'; import type { getTraceData } from '../utils/traceData'; import type { continueTrace, @@ -11,6 +12,11 @@ import type { } from './../tracing/trace'; import type { getActiveSpan } from './../utils/spanUtils'; +export interface TracingChannelBinding { + asyncLocalStorage: unknown; + getStoreWithActiveSpan: (span: Span) => unknown; +} + /** * @private Private API with no semver guarantees! * @@ -80,4 +86,7 @@ export interface AsyncContextStrategy { /** Start a new trace, ensuring all spans in the callback share the same traceId. */ startNewTrace?: typeof startNewTrace; + + /** Get the runtime store required to bind tracing channels to an active span. */ + getTracingChannelBinding?: () => TracingChannelBinding | undefined; } diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index e2160c9e14d9..f529f48a2a93 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -4,7 +4,7 @@ /* eslint-disable max-lines */ export type { ClientClass as SentryCoreCurrentScopes } from './sdk'; -export type { AsyncContextStrategy } from './asyncContext/types'; +export type { AsyncContextStrategy, TracingChannelBinding } from './asyncContext/types'; export type { Carrier } from './carrier'; export type { OfflineStore, OfflineTransportOptions } from './transports/offline'; export type { IntegrationIndex } from './integration'; @@ -47,7 +47,10 @@ export { hasExternalPropagationContext, } from './currentScopes'; export { getDefaultCurrentScope, getDefaultIsolationScope } from './defaultScopes'; -export { setAsyncContextStrategy } from './asyncContext'; +export { + setAsyncContextStrategy, + getTracingChannelBinding as _INTERNAL_getTracingChannelBinding, +} from './asyncContext'; export { getGlobalSingleton, getMainCarrier } from './carrier'; export { makeSession, closeSession, updateSession } from './session'; export { Scope } from './scope'; diff --git a/packages/deno/src/async.ts b/packages/deno/src/async.ts index 16806284d314..29ebef5947ea 100644 --- a/packages/deno/src/async.ts +++ b/packages/deno/src/async.ts @@ -1,7 +1,12 @@ // Need to use node: prefix for deno compatibility import { AsyncLocalStorage } from 'node:async_hooks'; import type { Scope } from '@sentry/core'; -import { getDefaultCurrentScope, getDefaultIsolationScope, setAsyncContextStrategy } from '@sentry/core'; +import { + _INTERNAL_setSpanForScope, + getDefaultCurrentScope, + getDefaultIsolationScope, + setAsyncContextStrategy, +} from '@sentry/core'; let installed = false; @@ -88,5 +93,14 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void { withSetIsolationScope, getCurrentScope: () => getScopes().scope, getIsolationScope: () => getScopes().isolationScope, + getTracingChannelBinding: () => ({ + asyncLocalStorage: asyncStorage, + getStoreWithActiveSpan: span => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + _INTERNAL_setSpanForScope(scope, span); + return { scope, isolationScope }; + }, + }), }); } diff --git a/packages/node-core/src/light/asyncLocalStorageStrategy.ts b/packages/node-core/src/light/asyncLocalStorageStrategy.ts index 00a7939d664f..d690adf00c67 100644 --- a/packages/node-core/src/light/asyncLocalStorageStrategy.ts +++ b/packages/node-core/src/light/asyncLocalStorageStrategy.ts @@ -1,6 +1,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import type { Scope } from '@sentry/core'; import { + _INTERNAL_setSpanForScope, getDefaultCurrentScope, getDefaultIsolationScope, setAsyncContextStrategy, @@ -80,5 +81,14 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void { withSetIsolationScope, getCurrentScope: () => getScopes().scope, getIsolationScope: () => getScopes().isolationScope, + getTracingChannelBinding: () => ({ + asyncLocalStorage: asyncStorage, + getStoreWithActiveSpan: span => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + _INTERNAL_setSpanForScope(scope, span); + return { scope, isolationScope }; + }, + }), }); } diff --git a/packages/opentelemetry/src/asyncContextStrategy.ts b/packages/opentelemetry/src/asyncContextStrategy.ts index 7cb8dc0f54eb..b9ad711c299c 100644 --- a/packages/opentelemetry/src/asyncContextStrategy.ts +++ b/packages/opentelemetry/src/asyncContextStrategy.ts @@ -1,5 +1,5 @@ import * as api from '@opentelemetry/api'; -import type { Scope, withActiveSpan as defaultWithActiveSpan } from '@sentry/core'; +import type { Scope, Span, withActiveSpan as defaultWithActiveSpan } from '@sentry/core'; import { getDefaultCurrentScope, getDefaultIsolationScope, setAsyncContextStrategy } from '@sentry/core'; import { SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY, @@ -13,6 +13,14 @@ import { getActiveSpan } from './utils/getActiveSpan'; import { getTraceData } from './utils/getTraceData'; import { suppressTracing } from './utils/suppressTracing'; +interface ContextApi { + _getContextManager(): { + getAsyncLocalStorageLookup(): { + asyncLocalStorage: unknown; + }; + }; +} + /** * Sets the async context strategy to use follow the OTEL context under the hood. * We handle forking a hub inside of our custom OTEL Context Manager (./otelContextManager.ts) @@ -108,5 +116,18 @@ export function setOpenTelemetryContextAsyncContextStrategy(): void { // The types here don't fully align, because our own `Span` type is narrower // than the OTEL one - but this is OK for here, as we now we'll only have OTEL spans passed around withActiveSpan: withActiveSpan as typeof defaultWithActiveSpan, + getTracingChannelBinding: () => { + try { + const contextManager = (api.context as unknown as ContextApi)._getContextManager(); + const lookup = contextManager.getAsyncLocalStorageLookup(); + + return { + asyncLocalStorage: lookup.asyncLocalStorage, + getStoreWithActiveSpan: (span: Span) => api.trace.setSpan(api.context.active(), span as api.Span), + }; + } catch { + return undefined; + } + }, }); } diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 43918e600a28..76c7073c2893 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -23,3 +23,5 @@ export type { RedisTracingChannelFactory, RedisTracingChannelSubscribers, } from './redis/redis-dc-subscriber'; +export { bindTracingChannelToSpan } from './tracing-channel'; +export type { SentryTracingChannel, TracingChannelPayloadWithSpan } from './tracing-channel'; diff --git a/packages/server-utils/src/tracing-channel.ts b/packages/server-utils/src/tracing-channel.ts new file mode 100644 index 000000000000..199cac4771f1 --- /dev/null +++ b/packages/server-utils/src/tracing-channel.ts @@ -0,0 +1,80 @@ +import type { TracingChannel, TracingChannelSubscribers } from 'node:diagnostics_channel'; +import type { AsyncLocalStorage } from 'node:async_hooks'; +import type { Span } from '@sentry/core'; +import { _INTERNAL_getTracingChannelBinding, debug, captureException, SPAN_STATUS_ERROR } from '@sentry/core'; +import { DEBUG_BUILD } from './debug-build'; + +export type TracingChannelPayloadWithSpan = TData & { + _sentrySpan?: Span; +}; + +/* + * A type patch so that we don't have to handle all subscription types. + */ +export interface SentryTracingChannel extends Omit< + TracingChannel>, + 'subscribe' | 'unsubscribe' +> { + subscribe(subscribers: Partial>>): void; + unsubscribe(subscribers: Partial>>): void; +} + +export interface TracingChannelBindingOptions { + lifecycle: 'auto' | 'manual'; +} + +const NOOP = () => {}; + +export function bindTracingChannelToSpan( + channel: TracingChannel, + getSpan: (data: TracingChannelPayloadWithSpan) => Span, + opts?: Partial, +): SentryTracingChannel { + const binding = _INTERNAL_getTracingChannelBinding(); + + if (!binding) { + DEBUG_BUILD && debug.log('[TracingChannel] Could not access async context binding.'); + return channel as SentryTracingChannel; + } + + channel.start.bindStore( + binding.asyncLocalStorage as AsyncLocalStorage, + (data: TracingChannelPayloadWithSpan) => { + const span = getSpan(data); + data._sentrySpan = span; + + return binding.getStoreWithActiveSpan(span) as TData; + }, + ); + + const sentryChannel = channel as SentryTracingChannel; + + if (opts?.lifecycle === 'manual') { + return sentryChannel; + } + + sentryChannel.subscribe({ + start: NOOP, + asyncStart: NOOP, + end(data) { + // The operation settled synchronously (returned or threw) + // Presence checks because caller can return `undefined` result or throw a falsy value. + if ('error' in data || 'result' in data) { + data._sentrySpan?.end(); + } + }, + error(data) { + captureException(data.error, { + mechanism: { + type: 'auto.diagnostic_channels.bind_span', + }, + }); + data._sentrySpan?.setStatus({ code: SPAN_STATUS_ERROR, message: String(data.error) }); + }, + asyncEnd(data) { + data._sentrySpan?.end(); + }, + }); + + return sentryChannel; +} diff --git a/packages/server-utils/test/tracing-channel.test.ts b/packages/server-utils/test/tracing-channel.test.ts new file mode 100644 index 000000000000..2c3a9f2bcb7d --- /dev/null +++ b/packages/server-utils/test/tracing-channel.test.ts @@ -0,0 +1,424 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Scope, Span } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { + _INTERNAL_setSpanForScope, + Client, + createTransport, + getActiveSpan, + getCurrentScope, + getDefaultCurrentScope, + getDefaultIsolationScope, + getGlobalScope, + getIsolationScope, + initAndBind, + resolvedSyncPromise, + setAsyncContextStrategy, + spanToJSON, + startInactiveSpan, + startSpan, +} from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { bindTracingChannelToSpan } from '../src/tracing-channel'; + +interface TestStore { + scope: Scope; + isolationScope: Scope; +} + +class TestClient extends Client { + public eventFromException(): PromiseLike { + return resolvedSyncPromise({}); + } + + public eventFromMessage(): PromiseLike { + return resolvedSyncPromise({}); + } +} + +function initTestClient(): void { + initAndBind(TestClient, { + dsn: 'https://username@domain/123', + integrations: [], + sendClientReports: false, + stackParser: () => [], + tracesSampleRate: 1, + transport: () => + createTransport( + { + recordDroppedEvent: () => undefined, + }, + () => resolvedSyncPromise({}), + ), + }); +} + +function installTestAsyncContextStrategy(): void { + const asyncStorage = new AsyncLocalStorage(); + + function getScopes(): TestStore { + return ( + asyncStorage.getStore() || { + scope: getDefaultCurrentScope(), + isolationScope: getDefaultIsolationScope(), + } + ); + } + + setAsyncContextStrategy({ + withScope: callback => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withSetScope: (scope, callback) => { + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withIsolationScope: callback => { + const scope = getScopes().scope; + const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + withSetIsolationScope: (isolationScope, callback) => { + const scope = getScopes().scope; + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + getCurrentScope: () => getScopes().scope, + getIsolationScope: () => getScopes().isolationScope, + getTracingChannelBinding: () => ({ + asyncLocalStorage: asyncStorage, + getStoreWithActiveSpan: span => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + _INTERNAL_setSpanForScope(scope, span); + return { scope, isolationScope }; + }, + }), + }); +} + +describe('bindTracingChannelToSpan', () => { + afterEach(() => { + setAsyncContextStrategy(undefined); + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getIsolationScope().clear(); + getGlobalScope().clear(); + vi.clearAllMocks(); + }); + + it('calls the span callback on start and stores the span on data', () => { + installTestAsyncContextStrategy(); + + const span = startInactiveSpan({ name: 'channel-span' }); + const getSpan = vi.fn(() => span); + const channel = bindTracingChannelToSpan(tracingChannel<{ operation: string }>('test:bind-span:data'), getSpan); + + let dataSpan: Span | undefined; + channel.subscribe({ + end: data => { + dataSpan = data._sentrySpan; + }, + }); + + channel.traceSync(() => undefined, { operation: 'read' }); + + expect(getSpan).toHaveBeenCalledTimes(1); + expect(getSpan).toHaveBeenCalledWith(expect.objectContaining({ operation: 'read', _sentrySpan: span })); + expect(dataSpan).toBe(span); + }); + + it('sets the returned span as active inside the traced operation', () => { + installTestAsyncContextStrategy(); + + const span = startInactiveSpan({ name: 'channel-span' }); + const channel = bindTracingChannelToSpan( + tracingChannel<{ operation: string }>('test:bind-span:active'), + () => span, + ); + + let activeSpan: Span | undefined; + + channel.traceSync( + () => { + activeSpan = getActiveSpan(); + }, + { operation: 'read' }, + ); + + expect(activeSpan).toBe(span); + }); + + it('parents child spans created inside the traced operation to the bound span', () => { + installTestAsyncContextStrategy(); + initTestClient(); + + const parent = startInactiveSpan({ forceTransaction: true, name: 'parent-span' }); + const channel = bindTracingChannelToSpan( + tracingChannel<{ operation: string }>('test:bind-span:children'), + () => parent, + ); + + let childParentSpanId: string | undefined; + + channel.traceSync( + () => { + startSpan({ name: 'child-span' }, child => { + childParentSpanId = spanToJSON(child).parent_span_id; + }); + }, + { operation: 'read' }, + ); + + expect(childParentSpanId).toBe(parent.spanContext().spanId); + }); + + describe('auto lifecycle ending strategy', () => { + const MECHANISM = { mechanism: { type: 'auto.diagnostic_channels.bind_span' } }; + + // Returns a channel whose span we can observe, plus spies for `span.end` and `captureException`. + function setup(name: string): { + channel: ReturnType; + span: Span; + endSpy: ReturnType; + captureExceptionSpy: ReturnType; + } { + installTestAsyncContextStrategy(); + initTestClient(); + const span = startInactiveSpan({ name: 'channel-span' }); + const endSpy = vi.spyOn(span, 'end'); + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id'); + const channel = bindTracingChannelToSpan(tracingChannel<{ operation: string }>(name), () => span); + return { channel, span, endSpy, captureExceptionSpy }; + } + + it('traceSync success: ends the span once on `end`', () => { + const { channel, span, endSpy, captureExceptionSpy } = setup('test:lifecycle:sync-ok'); + + channel.traceSync(() => undefined, { operation: 'read' }); + + expect(endSpy).toHaveBeenCalledTimes(1); + expect(spanToJSON(span).timestamp).toBeDefined(); + expect(spanToJSON(span).status).toBeUndefined(); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('traceSync throw: ends the span once on `end`, sets error status, captures the exception', () => { + const { channel, span, endSpy, captureExceptionSpy } = setup('test:lifecycle:sync-throw'); + const error = new Error('sync-throw'); + + expect(() => + channel.traceSync( + () => { + throw error; + }, + { operation: 'read' }, + ), + ).toThrow(error); + + expect(endSpy).toHaveBeenCalledTimes(1); + expect(spanToJSON(span).status).toBe('Error: sync-throw'); + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, MECHANISM); + }); + + it('traceSync throw of a falsy value: still ends the span once on `end`', () => { + const { channel, endSpy, captureExceptionSpy } = setup('test:lifecycle:sync-throw-falsy'); + + let threw = false; + try { + channel.traceSync( + () => { + throw 0; + }, + { operation: 'read' }, + ); + } catch { + threw = true; + } + + // No async events follow a synchronous throw, so the span must be ended on `end` — even + // though the thrown value is falsy, the `error` key is present on the context object. + expect(threw).toBe(true); + expect(endSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenCalledWith(0, MECHANISM); + }); + + it('tracePromise resolve: ends the span once on `asyncEnd`, not on the early synchronous `end`', async () => { + const { channel, span, endSpy, captureExceptionSpy } = setup('test:lifecycle:promise-ok'); + + let resolveOperation: (value: string) => void; + const promise = channel.tracePromise( + () => + new Promise(resolve => { + resolveOperation = resolve; + }), + { operation: 'read' }, + ); + + // The synchronous `end` event has already fired here, but the span must stay open until the promise settles. + expect(endSpy).not.toHaveBeenCalled(); + + resolveOperation!('ok'); + await promise; + + expect(endSpy).toHaveBeenCalledTimes(1); + expect(spanToJSON(span).timestamp).toBeDefined(); + expect(spanToJSON(span).status).toBeUndefined(); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('tracePromise reject: ends the span once on `asyncEnd`, sets error status, captures the exception', async () => { + const { channel, span, endSpy, captureExceptionSpy } = setup('test:lifecycle:promise-reject'); + const error = new Error('async-reject'); + + let rejectOperation: (reason: Error) => void; + const promise = channel.tracePromise( + () => + new Promise((_resolve, reject) => { + rejectOperation = reject; + }), + { operation: 'read' }, + ); + + expect(endSpy).not.toHaveBeenCalled(); + + rejectOperation!(error); + await expect(promise).rejects.toThrow(error); + + expect(endSpy).toHaveBeenCalledTimes(1); + expect(spanToJSON(span).status).toBe('Error: async-reject'); + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, MECHANISM); + }); + + it('tracePromise with a synchronous throw: ends the span once on `end` (no async events follow)', () => { + const { channel, span, endSpy, captureExceptionSpy } = setup('test:lifecycle:promise-sync-throw'); + const error = new Error('promise-sync-throw'); + + expect(() => + channel.tracePromise( + () => { + throw error; + }, + { operation: 'read' }, + ), + ).toThrow(error); + + expect(endSpy).toHaveBeenCalledTimes(1); + expect(spanToJSON(span).status).toBe('Error: promise-sync-throw'); + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, MECHANISM); + }); + + it('traceCallback success: ends the span once on `asyncEnd`', async () => { + const { channel, span, endSpy, captureExceptionSpy } = setup('test:lifecycle:callback-ok'); + + await new Promise(done => { + channel.traceCallback( + (cb: (err: Error | null, result?: string) => void) => { + setTimeout(() => cb(null, 'ok'), 1); + }, + 0, + { operation: 'read' }, + undefined, + () => done(), + ); + }); + + expect(endSpy).toHaveBeenCalledTimes(1); + expect(spanToJSON(span).timestamp).toBeDefined(); + expect(spanToJSON(span).status).toBeUndefined(); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('traceCallback error: ends the span once on `asyncEnd`, sets error status, captures the exception', async () => { + const { channel, span, endSpy, captureExceptionSpy } = setup('test:lifecycle:callback-error'); + const error = new Error('callback-error'); + + await new Promise(done => { + channel.traceCallback( + (cb: (err: Error | null, result?: string) => void) => { + setTimeout(() => cb(error), 1); + }, + 0, + { operation: 'read' }, + undefined, + () => done(), + ); + }); + + expect(endSpy).toHaveBeenCalledTimes(1); + expect(spanToJSON(span).status).toBe('Error: callback-error'); + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, MECHANISM); + }); + + it('traceCallback with a synchronous throw: ends the span once on `end` (no async events follow)', () => { + const { channel, span, endSpy, captureExceptionSpy } = setup('test:lifecycle:callback-sync-throw'); + const error = new Error('callback-sync-throw'); + + expect(() => + channel.traceCallback( + () => { + throw error; + }, + 0, + { operation: 'read' }, + undefined, + () => undefined, + ), + ).toThrow(error); + + expect(endSpy).toHaveBeenCalledTimes(1); + expect(spanToJSON(span).status).toBe('Error: callback-sync-throw'); + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, MECHANISM); + }); + }); + + it('manual lifecycle: binds the span as active but does not end it automatically', () => { + installTestAsyncContextStrategy(); + initTestClient(); + + const span = startInactiveSpan({ name: 'channel-span' }); + const endSpy = vi.spyOn(span, 'end'); + const getSpan = vi.fn(() => span); + const channel = bindTracingChannelToSpan(tracingChannel<{ operation: string }>('test:lifecycle:manual'), getSpan, { + lifecycle: 'manual', + }); + + let activeSpan: Span | undefined; + channel.traceSync( + () => { + activeSpan = getActiveSpan(); + }, + { operation: 'read' }, + ); + + expect(getSpan).toHaveBeenCalledTimes(1); + expect(activeSpan).toBe(span); + expect(endSpy).not.toHaveBeenCalled(); + expect(spanToJSON(span).timestamp).toBeUndefined(); + }); + + it('returns the channel unchanged when no async context binding is available', () => { + // No async context strategy is installed, so the binding cannot be resolved. + const span = startInactiveSpan({ name: 'channel-span' }); + const endSpy = vi.spyOn(span, 'end'); + const getSpan = vi.fn(() => span); + const rawChannel = tracingChannel<{ operation: string }>('test:lifecycle:no-binding'); + + const channel = bindTracingChannelToSpan(rawChannel, getSpan); + + expect(channel).toBe(rawChannel); + + channel.traceSync(() => undefined, { operation: 'read' }); + + expect(getSpan).not.toHaveBeenCalled(); + expect(endSpy).not.toHaveBeenCalled(); + }); +}); From 685dbc41869c7fcb798512977cad25b58c304baa Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 18 Jun 2026 13:37:58 -0400 Subject: [PATCH 2/5] feat: added knobs to enrich span and handler error reporting --- packages/server-utils/src/tracing-channel.ts | 65 +++++++-- .../server-utils/test/tracing-channel.test.ts | 125 +++++++++++++++++- 2 files changed, 172 insertions(+), 18 deletions(-) diff --git a/packages/server-utils/src/tracing-channel.ts b/packages/server-utils/src/tracing-channel.ts index 199cac4771f1..d74e61df522a 100644 --- a/packages/server-utils/src/tracing-channel.ts +++ b/packages/server-utils/src/tracing-channel.ts @@ -19,16 +19,32 @@ export interface SentryTracingChannel extends Omi unsubscribe(subscribers: Partial>>): void; } -export interface TracingChannelBindingOptions { - lifecycle: 'auto' | 'manual'; +export interface TracingChannelBindingOptions { + /** + * Whether the span is ended automatically (`auto`, default) or left to the caller (`manual`). + */ + lifecycle?: 'auto' | 'manual'; + + /** + * Invoked with the span and the channel context object once the traced operation completes + * Use it to enrich the span from the result/error (branch on `'error' in data` / `'result' in data`) or to run cleanup. + */ + beforeSpanEnd?: (span: Span, data: TracingChannelPayloadWithSpan) => void; + + /** + * Whether a thrown error is captured as a Sentry event. The span is always marked with error + * status regardless. Defaults to `true`. + * 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). + */ + captureError?: boolean; } -const NOOP = () => {}; +const NOOP = (): void => {}; export function bindTracingChannelToSpan( channel: TracingChannel, getSpan: (data: TracingChannelPayloadWithSpan) => Span, - opts?: Partial, + opts?: TracingChannelBindingOptions, ): SentryTracingChannel { const binding = _INTERNAL_getTracingChannelBinding(); @@ -53,6 +69,8 @@ export function bindTracingChannelToSpan( return sentryChannel; } + const beforeSpanEnd = opts?.beforeSpanEnd; + sentryChannel.subscribe({ start: NOOP, asyncStart: NOOP, @@ -60,21 +78,44 @@ export function bindTracingChannelToSpan( // The operation settled synchronously (returned or threw) // Presence checks because caller can return `undefined` result or throw a falsy value. if ('error' in data || 'result' in data) { - data._sentrySpan?.end(); + endBoundSpan(data, beforeSpanEnd); } }, error(data) { - captureException(data.error, { - mechanism: { - type: 'auto.diagnostic_channels.bind_span', - }, - }); - data._sentrySpan?.setStatus({ code: SPAN_STATUS_ERROR, message: String(data.error) }); + if (opts?.captureError !== false) { + captureException(data.error, { + mechanism: { + type: 'auto.diagnostic_channels.bind_span', + handled: false, + }, + }); + } + data._sentrySpan?.setStatus({ code: SPAN_STATUS_ERROR, message: getErrorMessage(data.error) }); }, asyncEnd(data) { - data._sentrySpan?.end(); + endBoundSpan(data, beforeSpanEnd); }, }); return sentryChannel; } + +function endBoundSpan( + data: TracingChannelPayloadWithSpan, + beforeSpanEnd: TracingChannelBindingOptions['beforeSpanEnd'], +): void { + const span = data._sentrySpan; + if (!span) { + return; + } + beforeSpanEnd?.(span, data); + span.end(); +} + +/** Best-effort short message for a span status: an error-like's `message`, otherwise its string form. */ +function getErrorMessage(error: unknown): string { + if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') { + return error.message; + } + return String(error); +} diff --git a/packages/server-utils/test/tracing-channel.test.ts b/packages/server-utils/test/tracing-channel.test.ts index 2c3a9f2bcb7d..190911b5dae3 100644 --- a/packages/server-utils/test/tracing-channel.test.ts +++ b/packages/server-utils/test/tracing-channel.test.ts @@ -176,7 +176,7 @@ describe('bindTracingChannelToSpan', () => { }); describe('auto lifecycle ending strategy', () => { - const MECHANISM = { mechanism: { type: 'auto.diagnostic_channels.bind_span' } }; + const MECHANISM = { mechanism: { type: 'auto.diagnostic_channels.bind_span', handled: false } }; // Returns a channel whose span we can observe, plus spies for `span.end` and `captureException`. function setup(name: string): { @@ -219,7 +219,7 @@ describe('bindTracingChannelToSpan', () => { ).toThrow(error); expect(endSpy).toHaveBeenCalledTimes(1); - expect(spanToJSON(span).status).toBe('Error: sync-throw'); + expect(spanToJSON(span).status).toBe('sync-throw'); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); expect(captureExceptionSpy).toHaveBeenCalledWith(error, MECHANISM); }); @@ -290,7 +290,7 @@ describe('bindTracingChannelToSpan', () => { await expect(promise).rejects.toThrow(error); expect(endSpy).toHaveBeenCalledTimes(1); - expect(spanToJSON(span).status).toBe('Error: async-reject'); + expect(spanToJSON(span).status).toBe('async-reject'); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); expect(captureExceptionSpy).toHaveBeenCalledWith(error, MECHANISM); }); @@ -309,7 +309,7 @@ describe('bindTracingChannelToSpan', () => { ).toThrow(error); expect(endSpy).toHaveBeenCalledTimes(1); - expect(spanToJSON(span).status).toBe('Error: promise-sync-throw'); + expect(spanToJSON(span).status).toBe('promise-sync-throw'); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); expect(captureExceptionSpy).toHaveBeenCalledWith(error, MECHANISM); }); @@ -352,7 +352,7 @@ describe('bindTracingChannelToSpan', () => { }); expect(endSpy).toHaveBeenCalledTimes(1); - expect(spanToJSON(span).status).toBe('Error: callback-error'); + expect(spanToJSON(span).status).toBe('callback-error'); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); expect(captureExceptionSpy).toHaveBeenCalledWith(error, MECHANISM); }); @@ -374,12 +374,125 @@ describe('bindTracingChannelToSpan', () => { ).toThrow(error); expect(endSpy).toHaveBeenCalledTimes(1); - expect(spanToJSON(span).status).toBe('Error: callback-sync-throw'); + expect(spanToJSON(span).status).toBe('callback-sync-throw'); expect(captureExceptionSpy).toHaveBeenCalledTimes(1); expect(captureExceptionSpy).toHaveBeenCalledWith(error, MECHANISM); }); }); + describe('captureError', () => { + it('does not capture the exception when `captureError` is false, but still sets error status', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id'); + + const span = startInactiveSpan({ name: 'channel-span' }); + const error = new Error('db-down'); + const channel = bindTracingChannelToSpan( + tracingChannel<{ operation: string }>('test:captureError:off'), + () => span, + { captureError: false }, + ); + + await expect( + channel.tracePromise( + async () => { + throw error; + }, + { operation: 'read' }, + ), + ).rejects.toThrow(error); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + expect(spanToJSON(span).status).toBe('db-down'); + expect(spanToJSON(span).timestamp).toBeDefined(); + }); + }); + + describe('beforeSpanEnd', () => { + it('runs with the span still open so enrichment lands, then the span is ended (sync)', () => { + installTestAsyncContextStrategy(); + initTestClient(); + + const span = startInactiveSpan({ name: 'channel-span' }); + let openWhenCalled: boolean | undefined; + let receivedSpan: Span | undefined; + const channel = bindTracingChannelToSpan( + tracingChannel<{ operation: string }>('test:beforeSpanEnd:sync'), + () => span, + { + beforeSpanEnd(s, data) { + receivedSpan = s; + openWhenCalled = spanToJSON(s).timestamp === undefined; + expect(data._sentrySpan).toBe(s); + expect('result' in data).toBe(true); + s.setAttribute('enriched', true); + }, + }, + ); + + channel.traceSync(() => undefined, { operation: 'read' }); + + expect(receivedSpan).toBe(span); + expect(openWhenCalled).toBe(true); + expect(spanToJSON(span).timestamp).toBeDefined(); + expect(spanToJSON(span).data.enriched).toBe(true); + }); + + it('runs before the span is ended on async completion', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + + const span = startInactiveSpan({ name: 'channel-span' }); + const channel = bindTracingChannelToSpan( + tracingChannel<{ operation: string }>('test:beforeSpanEnd:async'), + () => span, + { + beforeSpanEnd(s) { + expect(spanToJSON(s).timestamp).toBeUndefined(); + s.setAttribute('enriched', true); + }, + }, + ); + + await channel.tracePromise(async () => 'ok', { operation: 'read' }); + + expect(spanToJSON(span).timestamp).toBeDefined(); + expect(spanToJSON(span).data.enriched).toBe(true); + }); + + it('runs on the error path with the error on the context object', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id'); + + const span = startInactiveSpan({ name: 'channel-span' }); + const error = new Error('boom'); + let sawError: unknown; + const channel = bindTracingChannelToSpan( + tracingChannel<{ operation: string }>('test:beforeSpanEnd:error'), + () => span, + { + beforeSpanEnd(_s, data) { + sawError = (data as { error?: unknown }).error; + }, + }, + ); + + await expect( + channel.tracePromise( + async () => { + throw error; + }, + { operation: 'read' }, + ), + ).rejects.toThrow(error); + + expect(sawError).toBe(error); + expect(spanToJSON(span).timestamp).toBeDefined(); + }); + }); + it('manual lifecycle: binds the span as active but does not end it automatically', () => { installTestAsyncContextStrategy(); initTestClient(); From 859d5807af43df6251499e084372f2f45ece02e8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 18 Jun 2026 14:57:58 -0400 Subject: [PATCH 3/5] feat(server-utils): Return a teardown handle from bindTracingChannelToSpan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bindTracingChannelToSpan` now returns a `TracingChannelBindingHandle` `{ channel, unbind }` instead of the channel directly. `unbind()` unsubscribes the auto lifecycle handlers and unbinds the start store (idempotent; a no-op when no async context binding is available), so callers can detach a binding — useful for teardown and test isolation. --- packages/server-utils/src/index.ts | 6 +- packages/server-utils/src/tracing-channel.ts | 51 +++++++++++----- .../server-utils/test/tracing-channel.test.ts | 58 ++++++++++++++----- 3 files changed, 86 insertions(+), 29 deletions(-) diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 76c7073c2893..7702ebeebbbb 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -24,4 +24,8 @@ export type { RedisTracingChannelSubscribers, } from './redis/redis-dc-subscriber'; export { bindTracingChannelToSpan } from './tracing-channel'; -export type { SentryTracingChannel, TracingChannelPayloadWithSpan } from './tracing-channel'; +export type { + SentryTracingChannel, + TracingChannelBindingHandle, + TracingChannelPayloadWithSpan, +} from './tracing-channel'; diff --git a/packages/server-utils/src/tracing-channel.ts b/packages/server-utils/src/tracing-channel.ts index d74e61df522a..b1a28ea7caaf 100644 --- a/packages/server-utils/src/tracing-channel.ts +++ b/packages/server-utils/src/tracing-channel.ts @@ -39,39 +39,52 @@ export interface TracingChannelBindingOptions { captureError?: boolean; } +/** Returned by {@link bindTracingChannelToSpan}: the bound channel plus a teardown handle. */ +export interface TracingChannelBindingHandle { + /** The tracing channel with the span bound into async context (and, in `auto` mode, its lifecycle subscribed). */ + channel: SentryTracingChannel; + /** + * Tears down the binding: unsubscribes the auto lifecycle handlers and unbinds the start store. + * Idempotent, and a no-op when no async context binding was available. + */ + unbind: () => void; +} + const NOOP = (): void => {}; export function bindTracingChannelToSpan( channel: TracingChannel, getSpan: (data: TracingChannelPayloadWithSpan) => Span, opts?: TracingChannelBindingOptions, -): SentryTracingChannel { +): TracingChannelBindingHandle { + const sentryChannel = channel as SentryTracingChannel; const binding = _INTERNAL_getTracingChannelBinding(); if (!binding) { DEBUG_BUILD && debug.log('[TracingChannel] Could not access async context binding.'); - return channel as SentryTracingChannel; + return { channel: sentryChannel, unbind: NOOP }; } - channel.start.bindStore( - binding.asyncLocalStorage as AsyncLocalStorage, - (data: TracingChannelPayloadWithSpan) => { - const span = getSpan(data); - data._sentrySpan = span; + const asyncLocalStorage = binding.asyncLocalStorage as AsyncLocalStorage; - return binding.getStoreWithActiveSpan(span) as TData; - }, - ); + channel.start.bindStore(asyncLocalStorage, (data: TracingChannelPayloadWithSpan) => { + const span = getSpan(data); + data._sentrySpan = span; - const sentryChannel = channel as SentryTracingChannel; + return binding.getStoreWithActiveSpan(span) as TData; + }); + + const unbindStore = (): void => { + channel.start.unbindStore(asyncLocalStorage); + }; if (opts?.lifecycle === 'manual') { - return sentryChannel; + return { channel: sentryChannel, unbind: unbindStore }; } const beforeSpanEnd = opts?.beforeSpanEnd; - sentryChannel.subscribe({ + const subscribers: Partial>> = { start: NOOP, asyncStart: NOOP, end(data) { @@ -95,9 +108,17 @@ export function bindTracingChannelToSpan( asyncEnd(data) { endBoundSpan(data, beforeSpanEnd); }, - }); + }; + + sentryChannel.subscribe(subscribers); - return sentryChannel; + return { + channel: sentryChannel, + unbind: () => { + sentryChannel.unsubscribe(subscribers); + unbindStore(); + }, + }; } function endBoundSpan( diff --git a/packages/server-utils/test/tracing-channel.test.ts b/packages/server-utils/test/tracing-channel.test.ts index 190911b5dae3..f13ea620b81f 100644 --- a/packages/server-utils/test/tracing-channel.test.ts +++ b/packages/server-utils/test/tracing-channel.test.ts @@ -114,7 +114,7 @@ describe('bindTracingChannelToSpan', () => { const span = startInactiveSpan({ name: 'channel-span' }); const getSpan = vi.fn(() => span); - const channel = bindTracingChannelToSpan(tracingChannel<{ operation: string }>('test:bind-span:data'), getSpan); + const { channel } = bindTracingChannelToSpan(tracingChannel<{ operation: string }>('test:bind-span:data'), getSpan); let dataSpan: Span | undefined; channel.subscribe({ @@ -134,7 +134,7 @@ describe('bindTracingChannelToSpan', () => { installTestAsyncContextStrategy(); const span = startInactiveSpan({ name: 'channel-span' }); - const channel = bindTracingChannelToSpan( + const { channel } = bindTracingChannelToSpan( tracingChannel<{ operation: string }>('test:bind-span:active'), () => span, ); @@ -156,7 +156,7 @@ describe('bindTracingChannelToSpan', () => { initTestClient(); const parent = startInactiveSpan({ forceTransaction: true, name: 'parent-span' }); - const channel = bindTracingChannelToSpan( + const { channel } = bindTracingChannelToSpan( tracingChannel<{ operation: string }>('test:bind-span:children'), () => parent, ); @@ -180,7 +180,7 @@ describe('bindTracingChannelToSpan', () => { // Returns a channel whose span we can observe, plus spies for `span.end` and `captureException`. function setup(name: string): { - channel: ReturnType; + channel: ReturnType['channel']; span: Span; endSpy: ReturnType; captureExceptionSpy: ReturnType; @@ -190,7 +190,7 @@ describe('bindTracingChannelToSpan', () => { const span = startInactiveSpan({ name: 'channel-span' }); const endSpy = vi.spyOn(span, 'end'); const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id'); - const channel = bindTracingChannelToSpan(tracingChannel<{ operation: string }>(name), () => span); + const { channel } = bindTracingChannelToSpan(tracingChannel<{ operation: string }>(name), () => span); return { channel, span, endSpy, captureExceptionSpy }; } @@ -388,7 +388,7 @@ describe('bindTracingChannelToSpan', () => { const span = startInactiveSpan({ name: 'channel-span' }); const error = new Error('db-down'); - const channel = bindTracingChannelToSpan( + const { channel } = bindTracingChannelToSpan( tracingChannel<{ operation: string }>('test:captureError:off'), () => span, { captureError: false }, @@ -417,7 +417,7 @@ describe('bindTracingChannelToSpan', () => { const span = startInactiveSpan({ name: 'channel-span' }); let openWhenCalled: boolean | undefined; let receivedSpan: Span | undefined; - const channel = bindTracingChannelToSpan( + const { channel } = bindTracingChannelToSpan( tracingChannel<{ operation: string }>('test:beforeSpanEnd:sync'), () => span, { @@ -444,7 +444,7 @@ describe('bindTracingChannelToSpan', () => { initTestClient(); const span = startInactiveSpan({ name: 'channel-span' }); - const channel = bindTracingChannelToSpan( + const { channel } = bindTracingChannelToSpan( tracingChannel<{ operation: string }>('test:beforeSpanEnd:async'), () => span, { @@ -469,7 +469,7 @@ describe('bindTracingChannelToSpan', () => { const span = startInactiveSpan({ name: 'channel-span' }); const error = new Error('boom'); let sawError: unknown; - const channel = bindTracingChannelToSpan( + const { channel } = bindTracingChannelToSpan( tracingChannel<{ operation: string }>('test:beforeSpanEnd:error'), () => span, { @@ -500,9 +500,13 @@ describe('bindTracingChannelToSpan', () => { const span = startInactiveSpan({ name: 'channel-span' }); const endSpy = vi.spyOn(span, 'end'); const getSpan = vi.fn(() => span); - const channel = bindTracingChannelToSpan(tracingChannel<{ operation: string }>('test:lifecycle:manual'), getSpan, { - lifecycle: 'manual', - }); + const { channel } = bindTracingChannelToSpan( + tracingChannel<{ operation: string }>('test:lifecycle:manual'), + getSpan, + { + lifecycle: 'manual', + }, + ); let activeSpan: Span | undefined; channel.traceSync( @@ -525,7 +529,7 @@ describe('bindTracingChannelToSpan', () => { const getSpan = vi.fn(() => span); const rawChannel = tracingChannel<{ operation: string }>('test:lifecycle:no-binding'); - const channel = bindTracingChannelToSpan(rawChannel, getSpan); + const { channel } = bindTracingChannelToSpan(rawChannel, getSpan); expect(channel).toBe(rawChannel); @@ -534,4 +538,32 @@ describe('bindTracingChannelToSpan', () => { expect(getSpan).not.toHaveBeenCalled(); expect(endSpy).not.toHaveBeenCalled(); }); + + it('unbind detaches the binding: getSpan no longer runs and the span is no longer ended', () => { + installTestAsyncContextStrategy(); + initTestClient(); + + const span = startInactiveSpan({ name: 'channel-span' }); + const endSpy = vi.spyOn(span, 'end'); + const getSpan = vi.fn(() => span); + const { channel, unbind } = bindTracingChannelToSpan( + tracingChannel<{ operation: string }>('test:lifecycle:unbind'), + getSpan, + ); + + // Sanity: while bound, the span is created and ended. + channel.traceSync(() => undefined, { operation: 'read' }); + expect(getSpan).toHaveBeenCalledTimes(1); + expect(endSpy).toHaveBeenCalledTimes(1); + + unbind(); + + // After unbind, neither the start store nor the lifecycle handlers fire. + channel.traceSync(() => undefined, { operation: 'read' }); + expect(getSpan).toHaveBeenCalledTimes(1); + expect(endSpy).toHaveBeenCalledTimes(1); + + // Idempotent. + expect(() => unbind()).not.toThrow(); + }); }); From e34e28acf4f058aea13a25ee0e08f1505dad0990 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 18 Jun 2026 21:16:55 -0400 Subject: [PATCH 4/5] ref: make exclusive with beforeSpanEnd or captureException --- packages/server-utils/src/tracing-channel.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/server-utils/src/tracing-channel.ts b/packages/server-utils/src/tracing-channel.ts index b1a28ea7caaf..c4dcd8fe4aff 100644 --- a/packages/server-utils/src/tracing-channel.ts +++ b/packages/server-utils/src/tracing-channel.ts @@ -19,11 +19,18 @@ export interface SentryTracingChannel extends Omi unsubscribe(subscribers: Partial>>): void; } -export interface TracingChannelBindingOptions { +interface TracingChannelManualBindingOptions { /** * Whether the span is ended automatically (`auto`, default) or left to the caller (`manual`). */ - lifecycle?: 'auto' | 'manual'; + lifecycle: 'manual'; +} + +interface TracingChannelAutoBindingOptions { + /** + * Whether the span is ended automatically (`auto`, default) or left to the caller (`manual`). + */ + lifecycle?: 'auto' | undefined; /** * Invoked with the span and the channel context object once the traced operation completes @@ -39,6 +46,10 @@ export interface TracingChannelBindingOptions { captureError?: boolean; } +export type TracingChannelBindingOptions = + | TracingChannelAutoBindingOptions + | TracingChannelManualBindingOptions; + /** Returned by {@link bindTracingChannelToSpan}: the bound channel plus a teardown handle. */ export interface TracingChannelBindingHandle { /** The tracing channel with the span bound into async context (and, in `auto` mode, its lifecycle subscribed). */ @@ -78,7 +89,7 @@ export function bindTracingChannelToSpan( channel.start.unbindStore(asyncLocalStorage); }; - if (opts?.lifecycle === 'manual') { + if (opts && 'lifecycle' in opts && opts.lifecycle === 'manual') { return { channel: sentryChannel, unbind: unbindStore }; } @@ -123,7 +134,7 @@ export function bindTracingChannelToSpan( function endBoundSpan( data: TracingChannelPayloadWithSpan, - beforeSpanEnd: TracingChannelBindingOptions['beforeSpanEnd'], + beforeSpanEnd: TracingChannelAutoBindingOptions['beforeSpanEnd'], ): void { const span = data._sentrySpan; if (!span) { From 08b9cbbb7771f609aced8aa0a7a51c3b1fb937b8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 19 Jun 2026 11:00:50 -0400 Subject: [PATCH 5/5] 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) --- packages/server-utils/src/tracing-channel.ts | 53 +++++-- .../server-utils/test/tracing-channel.test.ts | 148 ++++++++++++++++++ 2 files changed, 189 insertions(+), 12 deletions(-) diff --git a/packages/server-utils/src/tracing-channel.ts b/packages/server-utils/src/tracing-channel.ts index c4dcd8fe4aff..819b640e9b85 100644 --- a/packages/server-utils/src/tracing-channel.ts +++ b/packages/server-utils/src/tracing-channel.ts @@ -1,6 +1,6 @@ import type { TracingChannel, TracingChannelSubscribers } from 'node:diagnostics_channel'; import type { AsyncLocalStorage } from 'node:async_hooks'; -import type { Span } from '@sentry/core'; +import type { ExclusiveEventHintOrCaptureContext, Span } from '@sentry/core'; import { _INTERNAL_getTracingChannelBinding, debug, captureException, SPAN_STATUS_ERROR } from '@sentry/core'; import { DEBUG_BUILD } from './debug-build'; @@ -39,11 +39,11 @@ interface TracingChannelAutoBindingOptions { beforeSpanEnd?: (span: Span, data: TracingChannelPayloadWithSpan) => void; /** - * Whether a thrown error is captured as a Sentry event. The span is always marked with error - * status regardless. Defaults to `true`. + * Whether a thrown error is captured as a Sentry event. The span is always marked with error status regardless. Defaults to `true`. + * You can alternatively pass a function that sets the ExclusiveEventHintOrCaptureContext on the captured error. * 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). */ - captureError?: boolean; + captureError?: boolean | ((e: unknown) => ExclusiveEventHintOrCaptureContext); } export type TracingChannelBindingOptions = @@ -63,9 +63,18 @@ export interface TracingChannelBindingHandle { const NOOP = (): void => {}; +/** + * Bind a span (and, in `auto` mode, its lifecycle) to a tracing channel so the span becomes the + * active async context for the traced operation. + * + * `getSpan` may return `undefined` to opt a payload out entirely: nothing is bound, no span is + * tracked, and the active context is left untouched. Use it for events that ride the same channel + * but should reuse the enclosing span instead of opening (and ending) their own — e.g. an agent + * loop's per-step events, where ending a freshly opened span would close the parent prematurely. + */ export function bindTracingChannelToSpan( channel: TracingChannel, - getSpan: (data: TracingChannelPayloadWithSpan) => Span, + getSpan: (data: TracingChannelPayloadWithSpan) => Span | undefined, opts?: TracingChannelBindingOptions, ): TracingChannelBindingHandle { const sentryChannel = channel as SentryTracingChannel; @@ -80,6 +89,10 @@ export function bindTracingChannelToSpan( channel.start.bindStore(asyncLocalStorage, (data: TracingChannelPayloadWithSpan) => { 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; + } data._sentrySpan = span; return binding.getStoreWithActiveSpan(span) as TData; @@ -95,6 +108,19 @@ export function bindTracingChannelToSpan( const beforeSpanEnd = opts?.beforeSpanEnd; + const getErrorHint = (e: unknown): ExclusiveEventHintOrCaptureContext => { + if (typeof opts?.captureError === 'function') { + return opts.captureError(e); + } + + return { + mechanism: { + type: 'auto.diagnostic_channels.bind_span', + handled: false, + }, + }; + }; + const subscribers: Partial>> = { start: NOOP, asyncStart: NOOP, @@ -106,15 +132,18 @@ export function bindTracingChannelToSpan( } }, error(data) { + // No span was bound for this payload (`getSpan` returned undefined), so there is nothing to + // annotate and no instrumentation that owns capturing this error. + const span = data._sentrySpan; + if (!span) { + return; + } + if (opts?.captureError !== false) { - captureException(data.error, { - mechanism: { - type: 'auto.diagnostic_channels.bind_span', - handled: false, - }, - }); + captureException(data.error, getErrorHint(data.error)); } - data._sentrySpan?.setStatus({ code: SPAN_STATUS_ERROR, message: getErrorMessage(data.error) }); + + span.setStatus({ code: SPAN_STATUS_ERROR, message: getErrorMessage(data.error) }); }, asyncEnd(data) { endBoundSpan(data, beforeSpanEnd); diff --git a/packages/server-utils/test/tracing-channel.test.ts b/packages/server-utils/test/tracing-channel.test.ts index f13ea620b81f..e695d1237288 100644 --- a/packages/server-utils/test/tracing-channel.test.ts +++ b/packages/server-utils/test/tracing-channel.test.ts @@ -407,6 +407,94 @@ describe('bindTracingChannelToSpan', () => { expect(spanToJSON(span).status).toBe('db-down'); expect(spanToJSON(span).timestamp).toBeDefined(); }); + + it('captures the exception with the default mechanism when `captureError` is true', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id'); + + const span = startInactiveSpan({ name: 'channel-span' }); + const error = new Error('boom'); + const { channel } = bindTracingChannelToSpan( + tracingChannel<{ operation: string }>('test:captureError:true'), + () => span, + { captureError: true }, + ); + + await expect( + channel.tracePromise( + async () => { + throw error; + }, + { operation: 'read' }, + ), + ).rejects.toThrow(error); + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { type: 'auto.diagnostic_channels.bind_span', handled: false }, + }); + expect(spanToJSON(span).status).toBe('boom'); + }); + + it('captures the exception with the hint returned by a `captureError` function, passing it the thrown error', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id'); + + const span = startInactiveSpan({ name: 'channel-span' }); + const error = new Error('boom'); + const captureError = vi.fn(() => ({ mechanism: { type: 'auto.http.custom', handled: false } })); + const { channel } = bindTracingChannelToSpan( + tracingChannel<{ operation: string }>('test:captureError:fn'), + () => span, + { captureError }, + ); + + await expect( + channel.tracePromise( + async () => { + throw error; + }, + { operation: 'read' }, + ), + ).rejects.toThrow(error); + + expect(captureError).toHaveBeenCalledTimes(1); + expect(captureError).toHaveBeenCalledWith(error); + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { type: 'auto.http.custom', handled: false }, + }); + expect(spanToJSON(span).status).toBe('boom'); + }); + + it('uses the default mechanism when `captureError` is a function on the synchronous error path', () => { + installTestAsyncContextStrategy(); + initTestClient(); + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id'); + + const span = startInactiveSpan({ name: 'channel-span' }); + const error = new Error('sync-boom'); + const captureError = vi.fn((e: unknown) => ({ extra: { caught: e instanceof Error } })); + const { channel } = bindTracingChannelToSpan( + tracingChannel<{ operation: string }>('test:captureError:fn-sync'), + () => span, + { captureError }, + ); + + expect(() => + channel.traceSync( + () => { + throw error; + }, + { operation: 'read' }, + ), + ).toThrow(error); + + expect(captureError).toHaveBeenCalledWith(error); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { extra: { caught: true } }); + }); }); describe('beforeSpanEnd', () => { @@ -566,4 +654,64 @@ describe('bindTracingChannelToSpan', () => { // Idempotent. expect(() => unbind()).not.toThrow(); }); + + describe('getSpan returns undefined', () => { + it('skips binding and lifecycle, leaving the enclosing span as the active parent', () => { + installTestAsyncContextStrategy(); + initTestClient(); + + const getSpan = vi.fn(() => undefined); + const { channel } = bindTracingChannelToSpan(tracingChannel<{ operation: string }>('test:skip:active'), getSpan); + + let dataSpan: Span | undefined; + channel.subscribe({ + end: data => { + dataSpan = data._sentrySpan; + }, + }); + + let enclosingSpanId: string | undefined; + let childParentSpanId: string | undefined; + startSpan({ forceTransaction: true, name: 'enclosing-span' }, enclosing => { + enclosingSpanId = enclosing.spanContext().spanId; + channel.traceSync( + () => { + startSpan({ name: 'child-span' }, child => { + childParentSpanId = spanToJSON(child).parent_span_id; + }); + }, + { operation: 'read' }, + ); + }); + + expect(getSpan).toHaveBeenCalledTimes(1); + // No span was stamped onto the payload, so the lifecycle handlers have nothing to end. + expect(dataSpan).toBeUndefined(); + // The context is left untouched, so children still parent to the enclosing span. + expect(childParentSpanId).toBe(enclosingSpanId); + }); + + it('does not capture the exception on the error path when no span was bound', async () => { + installTestAsyncContextStrategy(); + initTestClient(); + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id'); + + const { channel } = bindTracingChannelToSpan( + tracingChannel<{ operation: string }>('test:skip:error'), + () => undefined, + ); + + const error = new Error('boom'); + await expect( + channel.tracePromise( + async () => { + throw error; + }, + { operation: 'read' }, + ), + ).rejects.toThrow(error); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + }); });