getsentry / getsentry/sentry-react-native

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

Open
#6,659 1 comment 0 reactions 0 assignees View on GitHub
Android Errors Feature Platform: React Native React-Native
Dominant language
TypeScript
Stars
1.8k
Forks
366
Avg merge
1d 9h
Merged PRs (30d)
89

Description

### 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](https://develop.sentry.dev/sdk/telemetry/sessions/):
- `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 **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](https://github.com/getsentry/sentry-java/pull/5921)), merged via #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](https://github.com/getsentry/sentry-dart/issues/3300)), all opened 2026-09-07:
- [#4006](https://github.com/getsentry/sentry-dart/pull/4006) — bump native SDKs (Android 8.55.0 + Cocoa 9.27.0).
- [#4007](https://github.com/getsentry/sentry-dart/pull/4007) `feat` — capture path via `captureEnvelopeNonTerminating` / `captureNonTerminating`.
- [#4008](https://github.com/getsentry/sentry-dart/pull/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` | `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. |

### Phasing

- **Phase 1 — capture path** (this issue): M1–M5. Mirrors Flutter #4007; closes the `GlobalErrorBoundary` over-report.
- **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 #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.

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

Contributor guide

Open the contributing guide

Research direction

Start with reactnativeerrorhandlers.ts, misc.ts, NativeRNSentry.ts, wrapper.ts, RNSentryModuleImpl.java, RNSentry.mm, and RNSentryInternal.swift, tracing the existing hardCrashed path from the GlobalErrorBoundary decision to native capture. Verify the Android and iOS non-terminating APIs and their session semantics. Done means survived fatal errors become unhandled while terminating crashes remain crashed, with the bridge change backward-compatible.

Written by the indexing model from the issue text.

Assessment

Tech stack
java, javascript, objective-c, react-native, swift, typescript
Domain
mobile, observability-sre
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.