diff --git a/dev-packages/browser-integration-tests/suites/tracing/bindScopeToEmitter/init.js b/dev-packages/browser-integration-tests/suites/tracing/bindScopeToEmitter/init.js new file mode 100644 index 000000000000..7c200c542c56 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/bindScopeToEmitter/init.js @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/bindScopeToEmitter/subject.js b/dev-packages/browser-integration-tests/suites/tracing/bindScopeToEmitter/subject.js new file mode 100644 index 000000000000..5c19d5ca9793 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/bindScopeToEmitter/subject.js @@ -0,0 +1,21 @@ +// A browser-native event target. +const target = new EventTarget(); + +const parentSpan = Sentry.startInactiveSpan({ name: 'parent' }); + +// Bind + register the listener while `parentSpan` is the active span. +Sentry.withActiveSpan(parentSpan, () => { + Sentry.bindScopeToEmitter(target); + + target.addEventListener('data', () => { + Sentry.startSpan({ name: 'child-bound' }, () => { + // noop + }); + }); +}); + +// At this point no span is active. Dispatching should re-enter the bound (parent) scope, +// so `child-bound` is nested under `parent` rather than starting its own trace. +target.dispatchEvent(new Event('data')); + +parentSpan.end(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/bindScopeToEmitter/test.ts b/dev-packages/browser-integration-tests/suites/tracing/bindScopeToEmitter/test.ts new file mode 100644 index 000000000000..f95829febe1d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/bindScopeToEmitter/test.ts @@ -0,0 +1,32 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { + envelopeRequestParser, + shouldSkipCdnBundleTest, + shouldSkipTracingTest, + waitForTransactionRequest, +} from '../../../utils/helpers'; + +sentryTest('bindScopeToEmitter runs listeners with the bound scope active', async ({ getLocalTestUrl, page }) => { + // `bindScopeToEmitter` is not exported from the CDN bundles, only from npm. + if (shouldSkipTracingTest() || shouldSkipCdnBundleTest()) { + sentryTest.skip(); + } + + const req = waitForTransactionRequest(page, e => e.transaction === 'parent'); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const parentEvent = envelopeRequestParser(await req); + const parentSpanId = parentEvent.contexts?.trace?.span_id; + const parentTraceId = parentEvent.contexts?.trace?.trace_id; + expect(parentSpanId).toMatch(/[a-f\d]{16}/); + + // The listener fired while no span was active, yet `child-bound` is nested under `parent` + // because the parent scope was bound to the emitter. + const childBound = parentEvent.spans?.find(s => s.description === 'child-bound'); + expect(childBound).toBeDefined(); + expect(childBound?.parent_span_id).toBe(parentSpanId); + expect(childBound?.trace_id).toBe(parentTraceId); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/scenario.ts new file mode 100644 index 000000000000..a4a256d3171d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/scenario.ts @@ -0,0 +1,44 @@ +import { EventEmitter } from 'node:events'; +import type { Span } from '@sentry/core'; +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [], + transport: loggingTransport, +}); + +const boundEmitter = new EventEmitter(); +const unboundEmitter = new EventEmitter(); + +let parentSpan: Span; + +Sentry.startSpanManual({ name: 'parent' }, span => { + parentSpan = span; + + // Bind the current (parent) scope to the emitter. Listeners registered afterwards should run + // with the parent span active, even when they fire in a different async context. + Sentry.bindScopeToEmitter(boundEmitter); + + boundEmitter.on('data', () => { + Sentry.startSpan({ name: 'child-bound' }, () => undefined); + }); + + // The unbound emitter is the control: its listener should NOT see the parent span. + unboundEmitter.on('data', () => { + Sentry.startSpan({ name: 'child-unbound' }, () => undefined); + }); +}); + +// Emit from a fresh async context (a timer scheduled at the top level), where the parent span is +// no longer active. Only the bound emitter should re-enter the parent scope. +setTimeout(() => { + unboundEmitter.emit('data'); + boundEmitter.emit('data'); + parentSpan.end(); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Sentry.flush(); +}, 10); diff --git a/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/test.ts b/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/test.ts new file mode 100644 index 000000000000..1ce679449100 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/test.ts @@ -0,0 +1,40 @@ +import type { TransactionEvent } from '@sentry/core'; +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('bindScopeToEmitter preserves the active span for listeners firing in a different async context', async () => { + // Collect both transactions regardless of the order they are flushed in. + const transactions: Record = {}; + const collect = (event: TransactionEvent): void => { + transactions[event.transaction as string] = event; + }; + + await createRunner(__dirname, 'scenario.ts') + .expect({ transaction: collect }) + .expect({ transaction: collect }) + .start() + .completed(); + + const parent = transactions['parent']; + const childUnbound = transactions['child-unbound']; + + expect(parent).toBeDefined(); + expect(childUnbound).toBeDefined(); + + const parentTraceId = parent?.contexts?.trace?.trace_id; + const parentSpanId = parent?.contexts?.trace?.span_id; + + // The bound emitter's listener ran inside the parent span context -> nested child span. + const childBound = parent?.spans?.find(span => span.description === 'child-bound'); + expect(childBound).toBeDefined(); + expect(childBound?.parent_span_id).toBe(parentSpanId); + expect(childBound?.trace_id).toBe(parentTraceId); + + // The unbound emitter's listener ran without the parent active -> its own, separate trace. + expect(childUnbound?.spans).toEqual([]); + expect(childUnbound?.contexts?.trace?.trace_id).not.toBe(parentTraceId); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index c73bc4f31f27..e3cf0d2fed3d 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -138,6 +138,7 @@ export { spotlightIntegration, startInactiveSpan, startNewTrace, + bindScopeToEmitter, suppressTracing, startSession, startSpan, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 04b1b76efaa1..a05ed104a4ea 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -79,6 +79,7 @@ export { startInactiveSpan, startSpanManual, startNewTrace, + bindScopeToEmitter, suppressTracing, withActiveSpan, getRootSpan, diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index ed35b8b4ac81..1abd577a7170 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -59,6 +59,7 @@ export { startSpanManual, withActiveSpan, startNewTrace, + bindScopeToEmitter, getSpanDescendants, setMeasurement, getSpanStatusFromHttpCode, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index c1e8235c2163..2dd19455ff56 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -101,6 +101,7 @@ export { startInactiveSpan, startSpanManual, startNewTrace, + bindScopeToEmitter, suppressTracing, withActiveSpan, getRootSpan, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 1a8b36e15b4d..fd7861a214d3 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -66,6 +66,7 @@ export { startInactiveSpan, startSpanManual, startNewTrace, + bindScopeToEmitter, suppressTracing, withActiveSpan, getSpanDescendants, diff --git a/packages/core/src/tracing/bindScopeToEmitter.ts b/packages/core/src/tracing/bindScopeToEmitter.ts new file mode 100644 index 000000000000..ac35b88c4cd6 --- /dev/null +++ b/packages/core/src/tracing/bindScopeToEmitter.ts @@ -0,0 +1,205 @@ +import { getCurrentScope, withScope } from '../currentScopes'; +import type { Scope } from '../scope'; + +type BoundListener = (...args: unknown[]) => unknown; + +/** + * Per-event map from a user-provided listener to its single scope-bound wrapper. We reuse one stable + * wrapper per listener (rather than minting a new one per registration) and let the underlying + * emitter/target handle repeat registrations: + * - Node's `EventEmitter` allows duplicates and counts them, so registering the same wrapper N times + * fires N times and `removeListener` removes one instance per call — no orphaned wrappers. + * - the DOM `EventTarget` dedupes by `(type, callback, capture)`, so reusing the wrapper preserves + * that idempotency; a fresh wrapper per call would defeat it and fire the listener repeatedly. + */ +type ListenerPatchMap = Map>; + +// We patch both Node.js `EventEmitter` registration methods (`on`, `addListener`, ...) and the DOM +// `EventTarget.addEventListener`, so this works for Node emitters and browser-native event targets. + +/** Listener-registration methods we patch so listeners inherit the bound scope. */ +const ADD_LISTENER_METHODS = [ + 'addListener', + 'on', + 'once', + 'prependListener', + 'prependOnceListener', + 'addEventListener', +] as const; +/** Listener-removal methods we patch so removals find the scope-bound wrapper. */ +const REMOVE_LISTENER_METHODS = ['removeListener', 'off', 'removeEventListener'] as const; + +/** Symbol under which the patch map is stashed on a bound emitter. */ +const SCOPE_BOUND_LISTENERS = Symbol('SentryScopeBoundListeners'); + +/** + * Minimal structural type for a Node.js-style `EventEmitter` or DOM `EventTarget`. We intentionally + * avoid importing `node:events` so this stays usable in non-Node environments — objects without any + * of these methods simply pass through untouched. + */ +type EventEmitterLike = Record; + +// Tracks the scope-bound wrapper currently being registered. Node's `once`/`prependOnceListener` +// synchronously re-enter `on`/`prependListener`, passing an internal "once wrapper" whose `.listener` +// is our wrapper; that re-entry must not be wrapped again. We scope the guard to that exact wrapper +// rather than using a blanket flag for the whole registration, so unrelated listeners added in the same +// synchronous window — e.g. from a Node `newListener` handler, or on another bound emitter — are still +// wrapped and keep their scope. Binding is synchronous, so a module-level value (with save/restore for +// nesting) is safe here. +let registeringWrapper: BoundListener | undefined; + +// True when `listener` is the wrapper we're mid-registering, or a Node once-wrapper around it (Node sets +// `.listener` on the once-wrapper to the function we passed). These are the only re-entrant adds to skip. +function isReentrantWrapperRegistration(listener: BoundListener): boolean { + return ( + registeringWrapper !== undefined && + (listener === registeringWrapper || (listener as { listener?: unknown }).listener === registeringWrapper) + ); +} + +/** + * Binds a scope to the given event emitter, so that any listener added to it runs with that scope + * (and therefore the active span) active — even if the listener fires later, in a different async + * context. + * + * By default the currently active scope is bound, captured at the time this function is called. + * Pass an explicit `scope` to bind a different one. + * + * This is useful when instrumenting APIs that hand back an event emitter (e.g. a streamed database + * query) whose `'data'` / `'error'` / `'end'` listeners would otherwise lose the trace context. + * + * Works with both Node.js `EventEmitter`s (`on`, `addListener`, ...) and DOM `EventTarget`s + * (`addEventListener`). Objects exposing none of these methods are returned untouched. + * + * The isolation scope is intentionally not captured — it is carried along by the active async + * context. This mirrors the event-emitter behavior of OpenTelemetry's `ContextManager.bind`. + */ +export function bindScopeToEmitter(emitter: T, scope: Scope = getCurrentScope()): T { + const ee = emitter as EventEmitterLike; + + // Already bound -> nothing to do. + if (getPatchMap(ee)) { + return emitter; + } + + createPatchMap(ee); + + for (const methodName of ADD_LISTENER_METHODS) { + if (typeof ee[methodName] !== 'function') { + continue; + } + ee[methodName] = patchAddListener(ee, ee[methodName] as BoundListener, scope); + } + + for (const methodName of REMOVE_LISTENER_METHODS) { + if (typeof ee[methodName] !== 'function') { + continue; + } + ee[methodName] = patchRemoveListener(ee, ee[methodName] as BoundListener); + } + + if (typeof ee.removeAllListeners === 'function') { + ee.removeAllListeners = patchRemoveAllListeners(ee, ee.removeAllListeners as BoundListener); + } + + return emitter; +} + +/** Wraps a listener so it runs with the given scope active. */ +function bindListenerToScope(listener: BoundListener, scope: Scope): BoundListener { + return function (this: unknown, ...args: unknown[]) { + return withScope(scope, () => listener.apply(this, args)); + }; +} + +function isBoundListener(listener: unknown): listener is BoundListener { + return typeof listener === 'function'; +} + +function patchAddListener(ee: EventEmitterLike, original: BoundListener, scope: Scope): BoundListener { + return function (this: unknown, ...args: unknown[]) { + const event = args[0] as string; + const listener = args[1]; + // Extra args (e.g. the `options` argument of `addEventListener`) must be forwarded verbatim. + const rest = args.slice(2); + + // Pass through what we must not wrap: non-function listeners (e.g. `EventListener` objects passed to + // `addEventListener`) and the re-entrant once-wrapper registration. Anything else is wrapped, even + // when added synchronously mid-registration. + if (!isBoundListener(listener) || isReentrantWrapperRegistration(listener)) { + return original.apply(this, args); + } + + const map = getPatchMap(ee) || createPatchMap(ee); + let listeners = map.get(event); + if (!listeners) { + listeners = new WeakMap(); + map.set(event, listeners); + } + + // Reuse one stable wrapper per listener so repeat registrations are handled correctly by the + // underlying emitter/target (Node counts duplicates; the DOM dedupes by `(callback, capture)`). + let boundListener = listeners.get(listener); + if (!boundListener) { + boundListener = bindListenerToScope(listener, scope); + listeners.set(listener, boundListener); + } + + const previous = registeringWrapper; + registeringWrapper = boundListener; + try { + return original.call(this, event, boundListener, ...rest); + } finally { + registeringWrapper = previous; + } + }; +} + +// Unlike `patchRemoveAllListeners`, this intentionally leaves the map entry in place: Node counts +// duplicate registrations, so the same wrapper may still be registered after removing one instance, +// and later `removeListener` calls need the mapping to find it. The entry is in a `WeakMap` keyed by +// the user listener, so it is GC'd once the user drops their reference — no manual cleanup needed. +function patchRemoveListener(ee: EventEmitterLike, original: BoundListener): BoundListener { + return function (this: unknown, ...args: unknown[]) { + const event = args[0] as string; + const listener = args[1]; + const rest = args.slice(2); + + const boundListener = isBoundListener(listener) ? getPatchMap(ee)?.get(event)?.get(listener) : undefined; + if (!boundListener) { + return original.apply(this, args); + } + // Pass the same stable wrapper and forward the caller's extra args (e.g. the `capture` option of + // `removeEventListener`) unchanged, so the emitter/target matches the right registration itself. + return original.call(this, event, boundListener, ...rest); + }; +} + +// Safe to drop map entries here (unlike `patchRemoveListener`): this removes *every* listener for the +// event at once, so no registration referencing those wrappers remains. It also reclaims keys from the +// strong outer `Map` (keyed by event-name strings), which would otherwise accumulate indefinitely. +function patchRemoveAllListeners(ee: EventEmitterLike, original: BoundListener): BoundListener { + return function (this: unknown, ...args: unknown[]) { + const map = getPatchMap(ee); + if (map) { + if (args.length === 0) { + // `removeAllListeners()` with no event clears everything -> reset the map. + createPatchMap(ee); + } else { + const event = args[0] as string; + map.delete(event); + } + } + return original.apply(this, args); + }; +} + +function createPatchMap(ee: EventEmitterLike): ListenerPatchMap { + const map: ListenerPatchMap = new Map(); + (ee as Record)[SCOPE_BOUND_LISTENERS] = map; + return map; +} + +function getPatchMap(ee: EventEmitterLike): ListenerPatchMap | undefined { + return (ee as Record)[SCOPE_BOUND_LISTENERS]; +} diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index 9b56045b37f3..543d8242615a 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -15,6 +15,7 @@ export { startNewTrace, SUPPRESS_TRACING_KEY, } from './trace'; +export { bindScopeToEmitter } from './bindScopeToEmitter'; export { getDynamicSamplingContextFromClient, getDynamicSamplingContextFromSpan, diff --git a/packages/core/test/lib/tracing/bindScopeToEmitter.test.ts b/packages/core/test/lib/tracing/bindScopeToEmitter.test.ts new file mode 100644 index 000000000000..d3c00e241376 --- /dev/null +++ b/packages/core/test/lib/tracing/bindScopeToEmitter.test.ts @@ -0,0 +1,422 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient, withScope } from '../../../src'; +import { Scope } from '../../../src/scope'; +import { bindScopeToEmitter } from '../../../src/tracing/bindScopeToEmitter'; +import { startInactiveSpan, withActiveSpan } from '../../../src/tracing/trace'; +import { getActiveSpan } from '../../../src/utils/spanUtils'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +describe('bindScopeToEmitter', () => { + beforeEach(() => { + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + setCurrentClient(client); + client.init(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('runs listeners added after binding with the scope active at bind time', () => { + const emitter = new EventEmitter(); + + let boundScope: Scope | undefined; + withScope(scope => { + boundScope = scope; + bindScopeToEmitter(emitter); + }); + + // The listener is registered *outside* the `withScope`, yet should see the bound scope. + let scopeInListener: Scope | undefined; + emitter.on('data', () => { + scopeInListener = getCurrentScope(); + }); + + emitter.emit('data'); + + expect(scopeInListener).toBe(boundScope); + expect(scopeInListener).not.toBe(getCurrentScope()); + }); + + it('binds an explicitly passed scope instead of the current one', () => { + const emitter = new EventEmitter(); + + const explicitScope = new Scope(); + bindScopeToEmitter(emitter, explicitScope); + + let scopeInListener: Scope | undefined; + emitter.on('data', () => { + scopeInListener = getCurrentScope(); + }); + + emitter.emit('data'); + + expect(scopeInListener).toBe(explicitScope); + expect(scopeInListener).not.toBe(getCurrentScope()); + }); + + it('prefers the explicitly passed scope over the active scope at call time', () => { + const emitter = new EventEmitter(); + + const explicitScope = new Scope(); + withScope(activeScope => { + // Bind a *different* scope than the one that is currently active. + expect(activeScope).not.toBe(explicitScope); + bindScopeToEmitter(emitter, explicitScope); + }); + + let scopeInListener: Scope | undefined; + emitter.on('data', () => { + scopeInListener = getCurrentScope(); + }); + + emitter.emit('data'); + + expect(scopeInListener).toBe(explicitScope); + }); + + it('preserves the active span for listeners', () => { + const emitter = new EventEmitter(); + + const span = startInactiveSpan({ name: 'parent' }); + withActiveSpan(span, () => { + bindScopeToEmitter(emitter); + }); + + let activeSpanInListener: ReturnType; + emitter.on('end', () => { + activeSpanInListener = getActiveSpan(); + }); + + expect(getActiveSpan()).toBeUndefined(); + emitter.emit('end'); + + expect(activeSpanInListener).toBe(span); + }); + + it.each(['on', 'addListener', 'prependListener'] as const)('binds the scope for listeners added via %s', method => { + const emitter = new EventEmitter(); + + let boundScope: Scope | undefined; + withScope(scope => { + boundScope = scope; + bindScopeToEmitter(emitter); + }); + + let scopeInListener: Scope | undefined; + emitter[method]('data', () => { + scopeInListener = getCurrentScope(); + }); + + emitter.emit('data'); + + expect(scopeInListener).toBe(boundScope); + }); + + it('binds the scope for `once` listeners and only fires them once', () => { + const emitter = new EventEmitter(); + + let boundScope: Scope | undefined; + withScope(scope => { + boundScope = scope; + bindScopeToEmitter(emitter); + }); + + const listener = vi.fn(() => getCurrentScope()); + emitter.once('data', listener); + + emitter.emit('data'); + emitter.emit('data'); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveReturnedWith(boundScope); + }); + + it('removes the wrapped listener when removing via the original reference', () => { + const emitter = new EventEmitter(); + bindScopeToEmitter(emitter); + + const listener = vi.fn(); + emitter.on('data', listener); + emitter.removeListener('data', listener); + + emitter.emit('data'); + + expect(listener).not.toHaveBeenCalled(); + expect(emitter.listenerCount('data')).toBe(0); + }); + + it('handles the same listener registered multiple times for one event', () => { + const emitter = new EventEmitter(); + bindScopeToEmitter(emitter); + + const listener = vi.fn(); + emitter.on('data', listener); + emitter.on('data', listener); + expect(emitter.listenerCount('data')).toBe(2); + + emitter.emit('data'); + expect(listener).toHaveBeenCalledTimes(2); + + // Each `removeListener` must remove a distinct registration — neither wrapper may be orphaned. + emitter.removeListener('data', listener); + expect(emitter.listenerCount('data')).toBe(1); + emitter.removeListener('data', listener); + expect(emitter.listenerCount('data')).toBe(0); + + listener.mockClear(); + emitter.emit('data'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('handles a mix of `once` and `on` registrations of the same listener', () => { + const emitter = new EventEmitter(); + bindScopeToEmitter(emitter); + + const listener = vi.fn(); + emitter.once('data', listener); + emitter.on('data', listener); + expect(emitter.listenerCount('data')).toBe(2); + + // First emit fires both; the `once` registration removes itself. + emitter.emit('data'); + expect(listener).toHaveBeenCalledTimes(2); + expect(emitter.listenerCount('data')).toBe(1); + + // The remaining `on` registration is still removable via the original reference. + emitter.removeListener('data', listener); + expect(emitter.listenerCount('data')).toBe(0); + }); + + it('removes the wrapped listener via `off`', () => { + const emitter = new EventEmitter(); + bindScopeToEmitter(emitter); + + const listener = vi.fn(); + emitter.on('data', listener); + emitter.off('data', listener); + + emitter.emit('data'); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('binds listeners registered from a synchronous `newListener` handler', () => { + const emitter = new EventEmitter(); + + let boundScope: Scope | undefined; + withScope(scope => { + boundScope = scope; + bindScopeToEmitter(emitter); + }); + + // `newListener` fires synchronously *during* the `original.call` of the outer registration. A + // listener added from it must still be scope-bound (the guard must not suppress unrelated adds). + let scopeInNested: Scope | undefined; + const nested = (): void => { + scopeInNested = getCurrentScope(); + }; + emitter.once('newListener', () => { + emitter.on('nested', nested); + }); + + emitter.on('data', () => {}); + emitter.emit('nested'); + + expect(scopeInNested).toBe(boundScope); + }); + + it('binds listeners registered on another bound emitter mid-registration', () => { + const outer = new EventEmitter(); + const inner = new EventEmitter(); + + let boundScope: Scope | undefined; + withScope(scope => { + boundScope = scope; + bindScopeToEmitter(outer); + bindScopeToEmitter(inner); + }); + + // Registering on `inner` while `outer` is mid-registration must not be skipped by the shared guard. + let scopeInInner: Scope | undefined; + outer.once('newListener', () => { + inner.on('data', () => { + scopeInInner = getCurrentScope(); + }); + }); + + outer.on('data', () => {}); + inner.emit('data'); + + expect(scopeInInner).toBe(boundScope); + }); + + it('supports removeAllListeners', () => { + const emitter = new EventEmitter(); + + let boundScope: Scope | undefined; + withScope(scope => { + boundScope = scope; + bindScopeToEmitter(emitter); + }); + + const a = vi.fn(); + const b = vi.fn(); + emitter.on('data', a); + emitter.on('end', b); + + emitter.removeAllListeners('data'); + emitter.emit('data'); + expect(a).not.toHaveBeenCalled(); + + // listeners added after a targeted removeAllListeners are still bound and fire + let scopeInListener: Scope | undefined; + emitter.on('data', () => { + scopeInListener = getCurrentScope(); + }); + emitter.emit('data'); + expect(scopeInListener).toBe(boundScope); + + emitter.removeAllListeners(); + emitter.emit('end'); + expect(b).not.toHaveBeenCalled(); + }); + + it('does not double-wrap when binding the same emitter twice', () => { + const emitter = new EventEmitter(); + + const first = bindScopeToEmitter(emitter); + const second = bindScopeToEmitter(emitter); + + expect(first).toBe(emitter); + expect(second).toBe(emitter); + + const listener = vi.fn(); + emitter.on('data', listener); + emitter.emit('data'); + + // Listener fires exactly once per emit (not multiple times due to double wrapping). + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('preserves the emitter return value for chaining', () => { + const emitter = new EventEmitter(); + bindScopeToEmitter(emitter); + + const result = emitter.on('a', () => {}).on('b', () => {}); + expect(result).toBe(emitter); + }); + + it('passes through objects that are not event emitters', () => { + const obj = { foo: 'bar' }; + expect(bindScopeToEmitter(obj)).toBe(obj); + }); + + describe('DOM EventTarget', () => { + it('binds the scope for listeners added via addEventListener', () => { + const target = new EventTarget(); + + let boundScope: Scope | undefined; + withScope(scope => { + boundScope = scope; + bindScopeToEmitter(target); + }); + + let scopeInListener: Scope | undefined; + target.addEventListener('data', () => { + scopeInListener = getCurrentScope(); + }); + + target.dispatchEvent(new Event('data')); + + expect(scopeInListener).toBe(boundScope); + }); + + it('removes the wrapped listener via removeEventListener', () => { + const target = new EventTarget(); + bindScopeToEmitter(target); + + const listener = vi.fn(); + target.addEventListener('data', listener); + target.removeEventListener('data', listener); + + target.dispatchEvent(new Event('data')); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('forwards the options argument (e.g. `once`)', () => { + const target = new EventTarget(); + bindScopeToEmitter(target); + + const listener = vi.fn(); + target.addEventListener('data', listener, { once: true }); + + target.dispatchEvent(new Event('data')); + target.dispatchEvent(new Event('data')); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('preserves addEventListener idempotency for identical (type, listener) registrations', () => { + const target = new EventTarget(); + bindScopeToEmitter(target); + + const listener = vi.fn(); + // The DOM dedupes identical registrations -> the listener must only fire once. + target.addEventListener('data', listener); + target.addEventListener('data', listener); + + target.dispatchEvent(new Event('data')); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('removes the correct registration when only the capture phase differs', () => { + const target = new EventTarget(); + bindScopeToEmitter(target); + + const listener = vi.fn(); + // Capture is part of a registration's identity, so these are two distinct registrations. + target.addEventListener('data', listener, { capture: true }); + target.addEventListener('data', listener, { capture: false }); + + // Remove only the capture-phase one; the bubble-phase registration must survive. + target.removeEventListener('data', listener, { capture: true }); + + target.dispatchEvent(new Event('data')); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('passes through non-function (EventListener object) listeners without throwing', () => { + const target = new EventTarget(); + bindScopeToEmitter(target); + + const handleEvent = vi.fn(); + const listenerObject = { handleEvent }; + target.addEventListener('data', listenerObject); + + expect(() => target.dispatchEvent(new Event('data'))).not.toThrow(); + expect(handleEvent).toHaveBeenCalledTimes(1); + }); + }); + + it('forwards `this` and arguments to the original listener', () => { + const emitter = new EventEmitter(); + bindScopeToEmitter(emitter); + + const listener = vi.fn(); + emitter.on('data', listener); + emitter.emit('data', 1, 'two', { three: true }); + + expect(listener).toHaveBeenCalledWith(1, 'two', { three: true }); + // EventEmitter invokes listeners with `this` bound to the emitter. + expect(listener.mock.instances[0]).toBe(emitter); + }); +}); diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 5e855380e238..0a3c78c8cc39 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -65,6 +65,7 @@ export { startInactiveSpan, startSpanManual, startNewTrace, + bindScopeToEmitter, suppressTracing, // eslint-disable-next-line typescript/no-deprecated inboundFiltersIntegration, diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 8f3b80a1a95c..e61a9027dd57 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -79,6 +79,7 @@ export { startInactiveSpan, startSpanManual, startNewTrace, + bindScopeToEmitter, suppressTracing, withActiveSpan, getRootSpan, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index e473381fbf22..5de7b02ee273 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -80,6 +80,7 @@ export { startInactiveSpan, startSpanManual, startNewTrace, + bindScopeToEmitter, suppressTracing, withActiveSpan, getRootSpan, diff --git a/packages/node-core/src/common-exports.ts b/packages/node-core/src/common-exports.ts index ddedd5c171eb..205f7cef159d 100644 --- a/packages/node-core/src/common-exports.ts +++ b/packages/node-core/src/common-exports.ts @@ -102,6 +102,7 @@ export { startSpanManual, startInactiveSpan, startNewTrace, + bindScopeToEmitter, suppressTracing, getActiveSpan, withActiveSpan, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index df90fd85e755..26c1a9ab3916 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -121,6 +121,7 @@ export { startSpanManual, startInactiveSpan, startNewTrace, + bindScopeToEmitter, suppressTracing, getActiveSpan, withActiveSpan, diff --git a/packages/remix/src/cloudflare/index.ts b/packages/remix/src/cloudflare/index.ts index 82438119e5d2..f6e6c62dcae5 100644 --- a/packages/remix/src/cloudflare/index.ts +++ b/packages/remix/src/cloudflare/index.ts @@ -88,6 +88,7 @@ export { startInactiveSpan, startSpanManual, startNewTrace, + bindScopeToEmitter, suppressTracing, withActiveSpan, getSpanDescendants, diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 3ccda4362e9e..0ea9f14d7122 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -108,6 +108,7 @@ export { spotlightIntegration, startInactiveSpan, startNewTrace, + bindScopeToEmitter, suppressTracing, startSession, startSpan, diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index f0e6b1f5e523..32efe353499f 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -112,6 +112,7 @@ export { spotlightIntegration, startInactiveSpan, startNewTrace, + bindScopeToEmitter, suppressTracing, startSession, startSpan, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index ac3760e87598..a24623f36f37 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -109,6 +109,7 @@ export { spotlightIntegration, startInactiveSpan, startNewTrace, + bindScopeToEmitter, suppressTracing, startSession, startSpan, diff --git a/packages/sveltekit/src/worker/index.ts b/packages/sveltekit/src/worker/index.ts index 6691d36ee99c..0532d29b438a 100644 --- a/packages/sveltekit/src/worker/index.ts +++ b/packages/sveltekit/src/worker/index.ts @@ -72,6 +72,7 @@ export { spanToTraceHeader, startInactiveSpan, startNewTrace, + bindScopeToEmitter, suppressTracing, startSpan, startSpanManual, diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index 47e6af074f6d..280f8216ae13 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -63,6 +63,7 @@ export { startInactiveSpan, startSpanManual, startNewTrace, + bindScopeToEmitter, suppressTracing, withActiveSpan, getSpanDescendants,