↓ Skip to main content

React Native Expo Sentry

Sentry's SDK, My Own Server: Crash Reporting for a React Native App in About 650 Lines of Django

Our apps had nothing reporting crashes. Instead of signing up for sentry.io, I kept Sentry's open-source SDK and pointed its DSN at an endpoint in our own Django API. Here's the envelope format, grouping minified crashes without source maps, alerting without flooding Discord, scrubbing, how I proved a native SIGABRT on iOS actually arrives, and why it ended with a rewritten privacy policy.

Until this week, the only way I found out our app had crashed was when someone told me. Most people don’t. They reopen the app, and if it happens again they stop opening it. The API side was covered (every ERROR log goes to a Discord channel), but a crash on a phone left no trace anywhere I could see.

The app did have one reporter already. When it puts an error message in front of someone, it posts that message to /client-errors/, which logs it at ERROR, so it reaches Discord too. I added it after a delete that had worked left the row in the list, and tapping it again showed “No Item matches the given query”. The server had logged two ordinary 404s and said nothing. That covers errors the app catches and shows. A crash is different: the JavaScript that would send the report is the thing that just died.

The obvious fix is Sentry. I like Sentry’s SDK a lot; it’s the reason crash reporting on React Native is a solved problem. What I didn’t want was another vendor holding user data, another privacy-policy line, and another bill for something that, at our size, is a few reports a day at most. So I kept the SDK and replaced the service: the app’s DSN points at our own API, and about 650 lines of Django receive what it sends, group it, alert on it, and keep it.

This post is how that works, and how I checked that a real native crash actually makes it through.

The DSN trick
#

A Sentry DSN looks like https://<public-key>@<host>/<project-id>. The SDK turns that into a POST to https://<host>/api/<project-id>/envelope/, with the key in an X-Sentry-Auth header. Nothing about it is specific to sentry.io. So the DSN baked into the app is:

https://<CRASH_REPORT_KEY>@api.example.com/1

and Django has a route that matches what the SDK will call:

path("api/<int:project_id>/envelope/", crash_envelope, name="crash_envelope"),

The key is in every binary, so it isn’t a secret. It’s there so that if the endpoint ever starts attracting junk I can change it, and so that an unset key on the server refuses everything rather than accepting the world:

def crash_envelope(request, project_id):
    expected = settings.CRASH_REPORT_KEY
    if not expected:
        return Response({"detail": "Crash reporting is not configured"}, status=503)
    if not hmac.compare_digest(_key(request).encode(), expected.encode()):
        return Response({"detail": "Unknown key"}, status=401)
    ...

_key reads sentry_key= out of the X-Sentry-Auth header, falling back to the query string, which some SDKs use instead.

What the SDK sends: envelopes
#

Sentry’s wire format is the envelope: one JSON header line, then any number of items, each one a JSON header line followed by a payload. If the item header has a length, the payload is exactly that many bytes; otherwise it runs to the next newline.

{"event_id":"9ec79c33...","dsn":"https://[email protected]/1"}
{"type":"event","length":4123}
{ ...the event JSON, 4123 bytes... }
{"type":"client_report"}
{"timestamp":1791400000.5,"discarded_events":[]}

Two details caught me out:

  • The native SDKs gzip the body; the JavaScript one doesn’t. A JS error captured by @sentry/react-native arrives as plain text, while a native crash from sentry-cocoa or sentry-android arrives with Content-Encoding: gzip. The decoder handles gzip, zlib-wrapped deflate and raw deflate, and stops decompressing at 1MB, because a crash event is a few kilobytes and anything bigger isn’t one.
  • Length-prefixed payloads can contain newlines. If you split on \n you’ll mangle every event the native SDKs send. You have to honour length when it’s there.

The parser is about forty lines. It only keeps event items; sessions, transactions, attachments and client reports are accepted and dropped.

Answering in a way the SDK understands
#

This matters more than I expected. The SDK keeps a queue on disk and retries anything it isn’t sure was delivered, so what the server answers decides whether it keeps trying:

  • 200 once stored, or deliberately dropped. If I answered a session item with a 4xx, the SDK would keep it and retry forever.
  • 429 with Retry-After when throttled. The endpoint has a per-IP throttle (120 an hour), because a phone in a crash loop sends one report per launch, and the SDK backs off on a 429 the way you’d hope.
  • 400 for a malformed envelope, logged as a warning, not an error: a broken report says a client is broken, and the next good one will say what crashed.

