Skip to content

fix(node): capture unhandled rejections when the host has its own listener - #5472

Open
ablaszkiewicz wants to merge 3 commits into
mainfrom
mfga/node-unhandled-rejection-host-listener
Open

ablaszkiewicz wants to merge 3 commits into
mainfrom
mfga/node-unhandled-rejection-host-listener

Conversation

@ablaszkiewicz

@ablaszkiewicz ablaszkiewicz commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Pull requests: 1. 👉 this PR 2. #5473 3. PostHog/context-mill#446 4. PostHog/wizard-workbench#4805 5. PostHog/posthog#125314

Problem

posthog-node's exception autocapture never captured a fire-and-forget promise rejection under Next.js, Nitro (Nuxt) or PM2. In Node's default throw mode the SDK relies on Node escalating the rejection to uncaughtException. A host with its own unhandledRejection listener stops that escalation.

Changes

  • Host listeners: in throw and warn-with-error-code modes, listen only while the host has a listener of its own, so Node's handling never changes. If the host's listener disappears without an event (Bun), reproduce what the runtime would have done.
  • One listener set per process: captures go once to each project, however many clients or bundled copies of the SDK there are.
  • One event per occurrence: a rethrow, a double delivery, or captureException from a framework hook (Nitro) counts once.
  • 1 s grace period, cancelled by rejectionHandled: late awaits aren't reported. flush(), waitUntil and shutdown() still send pending rejections. A deferred capture runs in the async context the rejection happened in, so it keeps that request's context and OpenTelemetry span.
  • Skipped: React postpones, Next.js prerender digests and AbortError, the same set Sentry drops.

How did you test this code?

  • posthog-node: 1359 tests pass, 75 of them in the autocapture spec (child-process tests on real Node among them). Typecheck, lint, edge-compat and oxfmt pass.
  • @posthog/nuxt: the unit suite passes against this build, including the Nuxt 5 + Nitro 3 consumer test.
  • End to end: examples in PostHog/error-tracking-examples (node-unhandled-rejection, next1{4,5,6}-rejection, nuxt-rejection). Each runs without the SDK, with posthog-node 5.59.0 and with this PR.
  • Review: two independent passes. Each confirmed finding is fixed and covered, except the slow-hook limit below.
Environment Setups Result
Node 20.20.2, 22.23.4, 24.15.0, 26.13.0 29 scenarios in all 5 --unhandled-rejections modes Same on every version; exit code always as without the SDK
Bun 1.4.2 29 scenarios Exit code always as without the SDK
Next.js 14.2.35, 15.5.28, 16.4.3 (+ Cache Components) next start and custom server, each with and without PM2 One event per error
Nuxt 4.6.3 + @posthog/nuxt 1.9.0 Nitro node-server One event per error

The last three columns count PostHog events, except where a cell says otherwise.

Scenario No SDK 5.59.0 This PR
Fire-and-forget rejection under a host listener (Next, Nitro, PM2) logged 0 1
No host listener, any mode — as Node as Node
Host listener added late, removed, once, self-removing, rethrowing — 0–1 1, exit as Node
Rejection handled within 1 s (late await) — 0 0
React postpone, Next.js prerender digest, AbortError — 0 0
Next.js 16 custom server under PM2 logged 0 1
Nuxt: rejection, uncaught exception logged 1, 2 1, 1
App captures an error, then rethrows it crash 2 1
12 clients for one project — 0 1
Bun: no host listener crash 0 1, crash as Bun

Known limits:

  • Unchanged from before: a host that calls process.exit() in its listener, and strict mode, exit before any capture.
  • Slow framework hooks: a hook that passes the error to captureException more than 1 s after a rejection, or a few ms after an uncaught exception, still adds a second event.

Release info Sub-libraries affected

Libraries affected

  • posthog-node

Checklist

  • All commits are signed (see Commit signing)
  • Tests for new code
  • Accounted for the impact of any changes across different platforms
  • Accounted for backwards compatibility of any changes (no breaking changes!)
  • Took care not to unnecessarily increase the bundle size

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Origin: Claude Code (Opus 5.5) found the gap while testing the wizard's Next.js error tracking end to end. It wrote the fix and the tests, borrowing from Sentry's Node SDK.
  • Not borrowed from Sentry:
    • always listening, because Sentry's default mode stops Node's crash;
    • dropping any event identical to the one before, because it loses real repeats.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FkuJAAYPkxG7cuvaUbEGCf

…tener

In Node's default throw mode (and warn-with-error-code), posthog-node did
not listen for unhandledRejection and relied on Node escalating the
rejection to uncaughtException. A host with its own unhandledRejection
listener, such as Next.js, stops that escalation, so the rejection was
never captured.

The SDK now listens for unhandledRejection only while another listener
exists. Such a listener has already stopped Node's default handling, so
adding ours changes nothing. It tracks hosts that add or remove their
listener after init through newListener and removeListener. A rejection
captured this way and then rethrown by the host listener is not captured
again by the uncaught-exception handler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkuJAD3n8f6Ra7L9p2UmPw

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 6907893: 1 Should fix.

This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.

See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.

Comment thread packages/node/src/extensions/error-tracking/autocapture.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Coherence

0 spec problems · 0 failing chokepoints · 2 spec gaps · 0 guard failures

