The React Native SDK is an early preview. Options, event attributes, and
capture behavior may change between releases, so pin the version you install
and read the changelog before you upgrade.
@sazabi/react-native) instruments a React Native or Expo app and sends telemetry to Sazabi. It captures JavaScript errors, screen changes, touches, network calls, console output, and app lifecycle, and lets you emit custom events and logs. It authenticates with a public key, which is write-only and safe to ship in your app bundle.
The package is pure JavaScript: no native module, no config plugin, and it runs in Expo Go. It needs React Native 0.76 or later (Expo SDK 52 or later). For a web build of the same app use the browser SDK; this SDK installs nothing under react-native-web.
For server-side reads and writes against the API, use the TypeScript SDK with a secret key instead.
Install
storage adapter. Install it if your app does not have it yet:
react-native manifest field. Metro resolves @sazabi/react-native/register with or without package exports, including React Native 0.76 to 0.78 and Expo SDK 52, where package exports are off by default.
Jest
React Native’s Jest preset does not transform this SDK’s ES modules innode_modules by default. Include the SDK in your transformIgnorePatterns exception:
jest.config.js
@sazabi/react-native to that expression.
Initialize
Import theregister entrypoint as the first import of your app’s entry module, the file your package.json main field points to. It installs the SDK’s XMLHttpRequest patch before any other module can capture the prototype methods. Then call init. Every import in the file runs before the first statement, so init still runs before your first screen renders.
React Native CLI
The entry module isindex.js, the file that calls AppRegistry.registerComponent. A bare React Native app has no process.env at runtime, so put the intake URL in a constant or read it with react-native-config:
index.js
Expo
Without expo-router, the entry module isindex.ts, the file that calls registerRootComponent. Expo inlines EXPO_PUBLIC_* variables at build time:
index.ts
Expo with expo-router
expo-router setsmain to expo-router/entry, so app/_layout.tsx loads after expo-router and every screen module. Add your own entry file that imports the SDK first and then expo-router, and point main at it:
package.json
index.js
Options
init is idempotent across module evaluations: a second call finds the running instance and is ignored. Keep one copy of the SDK in your bundle and import init, SazabiRoot, and the other APIs from that copy. Two copies do not install a second transport or global error handler, but components from a different copy can miss touches or render errors because their capture hooks belong to that copy. When intakeUrl or serviceName is missing it logs a [@sazabi/react-native] warning and installs nothing. init options:
Capture touches and screens
Wrap your app in<SazabiRoot> and pass your navigation container ref to trackNavigation. <SazabiRoot> must be the outermost component, outside your own error boundaries and providers: it observes touches and records render errors, and it only sees what renders inside it.
React Navigation
App.tsx
expo-router
expo-router owns the navigation container and exposes its ref throughuseNavigationContainerRef:
app/_layout.tsx
SazabiRoot renders one full-size View that observes touches without claiming them. It is also an error boundary: an error thrown while React renders your components is recorded as a fatal error and written to the crash outbox, then rethrown, so the app crashes (or a development build shows its red box) exactly as it would without the SDK. An error boundary of yours above SazabiRoot that recovers from the error leaves a fatal event for a crash that did not happen, which is another reason to keep SazabiRoot outermost. trackNavigation(ref) subscribes to the container ref and records each screen change; it returns an unsubscribe function.
Name and mask touches
Touches are named from the nearestaccessibilityLabel or testID. A sazabiName prop overrides the name, and sazabiMask on an ancestor suppresses names under it:
View, Pressable, Text, and the Touchable* components as soon as you import the SDK, so no declaration file is needed.
Identify and reset
identify attaches a client-asserted user identity (and optional traits) to subsequent events. reset clears the identity and rotates the session so a shared device does not thread activity into the previous user’s timeline.
Session lifetime
With astorage adapter, a session survives an app restart while it is within the 30-minute inactivity and 24-hour age limits. A stored session also rotates if its last activity is more than 60 seconds in the future after the device clock moves backward, or its stored format cannot be read. Upgrading the app alone does not rotate a valid session.
Custom events and logs
addEvent(name, attributes?) emits a custom event into the session stream. log(severity, message, metadata?) emits an application log line with session context; severity is one of DEBUG, INFO, WARN, or ERROR.
Flush and shutdown
flushIntervalMs) and again when the app goes to the background.
shutdown removes the network, console, and touch instrumentation and stops emitting. Two hooks cannot be handed back: the Hermes rejection tracker has one slot with no way to restore its previous occupant, and another library may have wrapped the SDK’s global error handler. Both stay installed but do nothing after shutdown; the previous global error handler and a tracker passed as errors.previousRejectionTracker still run.
Delivery
The intake response determines what happens to each batch:
The queue holds at most
maxQueuedEvents events (500 by default) and has a 1 MiB byte budget. If either limit is reached, the oldest events are dropped first. A full queue logs one [@sazabi/react-native] warning per launch. The next delivered event after queue evictions or refused queued batches carries app.sdk.dropped_events with the number of events lost. This counter covers the in-memory queue; reports evicted or refused from the stored crash outbox are not included. A single oversized event can exceed the queue’s byte budget.
Uploads may be delivered more than once: a timed-out request may have been accepted before the SDK retries it. Keep this in mind when counting events.
Saved queue
The OS can kill a backgrounded app without warning, and an offline queue would die with it. With astorage adapter, the SDK saves the queue under sazabi.outbox.queue each time the app leaves the foreground, before it flushes, within the same 20-batch and 512 K-character budget as the crash outbox. The copy is removed once everything queued has been delivered; otherwise the next launch posts it. Events captured after the app went to the background are saved only the next time it does. If the live upload reached the intake but the app was killed before the answer came back, the next launch posts the copy too; both deliveries carry the same app.outbox.id.
Crash outbox
A fatal JavaScript error, including a render error caught bySazabiRoot, ends the process shortly after React Native’s error handler returns, so a normal upload cannot finish. With a storage adapter, the SDK writes the pending events, the error included, to an outbox in storage before the app terminates. On the next launch, init posts the outbox. Retryable failures stay for a later attempt; permanent failures are removed according to the delivery policy below.
How often a crash is delivered depends on the adapter:
- Synchronous store (an MMKV wrapper): the write is on disk before the process ends, so the SDK waits until the next launch to send the crash. If the app is still running two seconds after the error (a development build, or an error handler that does not terminate), the SDK posts the outbox itself.
- Promise-returning store (AsyncStorage): the write may not finish before the process dies, so the SDK also sends the batch at once. If the write is lost and the send fails, that crash’s events are lost. If both land, the next launch posts the batch a second time.
app.outbox.id. Group or deduplicate on it to drop the second copy an AsyncStorage app can deliver.
Delivery is at least once. A crash whose acknowledgement the app never saw, because the process died or the response never arrived, is posted again on the next launch under the same app.outbox.id, and the saved queue can be posted in the same launch as a crash batch. The batch key and the index are two writes, and the process can die between them, leaving a report under its key that no index lists. When the adapter provides getAllKeys (AsyncStorage and MMKV both do), the next launch lists the store’s keys once and treats every sazabi.outbox.<id> key the index does not name as a stored report, posting and removing it like any other. A custom adapter without getAllKeys loses only a crash that died in that window. A delivered report’s key is removed before the index stops listing it, so a cleanup interrupted between the two leaves a listed id with no key, which the next launch drops rather than posting again.
The outbox retains reports after 408, 429, 5xx, network errors, and timeouts for a later launch. Unlike queued uploads, stored reports are not retried on a backoff timer and are not split on 413: a 413 or any other 4xx except 408 and 429 deletes the stored report with one warning per status per launch. The outbox retains the newest batches within a budget of 20 batches and 512 K characters (524,288 UTF-16 code units), dropping the oldest first. A single oversized batch can exceed that character budget.
Stored values are checked when they are read. A stored entry that is not valid JSON is discarded with one warning, never posted, and the first launch that discards it also removes it from storage, so the warning does not come back on every launch.
Without a storage adapter there is no outbox; the SDK only attempts an immediate send.
Storage keys
The SDK writes these keys through thestorage adapter. Do not read or edit them from your app.
Other crash reporters
Hermes has one slot for unhandled promise rejection tracking, and the last library to claim it silences the others. Hermes gives no way to read or wrap that slot (HermesInternal is frozen and its global cannot be reassigned), so init replaces whatever holds it, and a library initialized after init replaces the SDK’s tracker in turn; neither side can tell. Your options:
-
Hand the SDK a tracker to run after its own on every rejection with
errors.previousRejectionTracker. Most crash reporters (Sentry, Bugsnag) install theirs inline and do not expose it, so write the hand-off yourself against their capture API: -
Leave the slot to the other SDK with
errors: { unhandledRejections: false }. The SDK then records nounhandledrejectionevents. -
Initialize the other SDK after
initand accept that the SDK records nounhandledrejectionevents.
[@sazabi/react-native] console note when it takes the slot with neither option set. Global error handlers (ErrorUtils) always chain, in any order, so a crash reporter’s global handler keeps running whether it was installed before or after init.
What the SDK captures automatically
Afterinit, the SDK captures, with session context attached:
- Errors: errors that reach React Native’s global error handler, errors thrown while React renders inside
SazabiRoot, and unhandled promise rejections, with stack traces.app.error.kindisuncaught,fatal, orunhandledrejection.fatalanduncaughtare React Native’s ownisFatalflag passed through: a development build reports an uncaught error as fatal even though the red box keeps the app alive, andErrorUtils.reportErrorreports one as not fatal. A render error caught bySazabiRootis alwaysfatal. Each error is one row: theconsole.errorline React Native prints for it is not recorded again as alogevent. Rejection tracking needs the Hermes engine; an app on JavaScriptCore gets no rejection events. In a development build withouterrors.previousRejectionTracker, the SDK prints the same “Possible unhandled promise rejection” warning React Native’s own tracker would, which LogBox shows. See Other crash reporters for the Hermes tracker slot. - Screens: route changes from the navigation container you pass to
trackNavigation, with the route name, path, and param keys. - Touches: one event per touch from down to up, with coordinates, distance, duration, pressure, concurrent touches, and a once-per-second path sample. Taps and rapid repeat taps are derived from them. When a scroll view takes over a touch, the event ends at that point with
app.pointer.ended_by: cancel. - Network calls:
fetchandXMLHttpRequestcalls with method, status, and duration, with sensitive query parameters redacted inurl.full(see Redacted URLs). Intake uploads are excluded, matched by the intake’s own origin, so a lookalike host is still captured. In development, requests to the Metro dev server origin and any URL containing/symbolicateare excluded from both capture and trace propagation. Other requests receive a W3Ctraceparentheader by default;network.allowlistnarrows propagation without disabling capture (see Correlate with your backend). Setnetwork.propagateTraceContext: falseto disable injection while keeping capture. - Console: mirrors
console.errorandconsole.warnby default (the original console output is untouched). - Lifecycle: the app going to the background and coming back.
url.path and app.screen.url) is recorded as your router reports it, so a deep link’s query string, values included, is kept, as the browser SDK keeps the page URL. The SDK does not capture native crashes, ANRs, app-start or frame metrics, or session replay.
Each SDK hook catches its own errors, so a failure inside the SDK is not thrown into your app code. Error capture accepts strings, plain or circular objects, and errors with throwing property getters. Error messages and stacks are truncated to 4,096 characters.
Correlate with your backend
By default the SDK injects a W3Ctraceparent header into every request your app makes. A native app has no origin and no CORS preflight, so there is no same-origin default to fall back on; the browser SDK defaults to same-origin requests. Requests to the Sazabi intake are never injected, and in development neither are requests to the Metro dev server. Use network.allowlist to choose which hosts get the header, so third-party APIs never see one:
- A host:
api.example.com. Add the port when it is not the default (api.example.com:8443). - A domain with every subdomain:
.example.commatchesexample.comandapi.example.com. - An origin with an optional path prefix:
https://api.example.com, orhttps://example.com/apito match only requests under that path.
https://evil.test/?next=api.example.com and api.example.com.evil.test get no header. A RegExp is tested against the full URL.
Backend log lines that run inside the extracted trace context then share a trace_id with the originating request. If your backend runs OpenTelemetry, that extraction already happens by default. Set network.sampledFlag: true if you also want your backend tracer to record those traces; the log join works either way.
Redacted URLs
Network events keep the query string inurl.full, but replace the value of every sensitive parameter with [redacted]. The built-in list is token, access_token, refresh_token, id_token, api_key, apikey, key, secret, password, signature, sig, x-amz-signature, x-amz-credential, x-goog-signature, sentry_key, authorization, auth, session, sid, and code. Names match case-insensitively, in the query and in the fragment, so a hash router’s #/reset?token=… is redacted too. Credentials in the URL (user:pass@) are never recorded.
Add your own parameter names with network.redactQueryParams. A string matches a name exactly; a RegExp is tested against the name, from the start each time, so a g flag is safe:
app.screen.url) and a deep link’s route path on navigation events (url.path). The browser SDK takes the same option and applies it to the page URL on every event, and to a navigation’s url.full and app.navigation.from, the referrer included.
Attributes
Events carry the attributes below, plus attributes specific to itsapp.event_type:
The resource on every request carries
service.name, service.version, deployment.environment, telemetry.sdk.name (@sazabi/react-native), telemetry.sdk.version, telemetry.sdk.language (javascript), os.name (ios or android), os.version, and device attributes. Android supplies device.manufacturer and device.model.identifier from Platform.constants. iOS supplies device.manufacturer: "Apple" and device.type from its interface idiom (phone, pad, tv, mac, vision, or carplay). iOS does not expose a device model to JavaScript without a native module, so device.model.identifier is absent there.