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
Add the package
npx expo install @unhandled/react-native expo-constants expo-file-systemAdd the DSN
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 witheas env:create, so a build profile without it sends nothing.Add the config plugin
{ "expo": { "plugins": [["@unhandled/react-native", { "service": "my-app-ios" }]] } }Run
npx expo prebuildagain if your project keeps aniosdirectory.Start it in the root layout
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
dsnstringrequiredIngest endpoint, API key and service id. Empty turns the SDK off.
environmentproduction | staging | developmentrequiredSent as
x-unhandled-env.releasestringdefaultbundleId@version+buildSent 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.sampleRatenumberdefault1Share of errors and messages to send.
tracesSampleRatenumberdefault1Share of spans to send.
networkSpansbooleandefaulttruefalsestops recordingfetchandXMLHttpRequestcalls.ignoreErrors(string | RegExp | function)[]default[]A string matches when the message contains it. A function receives the exception and returns
trueto drop it.beforeSendLogfunctionReceives each log record. Return it, a changed copy, or
nullto drop it.queueobjectBounds of the on-disk queue:
maxBytes(default4000000) andmaxEnvelopes(default200).enabledbooleandefaulttruefalsereturns the inactive client.
Errors
The SDK chains to the global handler React Native installs, so the red box still shows in development.
| Source | mechanism | handled | Severity |
|---|---|---|---|
| A fatal uncaught error | onerror | false | FATAL |
| A non-fatal global error | onerror | true | ERROR |
| An unhandled promise rejection | onunhandledrejection | false | ERROR |
captureException() | manual | true | ERROR |
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.
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
| Span | When |
|---|---|
| The route name | Each screen change, from the navigation action to the next frame. app.screen.previous names where it came from. |
app start | Once per launch, from the app start time React Native records natively to the first screen on display. app.start.type is cold. |
app resume | Back from the background to the next frame. app.start.type is resume. |
GET api.example.com | Each 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 variable | Effect |
|---|---|
UNHANDLED_API_KEY | A project deploy key. Without it the phase only warns. |
EXPO_PUBLIC_UNHANDLED_DSN | Names the service the maps belong to. |
UNHANDLED_ENVIRONMENT | Deployment environment, production by default. |
UNHANDLED_DISABLE_UPLOAD | true skips the phase. |
On EAS, store the deploy key as a secret environment variable:
eas env:create --name UNHANDLED_API_KEY --value dk_… --visibility secret --environment productionEAS Update bundles are not symbolicated yet.