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 already lands in a team chat 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 a small endpoint on the API, which logs it at ERROR, so it reaches the chat channel 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 a small piece of the Django app there receives what it sends, groups it, alerts on it, and keeps 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/1and 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-nativearrives as plain text, while a native crash from sentry-cocoa or sentry-android arrives withContent-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
\nyou’ll mangle every event the native SDKs send. You have to honourlengthwhen it’s there.
The parser is short. 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-Afterwhen 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 becomes one issue, and each report is stored as an event under it. The issue only logs at ERROR (which is what the chat alerts listen 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 chat handler’s own rate limit on top, 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 older ones as new ones arrive. That broke my first chart, because 50 events can’t tell you how often something happened last week, so each issue also keeps a running count per day for 90 days, updated in the same transaction as the counter. The chart draws from the counts; the events are only for reading.
Two small races are worth handling if you build one of these. When a brand-new crash hits several phones at once, two reports can both try to create the issue, so make the fingerprint unique and have the loser re-read the existing row. And measure the 90-day window from the server’s today, not from the newest report, because a phone with its clock set wrong can date a report anywhere, including next year.
Payloads are capped at 128KB. An event over that loses its breadcrumbs and debug metadata first, and if it’s still too big only the exception, message, release and platform are kept, with a 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 requests carry the search term in the query string, 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 small module, for the same reason I wrap the over-the-air update SDK: an OTA 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:
import { NativeModules, Platform, TurboModuleRegistry } from "react-native";
import Constants from "expo-constants";
const DSN = process.env.EXPO_PUBLIC_CRASH_DSN;
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,
// ...turn off the other tracing options the same way
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 build profiles set the DSN and an environment (staging or production), so both kinds of build report to the same endpoint and can be told apart. The signed-in user is set by account ID, 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 fix is a small wrapper that calls captureException in a useEffect and then renders the router’s boundary as before, used for the root and per-route boundaries alike. 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.
For the web there’s a .web.ts version of the module 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.
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 had updated 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 8000 and a throwaway key:
EXPO_PUBLIC_CRASH_DSN=http://localcrashtestkey0000@localhost:8000/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 :8000 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 1.4.0 iOS 27.1 iPhone19,4 handled=False
received 09:19:53, crashed 09:19:38
contexts: app, device, os, ota_updates, trace · 24 breadcrumbsFifteen 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 account ID. 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 JSbeforeSendadds anotatag 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 withsetTagonce 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 the analytics SDK’s own requests to its collection endpoint, 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 reports are read in the Django admin, on an issue page loosely modelled on Sentry’s: counts and Resolve / Ignore / Reopen at the top, a bar per day, the split by release, OS, device and OTA bundle, then one event at a time with its exception, its frames (app frames highlighted, library frames folded away), the breadcrumbs and the device context. It didn’t need a new dependency.
One fix came out of building it: for a native crash, the “culprit” frame was the binary’s path on the device, which includes a per-install UUID, so the same crash looked different on every phone. It now shows only the address and the binary name, like 0x1000a1b2c (MyApp).
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 push tokens and passkeys 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 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, the admin page and the tests aren’t a lot of code, 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.
