docs

React Native SDK

Install @unhandled/react-native to report JS errors with Hermes source maps, queue telemetry offline, and record screen, network and app start spans from an Expo or React Native app.

@unhandled/react-native reports what happens inside your app on your users' devices. It captures uncaught JS errors and unhandled promise rejections, keeps everything in an on-disk queue until the device is online, and records screen changes, fetch and XMLHttpRequest calls and the cold start as spans. Its config plugin uploads the Hermes source map of every release build, so stack traces point at your source files. It posts OTLP/HTTP JSON to the ingest endpoint in your DSN.

It needs Expo modules, which every Expo app has and a bare React Native app gets from npx install-expo-modules. Native iOS and Android crashes are not captured yet; JS errors are.

Install

  1. Add the package

    Terminal
    npx expo install @unhandled/react-native expo-constants expo-file-system
  2. Add the DSN

    .env
    EXPO_PUBLIC_UNHANDLED_DSN=https://uh_live_…@ingest.unhandled.net/<service id>

    Expo inlines EXPO_PUBLIC_ variables when it bundles the app. On EAS, set the variable per environment with eas env:create, so a build profile without it sends nothing.

  3. Add the config plugin

    app.json
    {
      "expo": {
        "plugins": [["@unhandled/react-native", { "service": "my-app-ios" }]]
      }
    }

    Run npx expo prebuild again if your project keeps an ios directory.

  4. Start it in the root layout

    app/_layout.tsx
    import { init } from '@unhandled/react-native'
    import { Stack, useNavigationContainerRef } from 'expo-router'
    import { useEffect } from 'react'
    
    const unhandled = init({
      dsn: process.env.EXPO_PUBLIC_UNHANDLED_DSN ?? '',
      environment: 'production',
    })
    
    export default function RootLayout() {
      const navigation = useNavigationContainerRef()
      useEffect(() => unhandled.registerNavigationContainer(navigation), [navigation])
      return <Stack />
    }

    With React Navigation, pass the ref you give NavigationContainer.

init() never throws. An empty dsn returns an inactive client whose active is false, which is how a build without the variable stays silent. A malformed DSN or environment logs a warning and returns the same inactive client. A second init(), such as a Fast Refresh re-run of the module, logs a warning and returns the first client.

Options

dsnstringrequired

Ingest endpoint, API key and service id. Empty turns the SDK off.

environmentproduction | staging | developmentrequired

Sent as x-unhandled-env.

releasestringdefault bundleId@version+build

Sent as service.version. The default reads the bundle identifier and version from the app config and the build number from the native binary, which is the release the config plugin stamps on the source map.

sampleRatenumberdefault 1

Share of errors and messages to send.

tracesSampleRatenumberdefault 1

Share of spans to send.

networkSpansbooleandefault true

false stops recording fetch and XMLHttpRequest calls.

ignoreErrors(string | RegExp | function)[]default []

A string matches when the message contains it. A function receives the exception and returns true to drop it.

beforeSendLogfunction

Receives each log record. Return it, a changed copy, or null to drop it.

queueobject

Bounds of the on-disk queue: maxBytes (default 4000000) and maxEnvelopes (default 200).

enabledbooleandefault true

false returns the inactive client.

Errors

The SDK chains to the global handler React Native installs, so the red box still shows in development.

SourcemechanismhandledSeverity
A fatal uncaught erroronerrorfalseFATAL
A non-fatal global erroronerrortrueERROR
An unhandled promise rejectiononunhandledrejectionfalseERROR
captureException()manualtrueERROR

A fatal error ends the app in a release build. The SDK writes the record to disk before React Native's handler runs, and sends it on the next launch.

ts
unhandled.captureException(error, { tags: { flow: 'checkout' } })
unhandled.captureMessage('Seat map cache rebuilt', 'warning')
unhandled.logger.info('Cabin opened', { cabin: 'J' })

Every error carries the current screen as app.screen.name, and the tags os.name and device.class, so an issue shows how it splits by OS and by phone or tablet.

Offline queue

Errors, rejections and captureMessage calls are written to a file under the app's caches directory as they are captured, so killing the app right after one loses nothing. Spans and logs are batched for a second before they are written, so a kill inside that second drops them. A file is deleted only after the ingest answers, so a record written before a kill is sent once, on the next launch. Files go out oldest first, one at a time. A failed send backs off exponentially up to five minutes. Coming back to the foreground retries at once, and going to the background writes whatever is still buffered. A 4xx answer other than 408 and 429 drops the file.

When the queue passes maxEnvelopes or maxBytes, it drops the oldest spans and logs first and keeps errors as long as it can.

Spans

SpanWhen
The route nameEach screen change, from the navigation action to the next frame. app.screen.previous names where it came from.
app startOnce per launch, from the app start time React Native records natively to the first screen on display. app.start.type is cold.
app resumeBack from the background to the next frame. app.start.type is resume.
GET api.example.comEach fetch or XMLHttpRequest call, except calls to the ingest endpoint.

Screen spans use the navigator's route names, such as airline/[code] for an expo-router file route, never the resolved parameters. Call unhandled.appReady() to end the cold start yourself when the first screen is not the meaningful one.

Privacy

Nothing that identifies a person or a device is collected unless you send it. The resource carries os.name, os.version and device.class (phone, tablet, tv, desktop, vision). There is no device model, no install id and no advertising id. Network spans keep the scheme, host and path and drop the query string, fragment and credentials. Bundle paths lose the app container directory. setUser() attaches a user only when you call it.

Source maps

Release builds run Hermes bytecode, so a raw frame reads main.jsbundle:1:482114. The config plugin makes the Xcode bundle phase write the composed Hermes source map, and adds an Upload source maps to Unhandled phase that runs after it.

That phase uploads main.jsbundle.map with the release bundleId@version+build, read from the target's Info.plist, and marks a deployment of the service you passed to the plugin. It skips Debug builds, where Metro serves the bundle.

Build variableEffect
UNHANDLED_API_KEYA project deploy key. Without it the phase only warns.
EXPO_PUBLIC_UNHANDLED_DSNNames the service the maps belong to.
UNHANDLED_ENVIRONMENTDeployment environment, production by default.
UNHANDLED_DISABLE_UPLOADtrue skips the phase.

On EAS, store the deploy key as a secret environment variable:

Terminal
eas env:create --name UNHANDLED_API_KEY --value dk_… --visibility secret --environment production

EAS Update bundles are not symbolicated yet.