diff --git a/.changeset/node-host-rejection-listener.md b/.changeset/node-host-rejection-listener.md new file mode 100644 index 0000000000..0b2196d870 --- /dev/null +++ b/.changeset/node-host-rejection-listener.md @@ -0,0 +1,5 @@ +--- +'posthog-node': patch +--- + +fix: exception autocapture now captures unhandled promise rejections when the app or framework (for example Next.js, Nitro or PM2) has its own `unhandledRejection` listener, without changing what Node does with the rejection. Each occurrence is captured once per project, however many clients the app creates, and an error reported more than once in the same turn of the event loop (rethrown by a host listener, or one Error object rejected by two promises) is captured once. When the process survives the error, autocapture captures it at the end of the current turn of the event loop, so that a `captureException` call for the same error in that turn, from the app's own listener or a framework's error hook such as Nitro's, is the one sent, with its `distinctId` and properties. A `captureException` call for an error that autocapture already captured in the same turn is skipped. When the app flushes or shuts down a client, that client captures at once what is still waiting for the end of the turn, so a crash handler that flushes or shuts down and then exits still sends the error. A crash is captured whatever the error, an `AbortError` or a framework's control flow included. A rejection that the app handles only later is still captured, as Node reports it as unhandled. Next.js and React control-flow errors and `AbortError` rejections are skipped when the process survives them. diff --git a/packages/node/src/__tests__/extensions/exception-autocapture.child.cjs b/packages/node/src/__tests__/extensions/exception-autocapture.child.cjs index bc7d98f9fd..5826bb5d88 100644 --- a/packages/node/src/__tests__/extensions/exception-autocapture.child.cjs +++ b/packages/node/src/__tests__/extensions/exception-autocapture.child.cjs @@ -3,9 +3,33 @@ const { PostHog } = require('../../../dist/entrypoints/index.node.js') const scenario = process.argv[2] const withSdk = process.argv[3] === 'with-sdk' -if (scenario === 'unhandled-rejection-listener') { - process.on('unhandledRejection', (reason) => { - process.stdout.write(`unhandled-listener:${reason.message}\n`) +function logRejection(reason) { + process.stdout.write(`unhandled-listener:${reason.message}\n`) +} + +function rethrowRejection(reason) { + process.stdout.write(`rethrowing-listener:${reason.message}\n`) + throw reason +} + +if ( + scenario === 'unhandled-rejection-listener' || + scenario === 'removed-unhandled-rejection-listener' || + scenario === 'late-handled-rejection' || + scenario === 'many-clients' || + scenario === 'exits-without-shutdown' || + scenario === 'client-per-request' || + scenario === 'flush-then-exit' +) { + process.on('unhandledRejection', logRejection) +} else if (scenario === 'rethrowing-unhandled-rejection-listener') { + process.on('unhandledRejection', rethrowRejection) +} else if (scenario === 'once-listener-before-init') { + process.once('unhandledRejection', logRejection) +} else if (scenario === 'self-removing-listener-before-init') { + process.on('unhandledRejection', function removeItself(reason) { + process.removeListener('unhandledRejection', removeItself) + logRejection(reason) }) } else if (scenario === 'uncaught-exception-listener') { process.on('uncaughtException', (error, origin) => { @@ -17,23 +41,118 @@ if (scenario === 'unhandled-rejection-listener') { process.execArgv.push('--unhandled-rejections=warn') } -const posthog = withSdk - ? new PostHog('api_key', { - enableExceptionAutocapture: true, - }) - : undefined +const clients = withSdk + ? Array.from( + { length: scenario === 'many-clients' ? 3 : scenario === 'flush-second-client-in-uncaught-listener' ? 2 : 1 }, + () => new PostHog('api_key', { enableExceptionAutocapture: true }) + ) + : [] +const posthog = clients[0] -if (posthog) { - posthog._capturePreparedEvent = (event) => { - const exception = event.properties.$exception_list[0] - process.stdout.write(`posthog-capture:${exception.mechanism.type}:${exception.value}\n`) - return Promise.resolve() - } +function printCapture(event) { + const exception = event.properties.$exception_list[0] + process.stdout.write(`posthog-capture:${exception.mechanism.type}:${exception.value}\n`) + return Promise.resolve() } -process.once('beforeExit', async () => { - await posthog?.shutdown() - process.stdout.write('completed\n') -}) +for (const client of clients) { + client._capturePreparedEvent = printCapture + client.captureImmediate = printCapture +} -Promise.reject(new Error('Child process rejection')) +if (scenario === 'late-unhandled-rejection-listener') { + process.on('unhandledRejection', logRejection) +} else if (scenario === 'late-rethrowing-unhandled-rejection-listener') { + process.on('unhandledRejection', rethrowRejection) +} else if (scenario === 'removed-unhandled-rejection-listener') { + process.removeListener('unhandledRejection', logRejection) +} else if (scenario === 'string-rethrowing-unhandled-rejection-listener') { + process.on('unhandledRejection', (reason) => { + process.stdout.write(`rethrowing-listener:${reason}\n`) + throw reason + }) +} else if (scenario === 'immediate-capture-in-uncaught-listener') { + // Added after init, so it runs after the SDK's listener. Its own capture still wins over autocapture. + process.on('uncaughtException', async (error) => { + await posthog?.captureExceptionImmediate(error) + process.stdout.write('exiting\n') + process.exit(0) + }) +} else if (scenario === 'shutdown-in-uncaught-listener') { + // The usual way to send what is queued before a crash: shut the client down, then exit. + process.on('uncaughtException', async () => { + await posthog?.shutdown() + process.stdout.write('exiting\n') + process.exit(1) + }) +} else if (scenario === 'flush-in-uncaught-listener') { + process.on('uncaughtException', async () => { + await posthog?.flush() + process.stdout.write('exiting\n') + process.exit(1) + }) +} else if (scenario === 'flush-second-client-in-uncaught-listener') { + // Two clients for one project, and the crash handler flushes the one that is not the project's first. + process.on('uncaughtException', async () => { + await clients[1]?.flush() + process.stdout.write('exiting\n') + process.exit(1) + }) +} else if (scenario === 'repeated-string-throws') { + // Keeps the process alive through uncaught exceptions, so an idle job can throw the same string again. + process.on('uncaughtException', () => undefined) +} else if (scenario === 'shutdown-in-rejection-listener') { + process.on('unhandledRejection', async () => { + await posthog?.shutdown() + process.stdout.write('exiting\n') + process.exit(1) + }) +} else if (scenario === 'error-hook-listener') { + // Like Nitro: the host's listener hands the rejection to an error hook, which an integration passes to + // captureException a microtask later. + process.on('unhandledRejection', (reason) => { + logRejection(reason) + queueMicrotask(() => posthog?.captureException(reason)) + }) +} + +if (scenario !== 'exits-without-shutdown' && scenario !== 'flush-then-exit') { + process.once('beforeExit', async () => { + await Promise.all(clients.map((client) => client.shutdown())) + process.stdout.write('completed\n') + }) +} + +if ( + scenario === 'immediate-capture-in-uncaught-listener' || + scenario === 'shutdown-in-uncaught-listener' || + scenario === 'flush-in-uncaught-listener' || + scenario === 'flush-second-client-in-uncaught-listener' +) { + setTimeout(() => { + throw new Error('Child process exception') + }, 10) +} else if (scenario === 'repeated-string-throws') { + // Nothing else wakes the event loop between the throws. + for (const delay of [100, 300, 500]) { + setTimeout(() => { + throw 'tick' + }, delay) + } +} else if (scenario === 'flush-then-exit') { + // The end of a serverless invocation: flush, then the runtime freezes or ends the instance. + Promise.reject(new Error('Child process rejection')) + setTimeout(() => posthog?.flush().then(() => process.exit(0)) ?? process.exit(0), 20) +} else if (scenario === 'client-per-request') { + // The request's client shuts down before the rejection lands. + Promise.resolve(posthog?.shutdown()).then(() => + setTimeout(() => Promise.reject(new Error('Child process rejection')), 20) + ) +} else if (scenario === 'late-handled-rejection') { + const rejection = Promise.reject(new Error('Child process rejection')) + setTimeout(() => rejection.catch(() => process.stdout.write('handled-late\n')), 20) +} else if (scenario === 'string-rethrowing-unhandled-rejection-listener') { + Promise.reject('Child process rejection') +} else { + Promise.reject(new Error('Child process rejection')) +} diff --git a/packages/node/src/__tests__/extensions/exception-autocapture.spec.ts b/packages/node/src/__tests__/extensions/exception-autocapture.spec.ts index 50ad48148f..8c22b95268 100644 --- a/packages/node/src/__tests__/extensions/exception-autocapture.spec.ts +++ b/packages/node/src/__tests__/extensions/exception-autocapture.spec.ts @@ -1,10 +1,14 @@ import ErrorTracking from '@/extensions/error-tracking' import { PostHog } from '@/entrypoints/index.node' import { - addUncaughtExceptionListener, - addUnhandledRejectionListener, + capturePendingExceptions, + claimExplicitCapture, getUnhandledRejectionMode, + resetExceptionAutocapture, + startExceptionAutocapture, } from '@/extensions/error-tracking/autocapture' +import type { AutocaptureClient } from '@/extensions/error-tracking/autocapture' +import { AsyncLocalStorage } from 'async_hooks' import { Worker } from 'worker_threads' import { relative } from 'path' import { once } from 'events' @@ -21,9 +25,28 @@ type ChildResult = { type ChildScenario = | 'no-application-listener' | 'unhandled-rejection-listener' + | 'late-unhandled-rejection-listener' + | 'removed-unhandled-rejection-listener' + | 'rethrowing-unhandled-rejection-listener' + | 'late-rethrowing-unhandled-rejection-listener' | 'uncaught-exception-listener' | 'mutated-node-options' | 'mutated-exec-argv' + | 'late-handled-rejection' + | 'many-clients' + | 'string-rethrowing-unhandled-rejection-listener' + | 'error-hook-listener' + | 'exits-without-shutdown' + | 'client-per-request' + | 'once-listener-before-init' + | 'self-removing-listener-before-init' + | 'flush-then-exit' + | 'immediate-capture-in-uncaught-listener' + | 'shutdown-in-uncaught-listener' + | 'flush-in-uncaught-listener' + | 'flush-second-client-in-uncaught-listener' + | 'repeated-string-throws' + | 'shutdown-in-rejection-listener' function runUnhandledRejectionChild({ mode, @@ -66,9 +89,112 @@ function captureCount(result: ChildResult): number { return result.stdout.match(/^posthog-capture:/gm)?.length ?? 0 } +type Listener = (...args: any[]) => unknown + +// Replaces the process listener registry, so tests can add host listeners and emit events without touching the runner. +function stubProcessListeners(hostRejectionListeners: Listener[] = []) { + const registry = new Map([['unhandledRejection', [...hostRejectionListeners]]]) + const get = (event: string | symbol): Listener[] => registry.get(event) ?? [] + const emit = (event: string | symbol, ...args: unknown[]): void => get(event).forEach((listener) => listener(...args)) + + vi.spyOn(global.process, 'listeners').mockImplementation(((event: string | symbol) => [ + ...get(event), + ]) as typeof process.listeners) + vi.spyOn(global.process, 'rawListeners').mockImplementation(((event: string | symbol) => [ + ...get(event), + ]) as typeof process.rawListeners) + vi.spyOn(global.process, 'on').mockImplementation(((event: string | symbol, listener: Listener) => { + registry.set(event, [...get(event), listener]) + return global.process + }) as typeof process.on) + vi.spyOn(global.process, 'prependListener').mockImplementation(((event: string | symbol, listener: Listener) => { + registry.set(event, [listener, ...get(event)]) + return global.process + }) as typeof process.prependListener) + vi.spyOn(global.process, 'removeListener').mockImplementation(((event: string | symbol, listener: Listener) => { + registry.set( + event, + get(event).filter((registered) => registered !== listener) + ) + return global.process + }) as typeof process.removeListener) + // Only the events the SDK emits itself. Every other event passes through: vitest's workers receive their RPC + // replies as process 'message' events. + const realEmit = global.process.emit.bind(global.process) as (event: string | symbol, ...args: unknown[]) => boolean + vi.spyOn(global.process, 'emit').mockImplementation(((event: string | symbol, ...args: unknown[]) => { + if (event !== 'uncaughtException' && event !== 'uncaughtExceptionMonitor') { + return realEmit(event, ...args) + } + emit(event, ...args) + return get(event).length > 0 + }) as typeof process.emit) + const exit = vi.spyOn(global.process, 'exit').mockImplementation((() => undefined) as typeof process.exit) + const emitWarning = vi.spyOn(global.process, 'emitWarning').mockImplementation(() => undefined) + vi.spyOn(console, 'error').mockImplementation(() => undefined) + + return { + listenerCount: (event: string): number => get(event).length, + emit, + exit, + emitWarning, + // Matches EventEmitter: newListener before the listener is added, removeListener after it is removed. + addHostRejectionListener(listener: Listener): void { + emit('newListener', 'unhandledRejection', listener) + registry.set('unhandledRejection', [...get('unhandledRejection'), listener]) + }, + removeHostRejectionListenerSilently(listener: Listener): void { + registry.set( + 'unhandledRejection', + get('unhandledRejection').filter((registered) => registered !== listener) + ) + }, + removeHostRejectionListener(listener: Listener): void { + registry.set( + 'unhandledRejection', + get('unhandledRejection').filter((registered) => registered !== listener) + ) + emit('removeListener', 'unhandledRejection', listener) + }, + } +} + +function makeClient(key = 'api_key@https://us.i.posthog.com'): AutocaptureClient & { + capture: ReturnType + getContext: ReturnType + flush: ReturnType + shutdown: ReturnType +} { + return { + key, + capture: vi.fn(), + getContext: vi.fn(), + flush: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + } +} + +const UNHANDLED_REJECTION_HINT = { + mechanism: { + type: 'onunhandledrejection', + handled: false, + }, +} + +const UNCAUGHT_EXCEPTION_HINT = { + mechanism: { + type: 'onuncaughtexception', + handled: false, + }, +} + describe('exception autocapture', () => { - afterEach(() => { + afterEach(async () => { + // Lets a fatal exit started by the test reach the stubbed process.exit before the stubs are restored. + await vi.advanceTimersByTimeAsync(0) + resetExceptionAutocapture() vi.restoreAllMocks() + // A fatal exit sets it, and the test worker would otherwise end with it. + process.exitCode = undefined }) function checkException( exception: CoreErrorTracking.Exception, @@ -104,58 +230,607 @@ describe('exception autocapture', () => { } it('should install an uncaught-exception listener outside strict mode', () => { - const onSpy = vi.spyOn(global.process, 'on').mockReturnValue(global.process) + const processListeners = stubProcessListeners() - addUncaughtExceptionListener( - () => {}, - () => {}, - 'throw' - ) + startExceptionAutocapture(makeClient(), 'throw') - expect(onSpy).toHaveBeenCalledWith('uncaughtException', expect.any(Function)) + expect(processListeners.listenerCount('uncaughtException')).toBe(1) }) it('should monitor uncaught exceptions without handling them in strict mode', () => { - const capture = vi.fn() - const onFatal = vi.fn() - const onSpy = vi.spyOn(global.process, 'on').mockReturnValue(global.process) - addUncaughtExceptionListener(capture, onFatal, 'strict') - const handler = onSpy.mock.calls.find(([event]) => event === 'uncaughtExceptionMonitor')?.[1] as - | NodeJS.UncaughtExceptionListener - | undefined + const processListeners = stubProcessListeners() + const client = makeClient() + startExceptionAutocapture(client, 'strict') const reason = new Error('promoted rejection') - handler?.(reason, 'unhandledRejection') + processListeners.emit('uncaughtExceptionMonitor', reason, 'unhandledRejection') - expect(capture).toHaveBeenCalledWith(reason, { - mechanism: { - type: 'onunhandledrejection', - handled: false, - }, - }) - expect(onFatal).not.toHaveBeenCalled() - expect(onSpy).not.toHaveBeenCalledWith('uncaughtException', expect.any(Function)) + expect(client.capture).toHaveBeenCalledWith(reason, UNHANDLED_REJECTION_HINT) + expect(processListeners.listenerCount('uncaughtException')).toBe(0) + expect(processListeners.listenerCount('unhandledRejection')).toBe(0) + expect(processListeners.exit).not.toHaveBeenCalled() }) - it.each(['throw', 'strict', 'warn-with-error-code'] as const)( - 'should not install an unhandled-rejection listener in %s mode', + it.each(['throw', 'warn-with-error-code'] as const)( + 'should not install an unhandled-rejection listener in %s mode without a host listener', (mode) => { - const onSpy = vi.spyOn(global.process, 'on').mockReturnValue(global.process) + const processListeners = stubProcessListeners() - addUnhandledRejectionListener(() => {}, mode) + startExceptionAutocapture(makeClient(), mode) - expect(onSpy).not.toHaveBeenCalled() + expect(processListeners.listenerCount('unhandledRejection')).toBe(0) } ) + it.each(['throw', 'warn-with-error-code'] as const)( + 'should capture an unhandled rejection in %s mode at the end of the turn, when a host listener exists', + async (mode) => { + const client = makeClient() + const hostListener = vi.fn() + const processListeners = stubProcessListeners([hostListener]) + const reason = new Error('host handled rejection') + + startExceptionAutocapture(client, mode) + processListeners.emit('unhandledRejection', reason, Promise.resolve()) + + expect(processListeners.listenerCount('unhandledRejection')).toBe(2) + expect(hostListener).toHaveBeenCalledWith(reason, expect.any(Promise)) + expect(client.capture).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(0) + + expect(client.capture).toHaveBeenCalledTimes(1) + expect(client.capture).toHaveBeenCalledWith(reason, UNHANDLED_REJECTION_HINT) + } + ) + + it('should start capturing when a host listener is added after init and stop when the last one is removed', async () => { + const client = makeClient() + const firstHostListener = vi.fn() + const secondHostListener = vi.fn() + const processListeners = stubProcessListeners() + startExceptionAutocapture(client, 'throw') + expect(processListeners.listenerCount('unhandledRejection')).toBe(0) + + processListeners.addHostRejectionListener(firstHostListener) + processListeners.addHostRejectionListener(secondHostListener) + expect(processListeners.listenerCount('unhandledRejection')).toBe(3) + + const reason = new Error('late host rejection') + processListeners.emit('unhandledRejection', reason, Promise.resolve()) + await vi.advanceTimersByTimeAsync(0) + expect(client.capture).toHaveBeenCalledTimes(1) + expect(client.capture).toHaveBeenCalledWith(reason, UNHANDLED_REJECTION_HINT) + + processListeners.removeHostRejectionListener(firstHostListener) + expect(processListeners.listenerCount('unhandledRejection')).toBe(2) + + processListeners.removeHostRejectionListener(secondHostListener) + expect(processListeners.listenerCount('unhandledRejection')).toBe(0) + }) + + it.each([ + ['exits', false], + ['leaves the process to the app', true], + ] as const)( + 'should escalate the rejection like Node when the host listener disappeared without an event, and %s', + async (_name, appHandlesUncaught) => { + const client = makeClient() + const hostListener = vi.fn() + const processListeners = stubProcessListeners([hostListener]) + const appUncaughtListener = vi.fn() + if (appHandlesUncaught) { + process.on('uncaughtException', appUncaughtListener) + } + startExceptionAutocapture(client, 'throw') + const ourListener = process.listeners('unhandledRejection').find((listener) => listener !== hostListener)! + const reason = new Error('rejection after the host left') + + // A host removal that emits no event, as in Bun. + processListeners.removeHostRejectionListenerSilently(hostListener) + ourListener(reason, Promise.resolve()) + await vi.advanceTimersByTimeAsync(0) + + expect(client.capture).toHaveBeenCalledTimes(1) + expect(client.capture).toHaveBeenCalledWith(reason, UNHANDLED_REJECTION_HINT) + if (appHandlesUncaught) { + expect(appUncaughtListener).toHaveBeenCalledWith(reason, 'unhandledRejection') + expect(processListeners.exit).not.toHaveBeenCalled() + } else { + expect(processListeners.exit).toHaveBeenCalledWith(1) + } + } + ) + + it('should capture a rejection once when the host listener rethrows it', async () => { + const client = makeClient() + const processListeners = stubProcessListeners([vi.fn()]) + startExceptionAutocapture(client, 'throw') + const reason = new Error('rethrown rejection') + + processListeners.emit('unhandledRejection', reason, Promise.resolve()) + processListeners.emit('uncaughtException', reason, 'uncaughtException') + await vi.advanceTimersByTimeAsync(0) + + expect(client.capture).toHaveBeenCalledTimes(1) + expect(client.capture).toHaveBeenCalledWith(reason, UNHANDLED_REJECTION_HINT) + expect(client.shutdown).toHaveBeenCalled() + expect(processListeners.exit).toHaveBeenCalledWith(1) + }) + + it('should capture a string rejection once when the host listener rethrows it', async () => { + const client = makeClient() + const processListeners = stubProcessListeners([vi.fn()]) + startExceptionAutocapture(client, 'throw') + + processListeners.emit('unhandledRejection', 'string reason', Promise.resolve()) + processListeners.emit('uncaughtException', 'string reason', 'uncaughtException') + await vi.advanceTimersByTimeAsync(0) + + expect(client.capture).toHaveBeenCalledTimes(1) + expect(client.capture).toHaveBeenCalledWith('string reason', UNHANDLED_REJECTION_HINT) + }) + + it('should capture a rejection once when the runtime reports the rethrow before our rejection listener runs', async () => { + const client = makeClient() + const processListeners = stubProcessListeners([vi.fn()]) + startExceptionAutocapture(client, 'throw') + const reason = new Error('rethrown first') + + // Bun's order: the host listener's throw reaches uncaughtException before the next rejection listener runs. + processListeners.emit('uncaughtException', reason, 'uncaughtException') + processListeners.emit('unhandledRejection', reason, Promise.resolve()) + await vi.advanceTimersByTimeAsync(0) + + expect(client.capture).toHaveBeenCalledTimes(1) + }) + + it('should capture a rejection once when the same listener receives it twice', async () => { + const client = makeClient() + const processListeners = stubProcessListeners([vi.fn()]) + startExceptionAutocapture(client, 'throw') + const reason = new Error('delivered twice') + const promise = Promise.resolve() + + // Next.js 16's filter can hand a rejection to a listener that is both on the process and in its queue. + processListeners.emit('unhandledRejection', reason, promise) + processListeners.emit('unhandledRejection', reason, promise) + await vi.advanceTimersByTimeAsync(0) + + expect(client.capture).toHaveBeenCalledTimes(1) + }) + + it('should capture a separate occurrence of the same error object again', async () => { + const client = makeClient() + const processListeners = stubProcessListeners([vi.fn()]) + startExceptionAutocapture(client, 'throw') + const reason = new Error('aborted') + + // An AbortSignal reason rejects a fetch nobody awaits, and is later thrown again by throwIfAborted(). + processListeners.emit('unhandledRejection', reason, Promise.resolve()) + await vi.advanceTimersByTimeAsync(0) + // Lets the end-of-turn reset run, as it would before any later turn. + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + processListeners.emit('uncaughtException', reason, 'uncaughtException') + + expect(client.capture).toHaveBeenCalledTimes(2) + expect(client.capture).toHaveBeenLastCalledWith(reason, UNCAUGHT_EXCEPTION_HINT) + }) + + it('should capture a rejection once when the host rethrows it from an immediate', async () => { + const client = makeClient() + const reason = new Error('rethrown later') + // Next.js 16's filter catches a queued listener's error and rethrows it with setImmediate. + const processListeners = stubProcessListeners([ + (rejection: unknown) => + setImmediate(() => processListeners.emit('uncaughtException', rejection, 'uncaughtException')), + ]) + startExceptionAutocapture(client, 'throw') + + processListeners.emit('unhandledRejection', reason, Promise.resolve()) + await vi.advanceTimersByTimeAsync(0) + + expect(client.capture).toHaveBeenCalledTimes(1) + expect(client.capture).toHaveBeenCalledWith(reason, UNHANDLED_REJECTION_HINT) + }) + + it('should capture a rejection once when the host rethrows it two immediates later', async () => { + // Fake timers run an immediate queued by another immediate only on the next advance, unlike Node. + vi.useRealTimers() + try { + const client = makeClient() + const reason = new Error('rethrown two hops later') + const processListeners = stubProcessListeners([ + (rejection: unknown) => + setImmediate(() => + setImmediate(() => processListeners.emit('uncaughtException', rejection, 'uncaughtException')) + ), + ]) + process.on('uncaughtException', vi.fn()) + startExceptionAutocapture(client, 'throw') + + processListeners.emit('unhandledRejection', reason, Promise.resolve()) + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(client.capture).toHaveBeenCalledTimes(1) + expect(client.capture).toHaveBeenCalledWith(reason, UNHANDLED_REJECTION_HINT) + } finally { + vi.useFakeTimers() + } + }) + + it('should capture a reason once when two rejections report it in the same turn', async () => { + const client = makeClient() + const processListeners = stubProcessListeners([vi.fn()]) + startExceptionAutocapture(client, 'throw') + const reason = new Error('shared by two derived promises') + + processListeners.emit('unhandledRejection', reason, Promise.resolve()) + processListeners.emit('unhandledRejection', reason, Promise.resolve()) + await vi.advanceTimersByTimeAsync(0) + + expect(client.capture).toHaveBeenCalledTimes(1) + }) + + it('should treat a later capture of the same error object as a new occurrence', async () => { + const client = makeClient() + const processListeners = stubProcessListeners() + startExceptionAutocapture(client, 'throw') + const error = new Error('cached failure') + + // Without an app listener the exception is fatal, so autocapture captures it at once. + processListeners.emit('uncaughtException', error, 'uncaughtException') + // A framework's error hook handing the same occurrence to captureException. + expect(claimExplicitCapture(error, client.key)).toBe(false) + + // A later request capturing the cached error, and a later throw of it. + await vi.advanceTimersByTimeAsync(10) + expect(claimExplicitCapture(error, client.key)).toBe(true) + await vi.advanceTimersByTimeAsync(10) + processListeners.emit('uncaughtException', error, 'uncaughtException') + await vi.advanceTimersByTimeAsync(0) + expect(client.capture).toHaveBeenCalledTimes(2) + }) + + it.each([ + ['unhandled rejection', 'unhandledRejection'], + ['uncaught exception', 'uncaughtException'], + ] as const)('should let the app capture an %s itself during the turn, once', async (_name, event) => { + const client = makeClient() + const processListeners = stubProcessListeners([vi.fn()]) + process.on('uncaughtException', vi.fn()) + startExceptionAutocapture(client, 'throw') + const error = new Error('handed to an error hook') + + processListeners.emit(event, error, event === 'unhandledRejection' ? Promise.resolve() : 'uncaughtException') + // The host's listener or a framework's error hook, a microtask later. + await Promise.resolve() + expect(claimExplicitCapture(error, client.key)).toBe(true) + await vi.advanceTimersByTimeAsync(0) + + expect(client.capture).not.toHaveBeenCalled() + }) + + it('should capture once per project, however many clients there are', () => { + const processListeners = stubProcessListeners([vi.fn()]) + const sameProject = [makeClient(), makeClient()] + const otherProject = makeClient('other_key@https://us.i.posthog.com') + for (const client of [...sameProject, otherProject]) { + startExceptionAutocapture(client, 'throw') + } + + expect(processListeners.listenerCount('uncaughtException')).toBe(1) + expect(processListeners.listenerCount('unhandledRejection')).toBe(2) + + processListeners.emit('uncaughtException', new Error('one exception'), 'uncaughtException') + expect(sameProject[0].capture.mock.calls.length + sameProject[1].capture.mock.calls.length).toBe(1) + expect(otherProject.capture).toHaveBeenCalledTimes(1) + }) + + it('should keep capturing through the last client of a project after it shuts down', async () => { + const processListeners = stubProcessListeners([vi.fn()]) + // A client per request: created, used, shut down. + const requestClient = makeClient() + startExceptionAutocapture(requestClient, 'throw')() + const reason = new Error('after the request finished') + + processListeners.emit('unhandledRejection', reason, Promise.resolve()) + await vi.advanceTimersByTimeAsync(0) + expect(requestClient.capture).toHaveBeenCalledWith(reason, UNHANDLED_REJECTION_HINT) + + // The next request's client takes over. + const nextClient = makeClient() + startExceptionAutocapture(nextClient, 'throw') + processListeners.emit('uncaughtException', new Error('during the next request'), 'uncaughtException') + expect(nextClient.capture).toHaveBeenCalledTimes(1) + expect(requestClient.capture).toHaveBeenCalledTimes(1) + }) + + it('should capture each rejection in the async context it was reported in', async () => { + // A real immediate carries the listener's AsyncLocalStorage context. A fake one does not. + vi.useRealTimers() + try { + const requests = new AsyncLocalStorage<{ request: string }>() + const contexts: unknown[] = [] + const client = makeClient() + client.capture.mockImplementation(() => contexts.push(requests.getStore())) + const processListeners = stubProcessListeners([vi.fn()]) + startExceptionAutocapture(client, 'throw') + + requests.run({ request: 'a' }, () => + processListeners.emit('unhandledRejection', new Error('in request a'), Promise.resolve()) + ) + requests.run({ request: 'b' }, () => + processListeners.emit('unhandledRejection', new Error('in request b'), Promise.resolve()) + ) + processListeners.emit('unhandledRejection', new Error('outside a request'), Promise.resolve()) + await new Promise((resolve) => setImmediate(resolve)) + + expect(contexts).toEqual([{ request: 'a' }, { request: 'b' }, undefined]) + } finally { + vi.useFakeTimers() + } + }) + + it('should capture what waits for the end of the turn when the app flushes', () => { + const client = makeClient() + const processListeners = stubProcessListeners([vi.fn()]) + startExceptionAutocapture(client, 'throw') + const reason = new Error('pending at flush') + + processListeners.emit('unhandledRejection', reason, Promise.resolve()) + capturePendingExceptions() + + expect(client.capture).toHaveBeenCalledWith(reason, UNHANDLED_REJECTION_HINT) + }) + + it('should report the client that captured what waited, so that another client of the project can flush it', () => { + const processListeners = stubProcessListeners([vi.fn()]) + const other = makeClient() + const inRequest = makeClient() + inRequest.getContext.mockReturnValue({ distinctId: 'user-1' }) + startExceptionAutocapture(other, 'throw') + startExceptionAutocapture(inRequest, 'throw') + + processListeners.emit('unhandledRejection', new Error('pending at flush'), Promise.resolve()) + + // The client whose request context is active captures, whichever client flushes. + expect([...capturePendingExceptions()]).toEqual([inRequest]) + expect(other.capture).not.toHaveBeenCalled() + }) + + it('should capture a capture forced early in the async context the rejection was reported in', () => { + const requests = new AsyncLocalStorage<{ request: string }>() + const contexts: unknown[] = [] + const client = makeClient() + client.capture.mockImplementation(() => contexts.push(requests.getStore())) + const processListeners = stubProcessListeners([vi.fn()]) + startExceptionAutocapture(client, 'throw') + + requests.run({ request: 'a' }, () => + processListeners.emit('unhandledRejection', new Error('in request a'), Promise.resolve()) + ) + // Another request flushes or shuts a client down in the same turn. + requests.run({ request: 'b' }, () => capturePendingExceptions()) + + expect(contexts).toEqual([{ request: 'a' }]) + }) + + it.each([ + ['an AbortError', Object.assign(new Error('aborted'), { name: 'AbortError' })], + ['a Next.js control-flow digest', Object.assign(new Error('hanging'), { digest: 'HANGING_PROMISE_REJECTION' })], + ])('should capture a crash caused by %s', (_name, error) => { + const client = makeClient() + const processListeners = stubProcessListeners() + startExceptionAutocapture(client, 'throw') + + // Node escalates the rejection, and no app listener keeps the process alive. + processListeners.emit('uncaughtException', error, 'unhandledRejection') + + expect(client.capture).toHaveBeenCalledWith(error, UNHANDLED_REJECTION_HINT) + expect(client.shutdown).toHaveBeenCalled() + }) + + it('should install its rejection listener again for a new client in warn mode after the app removed it', () => { + const processListeners = stubProcessListeners() + startExceptionAutocapture(makeClient(), 'warn') + for (const listener of process.listeners('unhandledRejection')) { + process.removeListener('unhandledRejection', listener as Listener) + } + + startExceptionAutocapture(makeClient(), 'warn') + + expect(processListeners.listenerCount('unhandledRejection')).toBe(1) + expect(processListeners.listenerCount('uncaughtException')).toBe(1) + }) + + it('should install its listeners again for a new client after the app removed them', () => { + const processListeners = stubProcessListeners() + const first = makeClient() + startExceptionAutocapture(first, 'throw') + for (const listener of process.listeners('uncaughtException')) { + process.removeListener('uncaughtException', listener as Listener) + } + expect(processListeners.listenerCount('uncaughtException')).toBe(0) + + const second = makeClient() + startExceptionAutocapture(second, 'throw') + expect(processListeners.listenerCount('uncaughtException')).toBe(1) + + processListeners.emit('uncaughtException', new Error('after the removal'), 'uncaughtException') + expect(first.capture.mock.calls.length + second.capture.mock.calls.length).toBe(1) + }) + + it('should exit after a fatal error whose inspection throws', async () => { + const client = makeClient() + const processListeners = stubProcessListeners() + startExceptionAutocapture(client, 'throw') + vi.mocked(console.error).mockImplementation(() => { + throw new TypeError('inspect failed') + }) + + processListeners.emit('uncaughtException', new Error('cannot be printed'), 'uncaughtException') + await vi.advanceTimersByTimeAsync(0) + + expect(processListeners.exit).toHaveBeenCalledWith(1) + // Left alone on Node: an app's once listener that already removed itself may exit with its own code. + expect(process.exitCode).toBeUndefined() + }) + + it('should set the exit code at once for a fatal rejection on Bun', () => { + Object.defineProperty(process.versions, 'bun', { value: '1.4.2', configurable: true }) + try { + const client = makeClient() + const processListeners = stubProcessListeners() + startExceptionAutocapture(client, 'throw') + const reason = new Error('rejection on Bun') + + processListeners.emit('unhandledRejection', reason, Promise.resolve()) + + // Bun ends an idle process right after this listener, before the shutdown can finish. + expect(process.exitCode).toBe(1) + expect(client.capture).toHaveBeenCalledWith(reason, UNHANDLED_REJECTION_HINT) + } finally { + delete (process.versions as Record).bun + } + }) + + it('should defer with a timer in a runtime without setImmediate', async () => { + const client = makeClient() + const processListeners = stubProcessListeners([vi.fn()]) + startExceptionAutocapture(client, 'throw') + const setImmediate = globalThis.setImmediate + Object.assign(globalThis, { setImmediate: undefined }) + try { + processListeners.emit('unhandledRejection', new Error('no setImmediate'), Promise.resolve()) + await vi.advanceTimersByTimeAsync(1) + } finally { + Object.assign(globalThis, { setImmediate }) + } + + expect(client.capture).toHaveBeenCalledTimes(1) + }) + + it('should reproduce the warning for a reason String() cannot convert', () => { + const client = makeClient() + const hostListener = vi.fn() + const processListeners = stubProcessListeners([hostListener]) + startExceptionAutocapture(client, 'warn-with-error-code') + const ourListener = process.listeners('unhandledRejection').find((listener) => listener !== hostListener)! + const reason = Object.create(null) + + processListeners.removeHostRejectionListenerSilently(hostListener) + expect(() => ourListener(reason, Promise.resolve())).not.toThrow() + + expect(processListeners.emitWarning).toHaveBeenCalledWith( + 'Unhandled promise rejection: object', + 'UnhandledPromiseRejectionWarning' + ) + expect(client.capture).toHaveBeenCalledWith(reason, UNHANDLED_REJECTION_HINT) + }) + + it('should capture through the client of a project whose request context is active', () => { + const processListeners = stubProcessListeners([vi.fn()]) + const outside = makeClient() + const inRequest = makeClient() + inRequest.getContext.mockReturnValue({ distinctId: 'user-1' }) + startExceptionAutocapture(outside, 'throw') + startExceptionAutocapture(inRequest, 'throw') + + processListeners.emit('uncaughtException', new Error('in a request'), 'uncaughtException') + + expect(outside.capture).not.toHaveBeenCalled() + expect(inRequest.capture).toHaveBeenCalledTimes(1) + }) + + it('should not capture a rejection again when the app captured it just before it was thrown', async () => { + const client = makeClient() + const processListeners = stubProcessListeners([vi.fn()]) + startExceptionAutocapture(client, 'throw') + const reason = new Error('captured, then thrown from an async function') + + expect(claimExplicitCapture(reason, client.key)).toBe(true) + processListeners.emit('unhandledRejection', reason, Promise.resolve()) + await vi.advanceTimersByTimeAsync(0) + + expect(client.capture).not.toHaveBeenCalled() + }) + + it('should not capture an error through captureException after autocapture sent it to the same project', () => { + const client = makeClient() + const processListeners = stubProcessListeners() + startExceptionAutocapture(client, 'throw') + const error = new Error('handed to an error hook') + + processListeners.emit('uncaughtException', error, 'uncaughtException') + + expect(claimExplicitCapture(error, client.key)).toBe(false) + expect(claimExplicitCapture(error, 'other_key@https://us.i.posthog.com')).toBe(true) + expect(claimExplicitCapture('a string', client.key)).toBe(true) + }) + + it('should not autocapture an error the app already captured itself', () => { + const client = makeClient() + const processListeners = stubProcessListeners() + startExceptionAutocapture(client, 'throw') + const error = new Error('caught, captured and rethrown') + + expect(claimExplicitCapture(error, client.key)).toBe(true) + processListeners.emit('uncaughtException', error, 'uncaughtException') + + expect(client.capture).not.toHaveBeenCalled() + expect(processListeners.exit).toHaveBeenCalledTimes(0) + }) + + it('should ignore framework control flow and aborts', async () => { + const client = makeClient() + const processListeners = stubProcessListeners([vi.fn()]) + startExceptionAutocapture(client, 'throw') + const postpone = Object.assign(new Error('postponed'), { $$typeof: Symbol.for('react.postpone') }) + const hangingPromise = Object.assign(new Error('hanging'), { digest: 'HANGING_PROMISE_REJECTION' }) + const wrapped = new Error('wrapper', { + cause: Object.assign(new Error('bailout'), { digest: 'DYNAMIC_SERVER_USAGE' }), + }) + const abort = Object.assign(new Error('aborted'), { name: 'AbortError' }) + + const timers = vi.getTimerCount() + for (const reason of [postpone, hangingPromise, wrapped, abort]) { + processListeners.emit('unhandledRejection', reason, Promise.resolve()) + } + // Nothing waits for the end of the turn either. + expect(vi.getTimerCount()).toBe(timers) + await vi.advanceTimersByTimeAsync(0) + + expect(client.capture).not.toHaveBeenCalled() + }) + it.each(['warn', 'none'] as const)('should install an unhandled-rejection listener in %s mode', (mode) => { - const onSpy = vi.spyOn(global.process, 'on').mockReturnValue(global.process) + const processListeners = stubProcessListeners() - addUnhandledRejectionListener(() => {}, mode) + startExceptionAutocapture(makeClient(), mode) - expect(onSpy).toHaveBeenCalledWith('unhandledRejection', expect.any(Function)) + expect(processListeners.listenerCount('unhandledRejection')).toBe(1) }) + it.each(['throw', 'warn'] as const)( + 'should listen again in %s mode after a host clears every listener and adds its own', + (mode) => { + const processListeners = stubProcessListeners([vi.fn()]) + startExceptionAutocapture(makeClient(), mode) + expect(processListeners.listenerCount('unhandledRejection')).toBe(2) + + // Next.js's experimental removeUncaughtErrorAndRejectionListeners does this. + for (const listener of process.listeners('unhandledRejection')) { + process.removeListener('unhandledRejection', listener as Listener) + } + expect(processListeners.listenerCount('unhandledRejection')).toBe(0) + processListeners.addHostRejectionListener(vi.fn()) + + expect(processListeners.listenerCount('unhandledRejection')).toBe(2) + } + ) + it('should determine the effective unhandled rejection mode from execArgv and NODE_OPTIONS', () => { expect(getUnhandledRejectionMode([], undefined)).toBe('throw') expect(getUnhandledRejectionMode([], '--unhandled-rejections warn')).toBe('warn') @@ -176,22 +851,14 @@ describe('exception autocapture', () => { }) it('should tag promoted unhandled rejections from the Node uncaught-exception origin', () => { - const capture = vi.fn() - const onSpy = vi.spyOn(global.process, 'on').mockReturnValue(global.process) - addUncaughtExceptionListener(capture, () => {}) - const handler = onSpy.mock.calls.find(([event]) => event === 'uncaughtException')?.[1] as - | NodeJS.UncaughtExceptionListener - | undefined + const client = makeClient() + const processListeners = stubProcessListeners() + startExceptionAutocapture(client, 'throw') const reason = new Error('promoted rejection') - handler?.(reason, 'unhandledRejection') + processListeners.emit('uncaughtException', reason, 'unhandledRejection') - expect(capture).toHaveBeenCalledWith(reason, { - mechanism: { - type: 'onunhandledrejection', - handled: false, - }, - }) + expect(client.capture).toHaveBeenCalledWith(reason, UNHANDLED_REJECTION_HINT) }) it('should listen to uncaught errors', async () => { @@ -359,15 +1026,215 @@ describe('exception autocapture', () => { expect(captureCount(withSdk)).toBe(0) }) - it('should leave application unhandled-rejection handling untouched in throw mode', async () => { - const result = await runUnhandledRejectionChild({ mode: 'throw', scenario: 'unhandled-rejection-listener' }) + it.each([ + ['before', 'throw', 'unhandled-rejection-listener'], + ['after', 'throw', 'late-unhandled-rejection-listener'], + ['before', 'warn-with-error-code', 'unhandled-rejection-listener'], + ['after', 'warn-with-error-code', 'late-unhandled-rejection-listener'], + ] as const)( + 'should capture once when the application adds an unhandled-rejection listener %s init in %s mode', + async (_order, mode, scenario) => { + const [withoutSdk, withSdk] = await Promise.all([ + runUnhandledRejectionChild({ mode, scenario, withSdk: false }), + runUnhandledRejectionChild({ mode, scenario }), + ]) + + for (const result of [withoutSdk, withSdk]) { + expect(result.signal).toBeNull() + expect(result.code).toBe(0) + expect(result.stdout).toContain('unhandled-listener:Child process rejection') + expect(result.stdout).toContain('completed') + expect(result.stderr).toBe('') + } + expect(captureCount(withoutSdk)).toBe(0) + expect(captureCount(withSdk)).toBe(1) + expect(withSdk.stdout).toContain('posthog-capture:onunhandledrejection:Child process rejection') + } + ) + + it('should restore the default fatal rejection when the application removes its listener', async () => { + const [withoutSdk, withSdk] = await Promise.all([ + runUnhandledRejectionChild({ + mode: 'throw', + scenario: 'removed-unhandled-rejection-listener', + withSdk: false, + }), + runUnhandledRejectionChild({ mode: 'throw', scenario: 'removed-unhandled-rejection-listener' }), + ]) + + for (const result of [withoutSdk, withSdk]) { + expect(result.signal).toBeNull() + expect(result.code).toBe(1) + expect(result.stdout).not.toContain('unhandled-listener') + expect(result.stdout).not.toContain('completed') + expect(result.stderr).toContain('Child process rejection') + } + expect(captureCount(withoutSdk)).toBe(0) + expect(captureCount(withSdk)).toBe(1) + expect(withSdk.stdout).toContain('posthog-capture:onunhandledrejection:Child process rejection') + }) + + it.each([ + // Ours runs before the host's listener either way, so the rethrow is matched to the rejection it was. + ['before init', 'rethrowing-unhandled-rejection-listener', 'onunhandledrejection'], + ['after init', 'late-rethrowing-unhandled-rejection-listener', 'onunhandledrejection'], + ] as const)( + 'should capture once when an application listener added %s rethrows the rejection', + async (_order, scenario, mechanism) => { + const [withoutSdk, withSdk] = await Promise.all([ + runUnhandledRejectionChild({ mode: 'throw', scenario, withSdk: false }), + runUnhandledRejectionChild({ mode: 'throw', scenario }), + ]) + + for (const result of [withoutSdk, withSdk]) { + expect(result.signal).toBeNull() + expect(result.code).toBe(1) + expect(result.stdout).toContain('rethrowing-listener:Child process rejection') + expect(result.stdout).not.toContain('completed') + expect(result.stderr).toContain('Child process rejection') + } + expect(captureCount(withoutSdk)).toBe(0) + expect(captureCount(withSdk)).toBe(1) + expect(withSdk.stdout).toContain(`posthog-capture:${mechanism}:Child process rejection`) + } + ) + + it('should capture a rejection the application handles only after Node reports it, as Node reports it', async () => { + const [withoutSdk, withSdk] = await Promise.all([ + runUnhandledRejectionChild({ mode: 'throw', scenario: 'late-handled-rejection', withSdk: false }), + runUnhandledRejectionChild({ mode: 'throw', scenario: 'late-handled-rejection' }), + ]) + + for (const result of [withoutSdk, withSdk]) { + expect(result.code).toBe(0) + expect(result.stdout).toContain('unhandled-listener:Child process rejection') + expect(result.stdout).toContain('handled-late') + expect(result.stdout).toContain('completed') + expect(result.stderr).toContain('PromiseRejectionHandledWarning') + } + expect(captureCount(withSdk)).toBe(1) + }) + + it('should capture a rejection once however many clients the application creates', async () => { + const result = await runUnhandledRejectionChild({ mode: 'throw', scenario: 'many-clients' }) + + expect(result.code).toBe(0) + expect(captureCount(result)).toBe(1) + expect(result.stderr).not.toContain('MaxListenersExceededWarning') + }) + + it('should capture a string rejection once when an application listener rethrows it', async () => { + const [withoutSdk, withSdk] = await Promise.all([ + runUnhandledRejectionChild({ + mode: 'throw', + scenario: 'string-rethrowing-unhandled-rejection-listener', + withSdk: false, + }), + runUnhandledRejectionChild({ mode: 'throw', scenario: 'string-rethrowing-unhandled-rejection-listener' }), + ]) + + for (const result of [withoutSdk, withSdk]) { + expect(result.code).toBe(1) + expect(result.stdout).toContain('rethrowing-listener:Child process rejection') + expect(result.stdout).not.toContain('completed') + } + expect(captureCount(withSdk)).toBe(1) + }) + + it('should capture a rejection once when the host also passes it to captureException', async () => { + const result = await runUnhandledRejectionChild({ mode: 'throw', scenario: 'error-hook-listener' }) - expect(result.signal).toBeNull() expect(result.code).toBe(0) - expect(captureCount(result)).toBe(0) - expect(result.stdout).toContain('unhandled-listener:Child process rejection') expect(result.stdout).toContain('completed') - expect(result.stderr).toBe('') + expect(captureCount(result)).toBe(1) + // The hook's own capture wins, with whatever identity and properties it passes. + expect(result.stdout).toContain('posthog-capture:generic:Child process rejection') + }) + + it.each([ + ['ends on its own without shutting the client down', 'exits-without-shutdown'], + ['rejects after its client per request shut down', 'client-per-request'], + ] as const)('should capture a rejection when the process %s', async (_name, scenario) => { + const result = await runUnhandledRejectionChild({ mode: 'throw', scenario }) + + expect(result.code).toBe(0) + expect(result.stdout).toContain('unhandled-listener:Child process rejection') + expect(captureCount(result)).toBe(1) + }) + + it.each(['once-listener-before-init', 'self-removing-listener-before-init'] as const)( + 'should leave the process running when a host listener from before init removes itself (%s)', + async (scenario) => { + const [withoutSdk, withSdk] = await Promise.all([ + runUnhandledRejectionChild({ mode: 'throw', scenario, withSdk: false }), + runUnhandledRejectionChild({ mode: 'throw', scenario }), + ]) + + for (const result of [withoutSdk, withSdk]) { + expect(result.code).toBe(0) + expect(result.stdout).toContain('unhandled-listener:Child process rejection') + expect(result.stdout).toContain('completed') + } + expect(captureCount(withSdk)).toBe(1) + } + ) + + it('should let an uncaught-exception listener added after init capture the exception itself', async () => { + const result = await runUnhandledRejectionChild({ + mode: 'throw', + scenario: 'immediate-capture-in-uncaught-listener', + }) + + expect(result.code).toBe(0) + expect(result.stdout).toMatch(/posthog-capture:generic:Child process exception\nexiting/) + expect(captureCount(result)).toBe(1) + }) + + it.each([ + ['uncaught-exception', 'shutdown-in-uncaught-listener', 'onuncaughtexception: Child process exception'], + ['unhandled-rejection', 'shutdown-in-rejection-listener', 'onunhandledrejection: Child process rejection'], + ] as const)( + 'should send the error when the app shuts the client down in its %s listener and exits', + async (_name, scenario, capture) => { + const result = await runUnhandledRejectionChild({ mode: 'throw', scenario }) + + expect(result.code).toBe(1) + expect(result.stdout).toContain(`posthog-capture:${capture.replace(': ', ':')}\nexiting`) + expect(captureCount(result)).toBe(1) + } + ) + + it('should send the error when the crash handler flushes another client of the project and exits', async () => { + const result = await runUnhandledRejectionChild({ + mode: 'throw', + scenario: 'flush-second-client-in-uncaught-listener', + }) + + expect(result.code).toBe(1) + expect(result.stdout).toContain('posthog-capture:onuncaughtexception:Child process exception\nexiting') + expect(captureCount(result)).toBe(1) + }) + + it('should send the error when the app flushes in its uncaught-exception listener and exits', async () => { + const result = await runUnhandledRejectionChild({ mode: 'throw', scenario: 'flush-in-uncaught-listener' }) + + expect(result.code).toBe(1) + expect(result.stdout).toContain('posthog-capture:onuncaughtexception:Child process exception\nexiting') + expect(captureCount(result)).toBe(1) + }) + + it('should capture each throw of the same string by an idle job', async () => { + const result = await runUnhandledRejectionChild({ mode: 'throw', scenario: 'repeated-string-throws' }) + + expect(result.code).toBe(0) + expect(captureCount(result)).toBe(3) + }) + + it('should send a rejection with a flush that follows it', async () => { + const result = await runUnhandledRejectionChild({ mode: 'throw', scenario: 'flush-then-exit' }) + + expect(result.code).toBe(0) + expect(captureCount(result)).toBe(1) }) it('captures aggregate children in one event with Node frame modifiers', async () => { @@ -449,6 +1316,24 @@ describe('exception autocapture', () => { } }) + it('should still send the other exceptions on shutdown when one cannot be built', async () => { + vi.spyOn(ErrorTracking, 'buildEventMessage') + .mockRejectedValueOnce(new TypeError('Cannot perform get on a proxy that has been revoked')) + .mockResolvedValueOnce({ + event: '$exception', + distinctId: 'distinct-id', + properties: { $exception_list: [{ type: 'Error' }] }, + }) + const ph = new PostHog('TEST_API_KEY', { host: 'http://example.com', fetchRetryCount: 0 }) + const capture = vi.spyOn(ph, '_capturePreparedEvent').mockResolvedValue(undefined) + + ph['errorTracking']['onException']({}, {}) + ph['errorTracking']['onException']({}, {}) + + await expect(ph.shutdown()).resolves.toBeUndefined() + expect(capture).toHaveBeenCalledTimes(1) + }) + it('should honour the configured rate limiter bucket size', async () => { vi.spyOn(ErrorTracking, 'buildEventMessage').mockResolvedValue({ event: '$exception', diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 0bcee8dc34..cb4a251b43 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -350,6 +350,8 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen } override async flush(): Promise { + // An app's crash handler often flushes and exits before the end of the turn, when autocapture would capture. + this.errorTracking.capturePendingExceptions() return this._flushKeepingRuntimeAlive(false) } @@ -3195,7 +3197,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen uuid?: EventMessage['uuid'], flags?: FeatureFlagEvaluations ): void { - if (!ErrorTracking.isPreviouslyCapturedError(error)) { + if (!ErrorTracking.isPreviouslyCapturedError(error) && this.errorTracking.claimExplicitCapture(error)) { const syntheticException = new Error('PostHog syntheticException') this.addPendingPromise( ErrorTracking.buildEventMessage( @@ -3252,7 +3254,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen additionalProperties?: Record, flags?: FeatureFlagEvaluations ): Promise { - if (!ErrorTracking.isPreviouslyCapturedError(error)) { + if (!ErrorTracking.isPreviouslyCapturedError(error) && this.errorTracking.claimExplicitCapture(error)) { const syntheticException = new Error('PostHog syntheticException') return this.addPendingPromise( ErrorTracking.buildEventMessage( diff --git a/packages/node/src/extensions/error-tracking/autocapture.ts b/packages/node/src/extensions/error-tracking/autocapture.ts index fbfd4a287c..9006084d03 100644 --- a/packages/node/src/extensions/error-tracking/autocapture.ts +++ b/packages/node/src/extensions/error-tracking/autocapture.ts @@ -3,8 +3,11 @@ // Licensed under the MIT License: https://github.com/getsentry/sentry-javascript/blob/develop/LICENSE import { ErrorTracking as CoreErrorTracking } from '@posthog/core' +import type { ContextData } from '../context/types' -type ErrorHandler = { _posthogErrorHandler: boolean } & NodeJS.UncaughtExceptionListener +type PostHogListener = { _posthogErrorHandler: boolean } +type ErrorHandler = PostHogListener & NodeJS.UncaughtExceptionListener +type RejectionHandler = PostHogListener & NodeJS.UnhandledRejectionListener type UnhandledRejectionMode = 'throw' | 'strict' | 'warn' | 'warn-with-error-code' | 'none' const UNHANDLED_REJECTION_OPTION_NAMES = ['--unhandled-rejections', '--unhandled_rejections'] @@ -80,83 +83,602 @@ export function getUnhandledRejectionMode( const STARTUP_UNHANDLED_REJECTION_MODE = getUnhandledRejectionMode() +/** A client that captures exceptions through the shared process listeners. */ +export type AutocaptureClient = { + // Clients with the same project token and host send to the same project, so only one of them captures. + key: string + capture: (exception: unknown, hint: CoreErrorTracking.EventHint) => void + // Each client keeps its own request context, set with withContext. + getContext: () => ContextData | undefined + // Sends what the client holds, for a flush or shutdown of another client of the project. + flush: () => Promise + // Sends what the client still holds. Called before a fatal exit. + shutdown: () => Promise +} + +type ContextRunner = (run: () => T) => T + +type AsyncHooksModule = { AsyncLocalStorage?: { snapshot?: () => ContextRunner } } + +// The async context an exception was reported in: every AsyncLocalStorage, so each client's request context and the +// active OpenTelemetry span. A capture that a flush or shutdown elsewhere forces early still reports that request. +function snapshotAsyncContext(): ContextRunner { + try { + const process = globalThis.process as { getBuiltinModule?: (id: string) => unknown } | undefined + const asyncHooks = process?.getBuiltinModule?.('node:async_hooks') as AsyncHooksModule | undefined + const snapshot = asyncHooks?.AsyncLocalStorage?.snapshot?.() + if (snapshot) { + return snapshot + } + } catch { + // A runtime without AsyncLocalStorage.snapshot has nothing to restore. + } + return (run) => run() +} + +const UNHANDLED_REJECTION_HINT: CoreErrorTracking.EventHint = { + mechanism: { type: 'onunhandledrejection', handled: false }, +} +const UNCAUGHT_EXCEPTION_HINT: CoreErrorTracking.EventHint = { + mechanism: { type: 'onuncaughtexception', handled: false }, +} + +// Next.js and React pass these through rejections and throws as control flow. @sentry/nextjs drops the same set. +const CONTROL_FLOW_DIGESTS = new Set([ + 'HANGING_PROMISE_REJECTION', + 'NEXT_PRERENDER_INTERRUPTED', + 'DYNAMIC_SERVER_USAGE', + 'BAILOUT_TO_CLIENT_SIDE_RENDERING', +]) +const REACT_POSTPONE = Symbol.for('react.postpone') +const NEXT_REJECTION_FILTER = Symbol.for('next.unhandledRejectionFilter') + +// Next.js 16's patched process.on announces an unhandledRejection listener through this event instead. +const NEXT_LISTENER_EVENT = 'Next.UnhandledRejectionFilter.MacguffinEvent' + +// Shared through globalThis, so that every client and every copy of this module in the process (bundlers can load +// posthog-node more than once, for example in Next.js's separate server layers) use one set of process listeners. +// Bump the version in this key whenever the shape of what it holds changes. +const STATE_KEY = Symbol.for('posthog-node.exception-autocapture.v1') + +type CaptureSource = 'autocapture' | 'app' + +// A capture waiting for the end of the current turn of the event loop. +type DeferredCapture = { + exception: unknown + hint: CoreErrorTracking.EventHint + // The projects the app captured the exception for itself, which autocapture skips. + skipKeys: Set + runInContext: ContextRunner +} + +type AutocaptureState = { + mode: UnhandledRejectionMode + clients: Set + // The last client of each project that shut down. It keeps capturing until another client of the project starts, + // as the listeners of a shut-down client always did, so that a client per request still captures between requests. + retired: Map + // Who captured each exception for each project during this turn: autocapture, or the app through captureException. + // A host that rethrows a rejection, a runtime that reports one twice, and an app that captures an error and rethrows + // it all hand over the same exception again before the turn ends. Primitives compare by value. The same error object + // in a later turn is a new occurrence. + capturedThisTurn: Map> + // Captures waiting for the end of the turn, by exception. + deferred: Map + turnResetScheduled: boolean + calledFatalError: boolean + listeners: InstalledListeners +} + +type InstalledListeners = { + remove: () => void + // False once the app has removed them, for example with process.removeAllListeners(). + areInstalled: () => boolean +} + +type PostHogGlobal = typeof globalThis & { + [STATE_KEY]?: AutocaptureState + [NEXT_REJECTION_FILTER]?: boolean +} + +const posthogGlobal = globalThis as PostHogGlobal + +function isPostHogListener(listener: unknown): boolean { + return typeof listener === 'function' && (listener as Partial)._posthogErrorHandler === true +} + +function isWeakKey(value: unknown): value is object { + return (typeof value === 'object' && value !== null) || typeof value === 'function' +} + +function markListener unknown>(listener: T): T & PostHogListener { + return Object.assign(listener, { _posthogErrorHandler: true }) +} + +function hasHostUnhandledRejectionListener(process: NodeJS.Process): boolean { + return process.listeners('unhandledRejection').some((listener) => !isPostHogListener(listener)) +} + +function isFrameworkControlFlow(reason: unknown, depth = 0): boolean { + try { + if (!isWeakKey(reason)) { + return false + } + const candidate = reason as { $$typeof?: unknown; digest?: unknown; cause?: unknown } + if (candidate.$$typeof === REACT_POSTPONE) { + return true + } + if (typeof candidate.digest === 'string' && CONTROL_FLOW_DIGESTS.has(candidate.digest)) { + return true + } + return depth < 5 && isFrameworkControlFlow(candidate.cause, depth + 1) + } catch { + // A throwing getter or a revoked proxy is not a framework signal. + return false + } +} + +// An abort the app started itself and never awaited, for example a cancelled fetch. Sentry ignores these too. +function isAbortError(reason: unknown): boolean { + try { + return isWeakKey(reason) && (reason as { name?: unknown }).name === 'AbortError' + } catch { + return false + } +} + +function isIgnored(exception: unknown, origin: NodeJS.UncaughtExceptionOrigin | 'unhandledRejection'): boolean { + return isFrameworkControlFlow(exception) || (origin === 'unhandledRejection' && isAbortError(exception)) +} + +// One client per project: a running one if there is one, otherwise the last one that shut down. Among running +// clients, the one whose request context is active here, since no other client of the project can see it. +function captureTargets(state: AutocaptureState): AutocaptureClient[] { + const targets = new Map() + for (const client of state.clients) { + const chosen = targets.get(client.key) + if (!chosen || (chosen.getContext() === undefined && client.getContext() !== undefined)) { + targets.set(client.key, client) + } + } + for (const [key, client] of state.retired) { + if (!targets.has(key)) { + targets.set(key, client) + } + } + return [...targets.values()] +} + +// Runs after the microtasks the current callback queued. A runtime without setImmediate gets the next timer, which also +// runs after them: a throw from our listener would turn every rejection into a crash. Not unref'd: an unref'd immediate +// does not wake an idle event loop, which would stretch the turn until the next I/O or timer, and drop an occurrence +// that comes in the meantime. +function nextTurn(callback: () => void): void { + if (typeof globalThis.setImmediate === 'function') { + setImmediate(callback) + } else { + setTimeout(callback, 0) + } +} + +function scheduleTurnReset(state: AutocaptureState): void { + if (state.turnResetScheduled) { + return + } + state.turnResetScheduled = true + // Two immediates: a capture at the end of the turn runs in an immediate, and a host can rethrow the error from an + // immediate of its own (Next.js 16's filter does) or one more hop later. A real second occurrence of the same error + // object within that window counts once. + nextTurn(() => { + nextTurn(() => { + state.capturedThisTurn.clear() + state.turnResetScheduled = false + }) + }) +} + +function recordCapture(state: AutocaptureState, exception: unknown, key: string, source: CaptureSource): void { + const captured = state.capturedThisTurn.get(exception) ?? new Map() + if (!captured.has(key)) { + captured.set(key, source) + } + state.capturedThisTurn.set(exception, captured) + scheduleTurnReset(state) +} + +function wasCapturedThisTurn(state: AutocaptureState, exception: unknown): boolean { + const captured = state.capturedThisTurn.get(exception) + return captured !== undefined && captureTargets(state).every((client) => captured.has(client.key)) +} + +// Sends the exception to every project that has not received it in this turn. +function dispatch( + state: AutocaptureState, + exception: unknown, + hint: CoreErrorTracking.EventHint, + skipKeys?: Set +): AutocaptureClient[] { + const captured = state.capturedThisTurn.get(exception) + const owners: AutocaptureClient[] = [] + for (const client of captureTargets(state)) { + if (captured?.has(client.key) || skipKeys?.has(client.key)) { + continue + } + recordCapture(state, exception, client.key, 'autocapture') + client.capture(exception, hint) + owners.push(client) + } + return owners +} + +// Returns the clients that captured it. +function captureDeferred(state: AutocaptureState, deferred: DeferredCapture): AutocaptureClient[] { + if (state.deferred.get(deferred.exception) !== deferred) { + return [] + } + state.deferred.delete(deferred.exception) + return deferred.runInContext(() => dispatch(state, deferred.exception, deferred.hint, deferred.skipKeys)) +} + +// Used when the process survives the exception. The host's listeners run after ours, and a framework's error hook a +// microtask later, so capturing at the end of the turn lets the app's own capture win, with the identity and +// properties it passes. +function deferCapture(state: AutocaptureState, exception: unknown, hint: CoreErrorTracking.EventHint): void { + if (wasCapturedThisTurn(state, exception) || state.deferred.has(exception)) { + return + } + const deferred: DeferredCapture = { + exception, + hint, + skipKeys: new Set(state.capturedThisTurn.get(exception)?.keys()), + runInContext: snapshotAsyncContext(), + } + state.deferred.set(exception, deferred) + nextTurn(() => captureDeferred(state, deferred)) +} + +async function exitAfterFatalError(state: AutocaptureState, error: unknown): Promise { + try { + console.error(error) + } catch { + // An error whose inspection throws must not stop the exit. + } + // Captures waiting for the end of the turn would go down with the process. + capturePendingExceptions() + const clients = new Set([...state.clients, ...state.retired.values()]) + await Promise.all([...clients].map((client) => client.shutdown().catch(() => undefined))) + globalThis.process.exit(1) +} + function captureUncaughtException( - captureFn: (exception: Error, hint: CoreErrorTracking.EventHint) => void, - error: Error, - origin: NodeJS.UncaughtExceptionOrigin + state: AutocaptureState, + error: unknown, + origin: NodeJS.UncaughtExceptionOrigin, + fatal: boolean ): void { - captureFn(error, { - mechanism: { - type: origin === 'unhandledRejection' ? 'onunhandledrejection' : 'onuncaughtexception', - handled: false, - }, + const hint = origin === 'unhandledRejection' ? UNHANDLED_REJECTION_HINT : UNCAUGHT_EXCEPTION_HINT + if (!fatal) { + // The app handles uncaught exceptions itself, and its listener may capture this one. + if (!isIgnored(error, origin)) { + deferCapture(state, error, hint) + } + return + } + // A crash is captured whatever the error, an abort or a framework's control flow included. + // A host listener that rethrew a rejection: one occurrence, reported as the rejection it was. + const deferred = state.deferred.get(error) + if (deferred) { + captureDeferred(state, deferred) + } else { + dispatch(state, error, hint) + } +} + +function makeUncaughtExceptionHandler(state: AutocaptureState): ErrorHandler { + return markListener((error: Error, origin: NodeJS.UncaughtExceptionOrigin): void => { + const process = globalThis.process + // Like Sentry, we only consider listeners that are currently registered. We intentionally do not reconstruct + // participation by once listeners or listeners added or removed during dispatch. + const userProvidedListenersCount = process.listeners('uncaughtException').filter((listener) => { + return listener.name !== 'domainUncaughtExceptionClear' && !isPostHogListener(listener) + }).length + const fatal = userProvidedListenersCount === 0 + + captureUncaughtException(state, error, origin, fatal) + + if (fatal && !state.calledFatalError) { + state.calledFatalError = true + void exitAfterFatalError(state, error) + } }) } -function makeUncaughtExceptionHandler( - captureFn: (exception: Error, hint: CoreErrorTracking.EventHint) => void, - onFatalFn: (exception: Error) => void -): ErrorHandler { - let calledFatalError: boolean = false - - return Object.assign( - (error: Error, origin: NodeJS.UncaughtExceptionOrigin): void => { - // Like Sentry, we only consider listeners that are currently registered. We intentionally do not reconstruct - // participation by once listeners or listeners added or removed during dispatch. - const userProvidedListenersCount = global.process.listeners('uncaughtException').filter((listener) => { - return ( - listener.name !== 'domainUncaughtExceptionClear' && - (listener as Partial)._posthogErrorHandler !== true - ) - }).length - - captureUncaughtException(captureFn, error, origin) - - if (!calledFatalError && userProvidedListenersCount === 0) { - calledFatalError = true - onFatalFn(error) - } - }, - { _posthogErrorHandler: true } +// String() throws for a null-prototype object or a revoked proxy. +function describeReason(reason: unknown): string { + try { + return String(reason) + } catch { + return typeof reason + } +} + +// The error Node raises in throw mode for a rejection whose reason is not an Error. +function toUnhandledRejectionError(reason: unknown): Error { + if (reason instanceof Error) { + return reason + } + const error = new Error( + `This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason "${describeReason(reason)}".` ) + return Object.assign(error, { name: 'UnhandledPromiseRejection', code: 'ERR_UNHANDLED_REJECTION' }) } -export function addUncaughtExceptionListener( - captureFn: (exception: Error, hint: CoreErrorTracking.EventHint) => void, - onFatalFn: (exception: Error) => void, - mode: UnhandledRejectionMode = STARTUP_UNHANDLED_REJECTION_MODE -): void { +// No host listener is left, but ours still runs: the host's listener went away without a removeListener event (Bun +// never emits one), or the runtime does not escalate rejections at all (Bun again). Our listener has stopped the +// runtime's default handling, so do what the runtime would have done without it. +function applyDefaultRejectionHandling(state: AutocaptureState, reason: unknown): void { const process = globalThis.process - if (!process) { + if (state.mode === 'warn-with-error-code') { + process.emitWarning(`Unhandled promise rejection: ${describeReason(reason)}`, 'UnhandledPromiseRejectionWarning') + process.exitCode = 1 + if (!isIgnored(reason, 'unhandledRejection')) { + dispatch(state, reason, UNHANDLED_REJECTION_HINT) + } return } - if (mode === 'strict') { - process.on('uncaughtExceptionMonitor', (error, origin) => captureUncaughtException(captureFn, error, origin)) + if (process.versions?.bun) { + // Bun exits on an unhandled rejection without emitting uncaughtException. When nothing else is pending, it also + // ends the process right after this listener, without running anything the listener started, so set the exit code + // now. A crash is captured whatever the error. + process.exitCode = 1 + dispatch(state, reason, UNHANDLED_REJECTION_HINT) + if (!state.calledFatalError) { + state.calledFatalError = true + void exitAfterFatalError(state, reason) + } return } - process.on('uncaughtException', makeUncaughtExceptionHandler(captureFn, onFatalFn)) + // Node escalates the rejection to the uncaught-exception listeners, with the origin 'unhandledRejection'. Ours is one + // of them: it captures the rejection, and exits unless the app handles uncaught exceptions itself. + const escalated = toUnhandledRejectionError(reason) + const emit = process.emit as (event: string, ...args: unknown[]) => boolean + emit.call(process, 'uncaughtExceptionMonitor', escalated, 'unhandledRejection') + emit.call(process, 'uncaughtException', escalated, 'unhandledRejection') } -export function addUnhandledRejectionListener( - captureFn: (exception: unknown, hint: CoreErrorTracking.EventHint) => void, - mode: UnhandledRejectionMode = STARTUP_UNHANDLED_REJECTION_MODE -): void { - const process = globalThis.process +// replacesDefaultHandling: in throw and warn-with-error-code modes, an unhandledRejection listener replaces the +// runtime's default handling, so ours must reproduce it when it is the only one left. +function makeUnhandledRejectionHandler( + state: AutocaptureState, + replacesDefaultHandling: boolean, + isInstalled: () => boolean +): RejectionHandler { + return markListener((reason: unknown): void => { + // Our listener runs first, so a host listener that is gone now was not there when the rejection was emitted, unless + // ours went with it: a once listener, or one that removes itself, takes ours along through the removeListener + // handler, and the emit still calls ours from its copy of the list. + if (replacesDefaultHandling && !hasHostUnhandledRejectionListener(globalThis.process) && isInstalled()) { + applyDefaultRejectionHandling(state, reason) + return + } + if (!isIgnored(reason, 'unhandledRejection')) { + deferCapture(state, reason, UNHANDLED_REJECTION_HINT) + } + }) +} - // Installing an unhandledRejection listener changes Node's behavior in these modes. We intentionally skip capture - // rather than reproducing Node's fatal handling or warn-with-error-code warning and exit-code semantics in the SDK. - if (!process || mode === 'throw' || mode === 'strict' || mode === 'warn-with-error-code') { +// Next.js 16 moves every unhandledRejection listener into a queue behind one filtering listener, and patches +// process.on and process.listeners so that later listeners join that queue too. The filter drops rejections from +// prerenders Next has aborted on purpose. A listener left on the process in front of the filter would capture those, +// and one in both places would capture everything twice, so move ours into the queue once the filter is in place. +function joinHostRejectionQueue(listener: RejectionHandler): void { + const process = globalThis.process + if (!posthogGlobal[NEXT_REJECTION_FILTER] || typeof process.rawListeners !== 'function') { + return + } + if (!process.rawListeners('unhandledRejection').includes(listener)) { return } + // Next patches the methods on the process object itself, so the prototype's removeListener still reaches the + // process's real listener list. + const removeListener = Object.getPrototypeOf(process).removeListener as NodeJS.Process['removeListener'] + removeListener.call(process, 'unhandledRejection', listener) + if (!process.listeners('unhandledRejection').includes(listener)) { + process.prependListener('unhandledRejection', listener) + } +} - process.on('unhandledRejection', (reason: unknown) => { - captureFn(reason, { - mechanism: { - type: 'onunhandledrejection', - handled: false, - }, +function installListeners(state: AutocaptureState): InstalledListeners { + const process = globalThis.process + const removals: (() => void)[] = [] + const listen = (event: string, listener: (...args: any[]) => void): void => { + process.on(event, listener) + removals.push(() => process.removeListener(event, listener)) + } + const remove = (): void => removals.forEach((removal) => removal()) + + if (state.mode === 'strict') { + // Strict mode raises the rejection as an uncaught exception before any unhandledRejection listener runs, and the + // process exits right after. + const monitor = (error: Error, origin: NodeJS.UncaughtExceptionOrigin): void => + captureUncaughtException(state, error, origin, true) + listen('uncaughtExceptionMonitor', monitor) + return { remove, areInstalled: () => process.listeners('uncaughtExceptionMonitor').includes(monitor) } + } + + const uncaughtExceptionHandler = makeUncaughtExceptionHandler(state) + listen('uncaughtException', uncaughtExceptionHandler) + + // In throw and warn-with-error-code modes, any unhandledRejection listener stops Node's default handling (the + // escalation to uncaughtException, or the warning and exit code). We listen only while the host has a listener of + // its own, which has already stopped that handling, so the SDK never changes what Node does with the rejection. + // Bun never escalates a rejection, so there we always listen and reproduce its exit when no host listener is left. + const replacesDefaultHandling = state.mode === 'throw' || state.mode === 'warn-with-error-code' + const followsHostListeners = replacesDefaultHandling && !process.versions?.bun + const isInstalled = (): boolean => process.listeners('unhandledRejection').includes(listener) + const listener = makeUnhandledRejectionHandler(state, replacesDefaultHandling, isInstalled) + + // Ours runs before the host's listeners, so that it sees them as they were when the rejection was emitted. + const keepFirst = (): void => { + const raw = typeof process.rawListeners === 'function' ? process.rawListeners('unhandledRejection') : [] + if (raw.includes(listener) && raw[0] !== listener) { + process.removeListener('unhandledRejection', listener) + process.prependListener('unhandledRejection', listener) + } + } + + // newListener is emitted before the host listener is added, so ours is in place for the next rejection. Adding ours + // back here also recovers from a host that cleared every listener with removeAllListeners. + listen( + 'newListener', + markListener((event: string | symbol, added: unknown) => { + if (isPostHogListener(added)) { + return + } + if (event === 'unhandledRejection') { + if (!isInstalled()) { + process.prependListener('unhandledRejection', listener) + } + queueMicrotask(() => { + keepFirst() + joinHostRejectionQueue(listener) + }) + } else if (event === NEXT_LISTENER_EVENT) { + // Once Next's process.on has queued the host listener, ours joins the same queue if it is not there. + queueMicrotask(() => { + if (!isInstalled()) { + process.prependListener('unhandledRejection', listener) + } + }) + } }) + ) + + if (!followsHostListeners) { + process.prependListener('unhandledRejection', listener) + } else { + // removeListener is emitted after the removal. Without a host listener, ours would stop Node's default handling. + listen( + 'removeListener', + markListener((event: string | symbol) => { + if (event === 'unhandledRejection' && isInstalled() && !hasHostUnhandledRejectionListener(process)) { + process.removeListener('unhandledRejection', listener) + } + }) + ) + if (hasHostUnhandledRejectionListener(process)) { + process.prependListener('unhandledRejection', listener) + } + } + joinHostRejectionQueue(listener) + removals.push(() => { + process.removeListener('unhandledRejection', listener) + if (typeof process.rawListeners === 'function' && process.rawListeners('unhandledRejection').includes(listener)) { + const removeListener = Object.getPrototypeOf(process).removeListener as NodeJS.Process['removeListener'] + removeListener.call(process, 'unhandledRejection', listener) + } }) + return { + remove, + // In warn and none modes, and on Bun, the rejection listener is always installed. + areInstalled: () => + process.listeners('uncaughtException').includes(uncaughtExceptionHandler) && + (followsHostListeners || isInstalled()), + } +} + +/** + * Captures uncaught exceptions and unhandled rejections for a client, through one set of process listeners shared by + * every client in the process. Returns a function the client calls on shutdown. The listeners stay for the life of + * the process, as they always have, and the last client of a project keeps capturing after it shuts down until + * another client of the project starts. + */ +export function startExceptionAutocapture( + client: AutocaptureClient, + mode: UnhandledRejectionMode = STARTUP_UNHANDLED_REJECTION_MODE +): () => void { + if (!globalThis.process?.on) { + return () => undefined + } + + let state = posthogGlobal[STATE_KEY] + if (!state) { + const created: AutocaptureState = { + mode, + clients: new Set(), + retired: new Map(), + capturedThisTurn: new Map(), + deferred: new Map(), + turnResetScheduled: false, + calledFatalError: false, + listeners: { remove: () => undefined, areInstalled: () => true }, + } + created.listeners = installListeners(created) + posthogGlobal[STATE_KEY] = created + state = created + } else if (!state.listeners.areInstalled()) { + // The app removed them, for example with process.removeAllListeners(). A new client always added its own. + state.listeners.remove() + state.listeners = installListeners(state) + } + state.clients.add(client) + state.retired.delete(client.key) + + const activeState = state + return () => { + if (!activeState.clients.delete(client)) { + return + } + if (![...activeState.clients].some((other) => other.key === client.key)) { + activeState.retired.set(client.key, client) + } + } +} + +/** + * Records an error passed to captureException, and reports whether to capture it: false when autocapture already sent + * this error to the project during this turn. Otherwise the app's capture wins, and autocapture skips the error for the + * project for the rest of the turn. The same occurrence comes both ways when a framework hands an error from its own + * process listener to an error hook that calls captureException (Nitro's error hook, which @posthog/nuxt captures), + * and when an app captures an error and rethrows it. A capture of the same error object in a later turn is a new + * occurrence. + */ +export function claimExplicitCapture(error: unknown, key: string): boolean { + const state = posthogGlobal[STATE_KEY] + if (!state) { + return true + } + if (state.capturedThisTurn.get(error)?.get(key) === 'autocapture') { + return false + } + recordCapture(state, error, key, 'app') + state.deferred.get(error)?.skipKeys.add(key) + return true +} + +/** + * Captures at once the exceptions waiting for the end of the turn. A flush or shutdown calls this first: an app's + * crash handler often flushes or shuts the client down and exits before the turn ends. + */ +export function capturePendingExceptions(): Set { + const owners = new Set() + const state = posthogGlobal[STATE_KEY] + if (state) { + for (const deferred of [...state.deferred.values()]) { + captureDeferred(state, deferred).forEach((owner) => owners.add(owner)) + } + } + return owners +} + +/** @internal Removes the shared listeners and forgets every client, for tests. */ +export function resetExceptionAutocapture(): void { + const state = posthogGlobal[STATE_KEY] + if (state) { + state.deferred.clear() + state.listeners.remove() + delete posthogGlobal[STATE_KEY] + } } diff --git a/packages/node/src/extensions/error-tracking/index.ts b/packages/node/src/extensions/error-tracking/index.ts index 4ff558f725..467f1a7ecb 100644 --- a/packages/node/src/extensions/error-tracking/index.ts +++ b/packages/node/src/extensions/error-tracking/index.ts @@ -1,4 +1,5 @@ -import { addUncaughtExceptionListener, addUnhandledRejectionListener } from './autocapture' +import { capturePendingExceptions, claimExplicitCapture, startExceptionAutocapture } from './autocapture' +import type { AutocaptureClient } from './autocapture' import { PostHogBackendClient } from '@/client' import type { ContextData } from '../context/types' import { snapshotEventContext } from '../context/event-context' @@ -15,6 +16,8 @@ export default class ErrorTracking { private _exceptionAutocaptureEnabled: boolean private _rateLimiter: BucketedRateLimiter private _logger: Logger + private _autocaptureClient?: AutocaptureClient + private _stopAutocapture?: () => void constructor(client: PostHogBackendClient, options: PostHogOptions, _logger: Logger) { this.client = client @@ -76,10 +79,44 @@ export default class ErrorTracking { }) } + // Clients with the same project token and host send to the same project. + private get destinationKey(): string { + return `${this.client.apiKey}@${this.client.host}` + } + private startAutocaptureIfEnabled(): void { if (this.isEnabled()) { - addUncaughtExceptionListener(this.onException.bind(this), this.onFatalError.bind(this)) - addUnhandledRejectionListener(this.onException.bind(this)) + this._autocaptureClient = { + key: this.destinationKey, + capture: (exception, hint) => this.onException(exception, hint), + getContext: () => this.client.getContext(), + flush: () => this.client.flush(), + shutdown: () => this.client.shutdown(SHUTDOWN_TIMEOUT), + } + this._stopAutocapture = startExceptionAutocapture(this._autocaptureClient) + } + } + + /** + * Whether captureException should capture this error. False when exception autocapture already sent the same error + * object to this project in this turn of the event loop, for example when a framework passes an error from its own + * process listener to an error hook that calls captureException. + */ + claimExplicitCapture(error: unknown): boolean { + return claimExplicitCapture(error, this.destinationKey) + } + + /** + * Captures at once the exceptions autocapture holds until the end of the turn, so that this client's flush or + * shutdown sends them: an app's crash handler often flushes or shuts the client down and exits before the turn ends. + * The capture can land in another client of the project, the one whose request context is active, so this client + * waits for that client's flush too. + */ + capturePendingExceptions(): void { + for (const owner of capturePendingExceptions()) { + if (owner !== this._autocaptureClient) { + void this.client.addPendingPromise(owner.flush().catch(() => undefined)) + } } } @@ -106,21 +143,22 @@ export default class ErrorTracking { } return this.client._capturePreparedEvent(eventMessage, false) } - })() + })().catch((error) => { + // shutdown() waits for every pending promise and stops before it flushes if one rejects, so an error that + // cannot be turned into an event would take the others down with it. + this._logger.error('Failed to capture an exception:', error) + }) ) } - private async onFatalError(exception: Error): Promise { - console.error(exception) - await this.client.shutdown(SHUTDOWN_TIMEOUT) - globalThis.process.exit(1) - } - isEnabled(): boolean { return !this.client.isDisabled && this._exceptionAutocaptureEnabled } shutdown(): void { this._rateLimiter.stop() + this.capturePendingExceptions() + this._stopAutocapture?.() + this._stopAutocapture = undefined } }