Skip to main content
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.
The React Native SDK (@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

The examples below also pass AsyncStorage as the optional storage adapter. Install it if your app does not have it yet:
The package ships ES modules and a 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 in node_modules by default. Include the SDK in your transformIgnorePatterns exception:
jest.config.js
If you already have an exception for other dependencies, add @sazabi/react-native to that expression.

Initialize

Import the register 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 is index.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 is index.ts, the file that calls registerRootComponent. Expo inlines EXPO_PUBLIC_* variables at build time:
index.ts

Expo with expo-router

expo-router sets main 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 through useNavigationContainerRef:
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 nearest accessibilityLabel or testID. A sazabiName prop overrides the name, and sazabiMask on an ancestor suppresses names under it:
Both props are typed on 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 a storage 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

The SDK flushes every 5 seconds by default (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 a storage 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 by SazabiRoot, 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.
Every event in one crash batch carries the same 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 the storage 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 no unhandledrejection events.
  • Initialize the other SDK after init and accept that the SDK records no unhandledrejection events.
In a development build the SDK prints one [@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

After init, 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.kind is uncaught, fatal, or unhandledrejection. fatal and uncaught are React Native’s own isFatal flag passed through: a development build reports an uncaught error as fatal even though the red box keeps the app alive, and ErrorUtils.reportError reports one as not fatal. A render error caught by SazabiRoot is always fatal. Each error is one row: the console.error line React Native prints for it is not recorded again as a log event. Rejection tracking needs the Hermes engine; an app on JavaScriptCore gets no rejection events. In a development build without errors.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: fetch and XMLHttpRequest calls with method, status, and duration, with sensitive query parameters redacted in url.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 /symbolicate are excluded from both capture and trace propagation. Other requests receive a W3C traceparent header by default; network.allowlist narrows propagation without disabling capture (see Correlate with your backend). Set network.propagateTraceContext: false to disable injection while keeping capture.
  • Console: mirrors console.error and console.warn by default (the original console output is untouched).
  • Lifecycle: the app going to the background and coming back.
The SDK never captures request or response bodies, typed text, or navigation param values. The screen path (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 W3C traceparent 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 string entry is one of:
  • A host: api.example.com. Add the port when it is not the default (api.example.com:8443).
  • A domain with every subdomain: .example.com matches example.com and api.example.com.
  • An origin with an optional path prefix: https://api.example.com, or https://example.com/api to match only requests under that path.
A string matches by parsed origin or host, never as a substring, so 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 in url.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:
The option extends the built-in list; it cannot remove a built-in name. The same rule covers every recorded URL, not only requests: the screen URL stamped on every event (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 its app.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.

Further reading