@marsadco/react-native
v0.1.0-beta.0
Published
Marsad native crash capture for React Native and Expo (iOS + Android). Pairs with @marsadco/sdk.
Maintainers
Readme
@marsadco/react-native
Native crash capture for React Native and Expo apps, on iOS and Android. It
ships the MarsadNativeCrash native module; @marsadco/sdk finds it by name
and uses it automatically.
Beta. Install it with the
@betatag. It captures native crashes on iOS and Android, and with debug files uploaded they resolve to function, file and line. It hasn't been run on a physical iPhone yet. Report problems, or see what others hit, at https://www.marsad.space/community/issues.
Why a separate package
@marsadco/sdk is pure JavaScript and runs on the web, Node and React Native.
Native code lives here so that:
- web and Next.js users never download it, and
- a native build problem can only affect apps that installed this package.
Without it, @marsadco/sdk still captures JavaScript errors in React Native —
it just can't see crashes that happen below JavaScript.
Requirements
- Expo SDK 54–57, or React Native 0.81+ without Expo.
- The New Architecture, which is the default everywhere in that range.
- Not Expo Go, which can't load custom native code — use a development or EAS build.
Each release is checked with a release build of Expo SDK 54, 55, 56 and 57 and bare React Native 0.86, on the iOS simulator and the Android emulator. Each build must link the module, catch a native crash and deliver exactly one event for it. Built with Xcode 27 and JDK 17. It hasn't run on a physical iPhone yet.
The old architecture isn't supported. On SDK 54 with newArchEnabled: false,
Android still loads the module through React Native's interop layer, and it
works. On iOS the module isn't visible to JavaScript, and the app runs without
it. (On Xcode 27 that iOS build first fails inside React Native's own fmt
pod, which React Native 0.81 compiles from source on the old architecture. We
checked the module with fmt built as C++17.)
Install
npx expo install @marsadco/sdk @marsadco/react-native@betaAdd the config plugin to app.json, then run expo prebuild or an EAS build:
{ "expo": { "plugins": [["@marsadco/react-native", { "key": "bk_live_…" }]] } }The key lets native code send a crash before JavaScript starts (see
Delivery) and lets release builds upload their debug files (see
Symbols). Pass endpoint too if you self-host.
Bare React Native: npm install @marsadco/sdk @marsadco/react-native@beta, and
add one call at the top of the post_install block in ios/Podfile (the Expo
plugin adds it for you):
post_install do |installer|
require File.join(File.dirname(Pod::Executable.execute_command('node', ['-p', 'require.resolve("@marsadco/react-native/package.json", {paths: [process.argv[1]]})', __dir__]).strip), "ios", "marsad_post_install")
marsad_post_install(installer)
# … react_native_post_install(…) as beforeThen pod install in ios/. Autolinking does the rest. The call raises the
deployment target of KSCrash's privacy-manifest bundles, which Xcode 27 refuses
to build at the iOS 12 their podspec declares.
Nothing to call: capture starts before your app code does, so crashes during startup are caught too. Every report carries what JS had set when the app crashed: user, tags, breadcrumbs, context, release and environment.
What it captures on iOS
KSCrash 2.6 (MIT, a CocoaPods
dependency) is installed when the app binary loads, before main.
| Crash | Reported as |
|---|---|
| EXC_BAD_ACCESS / SIGSEGV, stack overflow | SIGSEGV, with the fault address |
| abort() and other signals | SIGABRT, … |
| Swift fatalError, force-unwrap of nil | SIGTRAP (EXC_BREAKPOINT) |
| Uncaught NSException | the exception's name and reason |
| Uncaught C++ exception | the exception's type and what() |
Each comes with the crashed thread's stack: address, binary image and its UUID per frame, and the symbol where the device could resolve one. On the next launch the KSCrash report is converted on the device into a compact report built from an explicit list of fields, then deleted: register values, stack memory, environment variables and the list of every loaded image never leave the phone.
Not caught: watchdog kills, hangs and out-of-memory terminations. iOS ends those from outside the process, so no in-app handler runs.
What it captures on Android
A content provider starts capture before Application.onCreate.
| Crash | How | Needs | |---|---|---| | Uncaught Kotlin/Java exception | An uncaught-exception handler writes the report before the process dies | Any Android | | Native crash (SIGSEGV, SIGABRT, …) | Read from Android's exit history on the next launch, with the tombstone's crashed-thread backtrace (build id + relative pc per frame) | Android 12+ for the stack; Android 11 gets the signal only | | ANR | Exit history, with the main thread's stack from the ANR dump | Android 11+, and only when the ANR ended the process | | Low-memory kill in the foreground | Exit history, sent as a warning | Android 11+ |
Symbols: function, file and line
A native crash arrives as addresses. To show and group it by function, file and
line, Marsad needs the build's debug files: the dSYMs on iOS, and on Android the
unstripped .so files plus R8's mapping.txt. With the config plugin, release
builds upload them for you, using the plugin's key and endpoint:
- iOS: an "Upload Marsad debug files" build phase, after the dSYMs are made.
Release configuration only (
MARSAD_UPLOAD_ALL_CONFIGURATIONS=1for all). - Android: a
marsadUploadDebugFiles<Variant>task afterassemble/bundleof every non-debuggable variant.
Only files the server doesn't have yet are sent, so a rebuild with no native
change uploads nothing. An upload that fails prints a warning; it never fails the
build. React Native's own libraries are skipped. MARSAD_KEY and MARSAD_URL
override the plugin's values (for a CI secret), MARSAD_UPLOAD_DEBUG_FILES=0
turns it off for one build, and { "uploadDebugFiles": false } in the plugin
options removes it.
A crash that arrives before its symbols, or while the server can't resolve it, waits up to 15 minutes, then is recorded with addresses only. Uploading symbols later doesn't change crashes already recorded. Issues whose frames were resolved show a Symbolicated badge.
Without the plugin (bare React Native, or your own CI), upload from the build outputs:
npx marsad upload-debug-files --platform ios --key $MARSAD_KEY ./ios/build/Build/Products/Release-iphoneos
npx marsad upload-debug-files --platform android --key $MARSAD_KEY \
./android/app/build/intermediates/merged_native_libs/release ./android/app/build/outputs/mapping/releaseObfuscated builds (R8 / ProGuard)
R8 renames classes and methods differently in every release. So the server can restore the real names (and keep one crash in one issue across releases), each build's mapping gets an id, which goes into the app's manifest and is uploaded with the mapping. The plugin does both: each minified build gets a fresh id. By hand, it's:
<!-- AndroidManifest.xml, inside <application> -->
<meta-data android:name="com.marsadco.PROGUARD_UUID" android:value="6b4ab3c4-…" />npx marsad upload-debug-files --platform android --proguard-id 6b4ab3c4-… ./android/app/build/outputs/mapping/releaseKotlin/Java frames then carry that id. Without it they're grouped and shown with their obfuscated names.
Fatal JS errors
Saved, not raced. React Native kills the app right after a fatal JS error,
too soon for a network request to finish. So in release builds @marsadco/sdk
hands the finished event to this module, which writes it to disk before
returning, and it's sent on the next launch like a native crash (with
beforeSend, the original event code and the crashed run's breadcrumbs).
React Native then ends the app with its own exception (JavascriptException on
Android, RCTFatalException on iOS). Native doesn't report that one when the SDK
saved the error, so it's never sent twice. When the SDK didn't get to see it (an
error while the bundle is still loading), native reports it, grouped by the JS
error rather than by React Native's crash frames.
Delivery
Reports are sent on the next launch. Normally JS sends them as soon as
init() runs, so beforeSend applies. Native sends them itself when:
- JS hasn't configured the SDK within 10 seconds, or
- the app is crashing on launch: the last two foreground launches died within 10 seconds, so waiting for JS would never end.
beforeSend doesn't run on reports native sends. Native uses the key and
endpoint from the last launch where JS ran, or what the config plugin writes
(the marsad dictionary in Info.plist; com.marsadco.API_KEY and
com.marsadco.INGEST_URL in the Android manifest). With neither, reports wait
for JS.
On Android, a native send happens in the app process while it's in the
foreground, and otherwise through a JobScheduler job, because Android freezes
background app processes and blocks their network. That's why the library
declares ACCESS_NETWORK_STATE, a normal permission granted at install. On iOS
the send runs as a background task, so leaving the app doesn't cut it off.
Alongside another crash reporter
Both reporters get the crash. Marsad installs first: before main on iOS, and
in a content provider on Android. A reporter that starts later, as Sentry does
at Sentry.init, installs its handlers on top and passes the crash on. Android
native crashes come from the system's exit record on the next launch, not from a
signal handler, so they don't compete for one.
Tested with @sentry/react-native 8.26.0 in its default setup, on the iOS
simulator and the Android emulator. Each crash below reached both, and Marsad
sent exactly one event, with its stack:
| iOS | Android |
|---|---|
| SIGSEGV, SIGABRT, NSException, C++ exception, Swift fatalError | Kotlin exception, native SIGSEGV, native abort |
One difference on iOS: an uncaught C++ exception reaches Marsad as SIGABRT
rather than the exception's type and message, because Sentry's terminate
handler takes the exception first. Sentry's report still has them. Other
reporters (Crashlytics, Bugsnag) haven't been tested.
Turning it off
<!-- iOS: Info.plist -->
<key>marsad</key>
<dict>
<key>captureNativeCrashes</key>
<false/>
</dict><!-- Android: AndroidManifest.xml, inside <application> -->
<meta-data android:name="com.marsadco.CAPTURE_NATIVE_CRASHES" android:value="false" />init({ enabled: false }) also stops native from sending: saved reports are
deleted instead.
Checking it's linked
import { getNativeCrashInfo, nativeCrash } from "@marsadco/react-native";
getNativeCrashInfo(); // { moduleVersion, platform, captureInstalled, … } — null if not linked
nativeCrash(); // crashes the app from native code, to test the pipelineLayout
| Path | What |
|---|---|
| src/NativeMarsadNativeCrash.ts | Codegen spec — the native contract. Keep in step with NativeCrashModule in @marsadco/sdk. |
| ios/MarsadNativeCrash.mm | iOS TurboModule: forwards to MarsadCrash |
| ios/MarsadCrash.m | iOS capture: KSCrash install, launch processing, scope, delivery |
| ios/MarsadReportConverter.m, ios/MarsadCrashStore.m | KSCrash report conversion and report storage. Foundation only, tested on macOS with real KSCrash reports: sh ios-tests/run.sh |
| android/…/MarsadNativeCrashModule.kt | Android TurboModule: forwards to crash/MarsadCrash |
| android/…/crash/ | Android capture: tombstone and ANR parsers, report storage, native sender. JVM tests with real emulator captures (npm run test:android in crash-lab) |
| MarsadReactNative.podspec | CocoaPods spec (autolinking reads it from the package root) |
| app.plugin.js | Expo config plugin (delegates to @marsadco/sdk) |
Both platforms are exercised end to end by examples/crash-lab.
