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..7702ebeebbbb 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -23,3 +23,9 @@ export type { RedisTracingChannelFactory, RedisTracingChannelSubscribers, } from './redis/redis-dc-subscriber'; +export { bindTracingChannelToSpan } 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 new file mode 100644 index 000000000000..819b640e9b85 --- /dev/null +++ b/packages/server-utils/src/tracing-channel.ts @@ -0,0 +1,182 @@ +import type { TracingChannel, TracingChannelSubscribers } from 'node:diagnostics_channel'; +import type { AsyncLocalStorage } from 'node:async_hooks'; +import type { ExclusiveEventHintOrCaptureContext, 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; +} + +interface TracingChannelManualBindingOptions { + /** + * Whether the span is ended automatically (`auto`, default) or left to the caller (`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 + * 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`. + * 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 | ((e: unknown) => ExclusiveEventHintOrCaptureContext); +} + +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). */ + 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 => {}; + +/** + * 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 | undefined, + opts?: TracingChannelBindingOptions, +): 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: sentryChannel, unbind: NOOP }; + } + + const asyncLocalStorage = binding.asyncLocalStorage as AsyncLocalStorage; + + 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; + }); + + const unbindStore = (): void => { + channel.start.unbindStore(asyncLocalStorage); + }; + + if (opts && 'lifecycle' in opts && opts.lifecycle === 'manual') { + return { channel: sentryChannel, unbind: unbindStore }; + } + + 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, + 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) { + endBoundSpan(data, beforeSpanEnd); + } + }, + 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, getErrorHint(data.error)); + } + + span.setStatus({ code: SPAN_STATUS_ERROR, message: getErrorMessage(data.error) }); + }, + asyncEnd(data) { + endBoundSpan(data, beforeSpanEnd); + }, + }; + + sentryChannel.subscribe(subscribers); + + return { + channel: sentryChannel, + unbind: () => { + sentryChannel.unsubscribe(subscribers); + unbindStore(); + }, + }; +} + +function endBoundSpan( + data: TracingChannelPayloadWithSpan, + beforeSpanEnd: TracingChannelAutoBindingOptions['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 new file mode 100644 index 000000000000..e695d1237288 --- /dev/null +++ b/packages/server-utils/test/tracing-channel.test.ts @@ -0,0 +1,717 @@ +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', handled: false } }; + + // Returns a channel whose span we can observe, plus spies for `span.end` and `captureException`. + function setup(name: string): { + channel: ReturnType['channel']; + 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('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('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('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('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('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(); + }); + + 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', () => { + 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(); + + 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(); + }); + + 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(); + }); + + 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(); + }); + }); +});