Skip to content

Report unhandled JS errors that don't terminate the process as unhandled sessions (not crashed) #6659

Description

@antonis

Summary

Adopt the native non-terminating session APIs so an unhandled fatal JS error that does not terminate the process is reported with session status unhandled instead of crashed. In practice this is the case where a mounted GlobalErrorBoundary catches a fatal and keeps the app running. Genuine terminating crashes stay crashed, unchanged.

Targeted at the next RN SDK major — not because it is an API/ABI break (it isn't), but because it shifts a visible Release Health metric (crash-free session rate). See Release classification below.

Spec basis

Per the sessions spec:

  • crashed — "session terminated in a crash (process terminated)"
  • unhandled (spec v1.6.0) — "an unhandled error occurred but the process did not terminate", e.g. "the language or framework prevented termination"

GlobalErrorBoundary is exactly that framework mechanism, so a survived fatal is an unhandled session by definition. Reporting it as crashed is a spec deviation. (sentry-conventions is span/attribute naming only — neutral here.)

Current RN behavior & the confirmed over-report

RN has no dedicated session API over the bridge; a hard crash is signaled by the hardCrashed boolean on captureEnvelope:

  • hardCrashed is set only for mechanism { handled: false, type: 'onerror' }packages/core/src/js/misc.ts:11, computed in packages/core/src/js/wrapper.ts:196-235.
  • Android maps it directly: InternalSentrySdk.captureEnvelope(bytes, !hardCrashed)RNSentryModuleImpl.java:500-511. When hardCrashed, maybeStartNewSession=false and the current session ends as crashed.
  • iOS: hardCrashedstore (persist for next launch), else captureRNSentry.mm:624-646.

reactnativeerrorhandlers.ts sets { handled: false, type: 'onerror' } for all isFatal errors. But when a GlobalErrorBoundary fallback is mounted, the handler deliberately skips defaultHandler (reactnativeerrorhandlers.ts:242-244) so the app survives — yet the session is still reported crashed. That is the over-report, and it is a gap the SDK created itself by shipping GlobalErrorBoundary.

Unhandled promise rejections are already marked handled: true and are unaffected.

Native APIs (available now, both platforms bundled)

  • Android — sentry-java 8.55.0 (#5921), merged via chore(deps): update Android SDK to v8.55.0 #6658: InternalSentrySdk.captureEnvelopeNonTerminating(byte[]), Session.State.Unhandled.
  • iOS — sentry-cocoa 9.27.0 (already pinned in RNSentry.podspec): SentrySDK.internal.envelope.captureNonTerminating(_:).
  • Both: keep the same session id, bump the error count, mark the session unhandled, and a later crash or abnormal exit still takes precedence over unhandled (built-in safety net). Never also call updateSessionForDroppedEventNonTerminating for the same event (double-count).

Flutter precedent — and why RN diverges

Flutter is adopting the same native APIs as a stacked series (fixes sentry-dart#3300), all opened 2026-09-07:

  • #4006 — bump native SDKs (Android 8.55.0 + Cocoa 9.27.0).
  • #4007 feat — capture path via captureEnvelopeNonTerminating / captureNonTerminating.
  • #4008 fix — sampling path via updateSessionForDroppedEventNonTerminating for sampled-out unhandled events.

Both Flutter PRs are marked "No breaking changes"; #3300 is labeled Improvement. The work is grouped into Flutter's v10 for release-timing reasons (a visible metric change on a major boundary), not because the code breaks compatibility.

Key divergence: Flutter pushes the decision fully into native by inspecting mechanism.handled == false, because in Flutter an unhandled framework error never terminates the process. RN is different — an unhandled onerror fatal does terminate (RCTFatal) unless a GlobalErrorBoundary catches it. So RN must not copy Flutter's native-decides model (it would mislabel real JS crashes as unhandled and lose crash reporting). RN keeps the survival signal in JS and carries it across the bridge.

Proposed design

# Module Change
M1 integrations/reactnativeerrorhandlers.ts Owns the survival decision. When isFatal and an interested GlobalErrorBoundary subscriber is present (the path where defaultHandler is skipped), annotate the event's exception mechanism as non-terminating. Every other fatal stays crashed.
M2 misc.ts Reshape isHardCrash(payload)getSessionDisposition(payload): 'crashed' | 'unhandled' | 'ok', read from the serialized envelope payload (the only channel to the wrapper).
M3 NativeRNSentry.ts + wrapper.ts Additive nonTerminating: boolean on captureEnvelope options, beside hardCrashed (mutually exclusive). Additive key on an untyped Object param → backward-compatible ABI; an older cached native binary ignores it and degrades to a normal capture.
M4 RNSentryModuleImpl.java nonTerminatingInternalSentrySdk.captureEnvelopeNonTerminating(bytes); else the existing path. Runs off-main (@ReactMethod), safe for the API's synchronous persist.
M5 RNSentry.mm + RNSentryInternal.swift Add RNSentryInternal.captureNonTerminating(_:)SentrySDK.internal.envelope.captureNonTerminating(envelope); branch on nonTerminating before the store/capture fork.

Phasing

Release classification

  • Not an API/ABI break. No public JS API change; the bridge change is additive and backward-compatible.
  • It is a Release Health behavior shiftGlobalErrorBoundary-survived errors move from crashedunhandled, raising crash-free session rate for affected apps. Ship in the next major, with a prominent CHANGELOG entry and a docs note so users understand the metric change.

Open risk

M1's survival check is re-evaluated post-flush, but the envelope is captured pre-flush → the disposition is a best-effort prediction. Worst case is a rare crashedunhandled mislabel, self-corrected by the "later crash takes precedence" rule. Never a host-app crash.

Follow-up from the 8.55.0 bump (#6658).

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions