getsentry / getsentry/sentry-react-native
Report unhandled JS errors that don't terminate the process as `unhandled` sessions (not `crashed`)
- Lingua principale
- TypeScript
- Stelle
- 1.8k
- Fork
- 366
- Merge medio
- 1g 6h
- PR unite (30g)
- 94
Descrizione
### 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).
Guida per i contributori
Apri la guida per i contributori
Direzione di ricerca
Inizia da reactnativeerrorhandlers.ts, misc.ts, NativeRNSentry.ts, wrapper.ts, RNSentryModuleImpl.java, RNSentry.mm e RNSentryInternal.swift, seguendo il percorso hardCrashed esistente dalla decisione di GlobalErrorBoundary fino alla cattura nativa. Verifica le API non terminanti di Android e iOS e la semantica delle relative sessioni. Il lavoro è completato quando gli errori fatali superati diventano unhandled, mentre i crash terminanti rimangono crashed, e la modifica al bridge è retrocompatibile.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Valutazione
- Stack tecnologico
- java, javascript, objective-c, react-native, swift, typescript
- Ambito
- mobile, observability-sre
- Tipo di issue
- Bug
- Difficoltà
- 4/5
- Tempo stimato
- 3-5 giorni
- Stato di attività
- Attiva
- Chiarezza
- Specificata chiaramente
- Idoneità per principianti
- 52/100