Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/node-host-rejection-listener.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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'))
}
Loading
Loading