44 components, 7 bullets (5 invariants, 2 requirements), 0 problems

Chokepoints: none declared.

Spec gaps: 2 entrances with outside or unknown trust and no traced control, and 10 more held by the adoption baseline

  • packages/web/Web.spec.md entrance public api (trust host-app): packages/web
  • packages/webpack-plugin/Webpack-plugin.spec.md entrance webpack plugin (trust build): packages/webpack-plugin

Close one with guard: <chokepoint>, an invariant that names it on its entrances: line, or control: none — <reason>; scaffold control proposes it.

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Node SDK acceptance v2

Commit: 323c622
Workflow run and artifacts

Capture v0

✅ 55 passed / ❌ 1 non-passing / ⚠️ 101 not selected

Drill-down: run artifacts → node-v2-v0 → acceptance/report.json.diagnostics.json (match case_id below).

❌ migration/yaml-parity-v1/remote-flags-v1.feature::Omitted GeoIP settings default to enabled in flag requests — failed_assertion

Flags field differs

Code: flag_request_field

Failed step: migration/yaml-parity-v1/remote-flags-v1.feature:134

the first flags request field "geoip_disable" should equal JSON false

Operation: /flags/

Field: geoip_disable

Expected:

false

Actual value:

true

Harness exit: 1; report validation exit: 1.

Capture v1

✅ 120 passed / ❌ 1 non-passing / ⚠️ 36 not selected

Drill-down: run artifacts → node-v2-v1 → acceptance/report.json.diagnostics.json (match case_id below).

❌ migration/yaml-parity-v1/remote-flags-v1.feature::Omitted GeoIP settings default to enabled in flag requests — failed_assertion

Flags field differs

Code: flag_request_field

Failed step: migration/yaml-parity-v1/remote-flags-v1.feature:134

the first flags request field "geoip_disable" should equal JSON false

Operation: /flags/

Field: geoip_disable

Expected:

false

Actual value:

true

Harness exit: 1; report validation exit: 1.

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Error tracking smoke

Commit: 323c622d7bf42f2de947cc196aa958438fae99a8 · CI run and artifacts

7/8 passed (0 skipped, 0 uncovered)

Fixture Result First failure
rollup-4 ✅ Passed —
vite-8-autocapture ✅ Passed —
webpack-5.112 ✅ Passed —
next-14 ✅ Passed —
next-15 ✅ Passed —
next-16-webpack ✅ Passed —
next-16-turbopack ❌ Failed release 1.0.0: maps dangling (none), expected deleted
nuxt-4 ✅ Passed —

… host-handled rejections

Shares one set of process listeners across clients and bundled copies, with one capture per project. A rethrow, a
double delivery or a captureException from a framework hook counts once. Rejections wait a one-second grace period,
cancelled by rejectionHandled, and are sent on flush(), waitUntil, shutdown(), a fatal exit and beforeExit. React
and Next.js control-flow errors and AbortError rejections are skipped. Our listener is prepended so it sees the host's
listeners, and reproduces the runtime's handling when the host's listener disappears without an event (Bun).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkuJAAYPkxG7cuvaUbEGCf
@ablaszkiewicz

Copy link
Copy Markdown
Contributor Author

@PostHog review --deep

@posthog

posthog Bot commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

@ablaszkiewicz PostHog Review is reviewing this pull request.

@posthog

posthog Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

PostHog Review · Deep · reviewed 296a4b8

Posted 7 findings: 1 Must fix, 3 Should fix, 3 Consider.

Step Status Result
Prepare the diff done 4 files, 1 chunk
Pick perspectives done 3 perspectives
Run review passes done 11 issues (+1 blind-spot)
Merge overlapping findings done 12 → 12
Validate findings done 7 kept, 5 dismissed
Publish done 7 posted (view review)

@ablaszkiewicz
ablaszkiewicz marked this pull request as ready for review October 29, 2026 14:52
@ablaszkiewicz
ablaszkiewicz requested a review from a team as a code owner October 29, 2026 14:52
@ablaszkiewicz
ablaszkiewicz requested review from a team, JamesPatrickGill and hpouillot and removed request for a team October 29, 2026 14:52
Comment thread packages/node/src/extensions/error-tracking/index.ts Outdated
Comment thread packages/node/src/extensions/error-tracking/autocapture.ts
Comment thread packages/node/src/client.ts Outdated
Comment thread packages/node/src/extensions/error-tracking/autocapture.ts Outdated
Comment thread packages/node/src/extensions/error-tracking/autocapture.ts Outdated
Comment thread packages/node/src/extensions/error-tracking/autocapture.ts Outdated
Comment thread packages/node/src/extensions/error-tracking/autocapture.ts Outdated
…, and send what a flush owes

Addresses the review of the host-handled rejection capture:
- A deferred capture runs in a snapshot of the async context the rejection happened in, so it reports that
  request's context and OpenTelemetry span, or none, never those of the code that triggered the capture.
- Among clients of one project, the one whose request context is active captures.
- captureExceptionImmediate on an occurrence autocapture already captured sends that event before resolving.
- A flush also sends what another client of the project captured for the rejections it waited for.
- An explicit capture just before a rejection carries into the pending rejection.
- One waitUntil keep-alive per client covers every pending rejection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkuJAAYPkxG7cuvaUbEGCf

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant