You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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: hardCrashed → store (persist for next launch), else capture — RNSentry.mm:624-646.
reactnativeerrorhandlers.ts sets { handled: false, type: 'onerror' } for allisFatal 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)
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:
#4007feat — capture path via captureEnvelopeNonTerminating / captureNonTerminating.
#4008fix — 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 isFataland 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
AdditivenonTerminating: 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
nonTerminating → InternalSentrySdk.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.
Phase 2 — sampling path (follow-up): unhandled events dropped by sampleRate never reach captureEnvelope, so their session would finalize exited instead of unhandled. Needs a JS-core sampled-out hook + a new bridge method updateSessionForDroppedEventNonTerminating, with a no-double-count guard. Mirrors Flutter chore(deps): bump rexml from 3.2.6 to 3.3.3 in /samples/react-native #4008 — hold until that design settles.
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 shift — GlobalErrorBoundary-survived errors move from crashed → unhandled, 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 crashed↔unhandled mislabel, self-corrected by the "later crash takes precedence" rule. Never a host-app crash.
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
unhandledinstead ofcrashed. In practice this is the case where a mountedGlobalErrorBoundarycatches a fatal and keeps the app running. Genuine terminating crashes staycrashed, 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"GlobalErrorBoundaryis exactly that framework mechanism, so a survived fatal is anunhandledsession by definition. Reporting it ascrashedis a spec deviation. (sentry-conventionsis 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
hardCrashedboolean oncaptureEnvelope:hardCrashedis set only for mechanism{ handled: false, type: 'onerror' }—packages/core/src/js/misc.ts:11, computed inpackages/core/src/js/wrapper.ts:196-235.InternalSentrySdk.captureEnvelope(bytes, !hardCrashed)—RNSentryModuleImpl.java:500-511. WhenhardCrashed,maybeStartNewSession=falseand the current session ends ascrashed.hardCrashed→store(persist for next launch), elsecapture—RNSentry.mm:624-646.reactnativeerrorhandlers.tssets{ handled: false, type: 'onerror' }for allisFatalerrors. But when aGlobalErrorBoundaryfallback is mounted, the handler deliberately skipsdefaultHandler(reactnativeerrorhandlers.ts:242-244) so the app survives — yet the session is still reportedcrashed. That is the over-report, and it is a gap the SDK created itself by shippingGlobalErrorBoundary.Unhandled promise rejections are already marked
handled: trueand are unaffected.Native APIs (available now, both platforms bundled)
InternalSentrySdk.captureEnvelopeNonTerminating(byte[]),Session.State.Unhandled.RNSentry.podspec):SentrySDK.internal.envelope.captureNonTerminating(_:).unhandled, and a later crash or abnormal exit still takes precedence overunhandled(built-in safety net). Never also callupdateSessionForDroppedEventNonTerminatingfor 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:
feat— capture path viacaptureEnvelopeNonTerminating/captureNonTerminating.fix— sampling path viaupdateSessionForDroppedEventNonTerminatingfor 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 unhandledonerrorfatal does terminate (RCTFatal) unless aGlobalErrorBoundarycatches it. So RN must not copy Flutter's native-decides model (it would mislabel real JS crashes asunhandledand lose crash reporting). RN keeps the survival signal in JS and carries it across the bridge.Proposed design
integrations/reactnativeerrorhandlers.tsisFataland an interestedGlobalErrorBoundarysubscriber is present (the path wheredefaultHandleris skipped), annotate the event's exception mechanism as non-terminating. Every other fatal stayscrashed.misc.tsisHardCrash(payload)→getSessionDisposition(payload): 'crashed' | 'unhandled' | 'ok', read from the serialized envelope payload (the only channel to the wrapper).NativeRNSentry.ts+wrapper.tsnonTerminating: booleanoncaptureEnvelopeoptions, besidehardCrashed(mutually exclusive). Additive key on an untypedObjectparam → backward-compatible ABI; an older cached native binary ignores it and degrades to a normal capture.RNSentryModuleImpl.javanonTerminating→InternalSentrySdk.captureEnvelopeNonTerminating(bytes); else the existing path. Runs off-main (@ReactMethod), safe for the API's synchronous persist.RNSentry.mm+RNSentryInternal.swiftRNSentryInternal.captureNonTerminating(_:)→SentrySDK.internal.envelope.captureNonTerminating(envelope); branch onnonTerminatingbefore the store/capture fork.Phasing
GlobalErrorBoundaryover-report.sampleRatenever reachcaptureEnvelope, so their session would finalizeexitedinstead ofunhandled. Needs a JS-core sampled-out hook + a new bridge methodupdateSessionForDroppedEventNonTerminating, with a no-double-count guard. Mirrors Flutter chore(deps): bump rexml from 3.2.6 to 3.3.3 in /samples/react-native #4008 — hold until that design settles.Release classification
GlobalErrorBoundary-survived errors move fromcrashed→unhandled, 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
crashed↔unhandledmislabel, self-corrected by the "later crash takes precedence" rule. Never a host-app crash.Follow-up from the 8.55.0 bump (#6658).