The SDK also resends the same event if it isn’t sure the first send arrived, so event_id has a unique constraint and a duplicate is a quiet no-op.

Grouping without source maps
#

This is where a self-built receiver has to make a choice Sentry makes for you. Sentry groups crashes by stack trace. But a release Hermes bundle is minified, so frame names are single letters and line/column positions change with every build. Group by frames, and the same bug opens a new issue every release.

Without source maps (more on that at the end), the most stable thing in a JavaScript error is its type and message. So the fingerprint is the exception type plus the message, with the parts that differ between sightings masked out:

_MASKS = [
    (re.compile(r"0x[0-9a-fA-F]+"), "<addr>"),
    (re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"), "<uuid>"),
    (re.compile(r"\b[0-9a-fA-F]{16,}\b"), "<hex>"),
    (re.compile(r"\d+"), "<n>"),
]

Cannot read property 'title' of undefined (reading index 3) and (reading index 17) become the same issue. If the SDK supplies its own fingerprint without {{ default }} in it, that wins, so I can force grouping from the client when the default is wrong. An event with no exception falls back to its masked log message, and one with neither falls back to the top in-app frame. For a native signal crash the message is something like Signal 6, Code 0, so those group by signal, which is coarse but honest until there’s symbolication.

Alerting without flooding the channel
#

Each fingerprint is a CrashIssue row, and each report a CrashEvent under it. The issue logs at ERROR (which is what the Discord handler listens for) in exactly these cases:

  • a new issue
  • a resolved issue that comes back, which reopens it
  • the 10th, 100th, 1,000th and 10,000th sighting

An ordinary repeat only increments a counter. An issue marked ignored keeps counting and never alerts. With the Discord handler’s own rate limit on top (repeats of one message once per ten minutes, 30 alerts an hour), a crash loop on a bad release reads as a handful of messages rather than a wall.

Storage is bounded the same way: each issue keeps its 50 most recent events and deletes the rest as new ones arrive. That broke the first version of the admin’s chart, because 50 events can’t tell you how often something happened last week. So the issue row also keeps a small daily_counts dict, {"2026-10-09": 3, ...}, trimmed to 90 days, updated under the same row lock that increments the counter. The chart draws from that; the events are only for reading.

Two small races needed handling. When a brand-new crash hits several phones at once, two reports can both try to create the issue. The loser gets an IntegrityError from the unique fingerprint and re-reads the row with select_for_update. The 90-day window is measured from the server’s today, not from the newest key in the dict, because a phone with its clock set wrong can date a report anywhere, including next year.

A stored payload is capped at 128KB. If an event is over that, breadcrumbs and debug_meta go first, and if it’s still over, only the exception, message, release and platform are kept, with a _truncated flag so I know it was cut.

Scrubbing
#

The SDK runs with sendDefaultPii: false, so in principle no IP address, cookies or request bodies are sent. The server doesn’t rely on that: before storing anything it keeps only id and username from user and drops request entirely, in case a later SDK version or setting starts sending them.

Testing against a real device turned up two more things to strip:

  • Breadcrumbs record full URLs, query strings included. The app’s search calls look like /api/v1/items/?search=..., so a crash right after a search would have stored what the person searched for. The server now cuts every breadcrumb URL at the ?.
  • Console breadcrumbs carry the raw arguments that were passed to console.log, which can be any object the app happened to log. The message text stays (it’s useful), and the arguments are dropped.

I only found those by looking at an actual report, which is the main argument for the next section.

The client side
#

On the app, everything goes through one file, services/crashReporting.ts, for the same reason the OTA SDK is only reached through one file: an over-the-air bundle also runs on binaries built before the native module existed. A top-level import * as Sentry from "@sentry/react-native" on a binary without RNSentry throws on import, and that’s a blank screen. So the SDK is required lazily, and only after checking the native module is actually there:

function nativeModulePresent(): boolean {
  try {
    return Boolean(TurboModuleRegistry.get("RNSentry") ?? NativeModules.RNSentry);
  } catch {
    return false;
  }
}

export function initCrashReporting(): boolean {
  if (!DSN || Platform.OS === "web" || !nativeModulePresent()) return false;
  const Sentry = require("@sentry/react-native");
  Sentry.init({
    dsn: DSN,
    release: Constants.expoConfig?.version ?? "unknown",
    sendDefaultPii: false,
    tracesSampleRate: 0,
    enableAutoSessionTracking: false,
    enableAutoPerformanceTracing: false,
    enableAppStartTracking: false,
    enableNativeFramesTracking: false,
    enableStallTracking: false,
    enableUserInteractionTracing: false,
    maxBreadcrumbs: 50,
    beforeSend(event) { /* tag the OTA bundle */ return event; },
  });
  return true;
}

Errors only. The server doesn’t store traces, sessions, profiles or replays, so none of them are switched on. No DSN (development, E2E builds) means the SDK never starts. The EAS build profiles set the DSN and an environment (staging or production), so both kinds of build report to the same endpoint and the admin can tell them apart. The signed-in user is set by Firebase UID, never a name or an email.

The error boundaries needed an explicit call. Expo Router’s root ErrorBoundary catches a render error and shows its own screen, so that error never reaches the global handler the SDK hooks into. The layout now exports a small wrapper that calls captureException in a useEffect and then renders the router’s boundary as before. The per-route boundary does the same. It’s in an effect rather than in the render so it reports once rather than on every re-render. The root component is also wrapped with Sentry.wrap, and if that throws it falls back to the bare component.

On the web there’s a crashReporting.web.ts beside it with every function switched off. Metro picks the platform file, so the SDK never ends up in the web bundle at all. The web app has no native module to run on.

The old /client-errors/ reporter stays. On a binary older than 2.5.9 it’s still the only thing reporting, and it covers errors that aren’t crashes.

There’s no sentry-cli, no build phase, and no expo prebuild; the ios/ and android/ directories in this project are hand-edited and committed, and autolinking picked up the native module on its own. It shipped in 2.5.9.

Proving a native crash arrives
#

The unit tests post hand-written envelopes, which proves the parser handles what I think the SDK sends. Two days after release, production had zero crash reports. Only three people were on 2.5.9 yet, so zero was plausible, but “nobody has crashed” and “crashes don’t arrive” look identical from the server. I wanted to see a real one.

So I built a Release simulator binary with the DSN pointed at a local Django on port 8002 and a throwaway key:

EXPO_PUBLIC_CRASH_DSN=http://localcrashtestkey0000@localhost:8002/1 \
EXPO_PUBLIC_CRASH_ENVIRONMENT=local-verify \
xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Release \
  -destination 'platform=iOS Simulator,id=<udid>' -derivedDataPath /tmp/iosbuild

(Release, not Debug: Debug builds link the dev launcher and behave differently, and Release is what people run.) Then installed it, launched it, waited a few seconds, and killed it the way a real fault would:

xcrun simctl install booted /tmp/iosbuild/Build/Products/Release-iphonesimulator/MyApp.app
xcrun simctl launch booted com.example.myapp      # prints the pid
kill -ABRT <pid>

Simulator apps are ordinary macOS processes, so a signal reaches SentryCrash’s handler exactly as it would on a device. The handler writes the report to disk; nothing is sent while the process is dying. On the next launch the SDK finds it and sends it. I relaunched, and the simulator’s network log showed a connection to :8002 a second later.

Then nothing in the Django log, which had me confused for a minute: the dev server only logs non-2xx responses at the level I had it on. The database had it:

SIGABRT: Signal 6, Code 0 | cocoa 2.5.9 iOS 27.1 iPhone19,4 handled=False
received 09:19:53, crashed 09:19:38
contexts: app, device, os, ota_updates, trace · 24 breadcrumbs

Fifteen seconds from crash to stored, most of which was me relaunching. And looking at the real payload is what taught me things the tests couldn’t:

  • The user ID wasn’t the Firebase UID. The test build isn’t really signed in, so sentry-cocoa fell back to its own per-install ID. Worth knowing, and worth saying in the privacy note: a report carries the account’s ID if you’re signed in and a random install ID if you’re not.
  • There’s no time zone. I’d assumed the culture context would be there. The device context has locale, memory, screen size, low-power mode and the kernel version, but no time zone.
  • Native crashes skip beforeSend. My JS beforeSend adds an ota tag with the running OTA bundle, but a native crash is assembled by the native SDK on the next launch and never passes through the JS hook. To tag native crashes, the tag has to be set on the scope with setTag once the bundle is known, so it syncs down to the native layer.
  • The breadcrumbs showed what the app does. Console warnings, view-controller lifecycle, and HTTP calls, including Firebase Analytics’ own requests to app-analytics-services.com, which is a good reminder of what else the app talks to. And the URLs had query strings, which is how I found the search-term problem above.

Reading them
#

The Django admin gets an issue page modelled on Sentry’s: a header with the counts, the number of distinct users among the kept events, and Resolve / Ignore / Reopen buttons, a bar per day for 30 days from daily_counts, the share of kept events by release, OS, device, OTA bundle, environment and fatal-or-handled (the top four values each, the rest folded into “other”), and one event at a time with its exception, its frames (app frames highlighted, library frames folded away), the breadcrumb timeline and the device context, plus the raw JSON at the bottom. The issue list has a 14-day sparkline per row. It’s one template and one module that reads the stored payload; nothing about it needed a new dependency.

One fix came out of building it: for a native crash, the stored “culprit” frame read ? (/private/var/containers/Bundle/Application/<uuid>/MyApp.app/MyApp), the binary’s path on the device, which includes a per-install UUID. It now reads 0x1000a1b2c (MyApp). A single report’s admin URL redirects to its issue page with that event selected, so there’s one place to read a crash rather than two.

The privacy policy, all of it
#

Crash reports are personal information, near enough: an account ID, a phone model, and what someone did in the minute before the app died. So the privacy policy needed a paragraph. Writing it properly meant saying exactly what a report carries, so I listed it from the payload I’d just looked at: the error, app version and OTA update, phone model, OS and language, free memory and screen size, the last few dozen steps (screens, web addresses with anything after a ? removed, the app’s own log lines), and the account ID or a random install ID. The policy also says the reports come to our server and not to Sentry or any other crash-reporting company. Retention gets its own line in the table: the 50 most recent reports of each problem, plus a daily count for 90 days.

Once I was checking one paragraph against the code, it was hard not to check the rest. The existing policy didn’t hold up. The delete-account page said push-notification tokens and passkeys were deleted immediately when you close your account, and that server logs were gone within 90 days. None of that was true. Closing an account unlinks the sign-in methods and anonymises the profile, but the token rows and the passkey records stay until someone deletes them by hand, and the log archive has no expiry yet. So the whole policy was rewritten from an inventory of what the code actually does, and those lines now say “not yet removed automatically; email us”. That’s less reassuring, and it’s accurate.

The repo now has a rule for it: the policy describes what the code does, not what we intend, and a change that collects, sends or keeps personal data somewhere new updates it in the same commit. That includes the summary screen in the app, the delete-account page, and the App Store and Play privacy forms, which only live in the store consoles. Crash reporting was the change that made me write that down.

What’s missing
#

Symbolication. This is the real gap, and the one thing sentry.io does that I haven’t replaced. Stacks are minified for JavaScript and unsymbolicated for native code:

  • JavaScript: frames point into the Hermes bundle. Fixing it means keeping the composed Hermes source map for every store build and every OTA bundle, keyed by release and bundle, and applying it to JS frames when the page renders. That’s the next piece, because most of our crashes will be JavaScript.
  • iOS native: instruction addresses need the build’s dSYMs.
  • Android: Java frames are obfuscated by R8 when minification is on (they need mapping.txt), and C++ frames need NDK symbols.

There’s also a smaller gap I found above: native crashes don’t carry the OTA tag yet, because it’s only set in beforeSend. Setting it on the scope once the running bundle is confirmed is a few lines I haven’t written yet.

For now, an issue tells me what crashed, on which release, OTA bundle and device, and what the person was doing just before. It doesn’t tell me where in the source. For a small app that ships a few times a week, the type, the message and the breadcrumbs have been enough to find things so far.

The receiver is about 650 lines (the parser, grouping, alerting, the model and the view), the admin page another 700 or so, the tests about 400, and it costs nothing on top of the API that was already running. The SDK does the hard part, which is catching a crash on a phone and getting the report off it on the next launch.