Error grouping
Sentry groups events into issues by hashing the stack trace. That works when the stack points at our code, and fails when it doesn't — every event from a shared wrapper looks identical, so unrelated failures pile into one issue and a real regression hides inside the noise.
The app overrides that grouping in two places: at individual call sites, and centrally in beforeSend.
Fingerprints at the call site
This is the normal way to control grouping here. Pass fingerprint to captureException and Sentry uses it instead of the stack:
Sentry.captureException(new Error(`Token refresh failed (${reason})`), {
level: 'error',
tags: {source: 'token-refresh', origin: source, status, is_timeout: String(hasTimedOut)},
fingerprint: ['token-refresh-failure', source, reason],
});The convention across the codebase is a stable prefix naming the failure, then the axes worth splitting on:
| Fingerprint | Where |
|---|---|
['token-refresh-failure', source, reason] | src/utils/errors/errors.ts |
['network-unreachable'] | src/services/middleware/rtkQueryErrorLogger.middleware.ts |
[endpointName, requestMethod, statusCode] | src/services/middleware/rtkQueryErrorLogger.middleware.ts |
['document-upload', 's3-upload', status] | src/services/user/user.api.ts |
['login-failed', statusCode, errorCode] | src/services/user/user.actions.async.ts |
['max-pagination-limit', endpointName] | src/utils/pagination/checkMaxPaginationReached.ts |
['channel-switch-failed'] | src/utils/updates/switchChannel.ts |
Two rules that matter more than they look:
- Every component must be stable across occurrences. A uuid, a file path, or an object identity hash in a fingerprint mints a new issue per event. Use
sanitizeUrlForGroupingfromsrc/utils/url/urlSanitization.tswhen a URL has to go in. - Reuse an existing prefix when it is the same failure. A second spelling of
network-unreachablecreates a rival issue rather than adding to the one people already watch.
beforeSend
src/utils/sentry/beforeSend.ts is the last thing to touch an event before it leaves the device. It lives in its own module rather than inline in Sentry.init because _layout.tsx runs Sentry.init at import time and eagerly builds integrations, so nothing defined in that file can be reached from a test.
It does two things.
Breadcrumb tidying. XHR breadcrumbs get a human-readable name from urlMapping.ts — so a trail reads Get jobs rather than a raw URL, with the URL still attached — and http and ui.lifecycle breadcrumbs are dropped. Native and JS each add a breadcrumb for the same request: http from native, xhr from JS. The SDK dedupes them itself from @sentry/react-native 8.12.0; this project is on 8.11.0, so the filter is still doing work and should be deleted when the SDK is bumped.
Expo error fingerprinting, below. This is the one grouping decision the app makes centrally, because the errors it applies to have no single call site worth attaching it to.
Why expo errors need central handling
Every error thrown by an expo native module is a CodedError constructed inside expo-modules-core. They all carry the same babel-only stack with no app frames, so Sentry's default grouping files all of them together. In this project that produced ENGINEER-APP-4Y: one issue holding 35 distinct failures, ~8,900 events and ~1,575 users. A keychain failure, a gallery-picker failure and an offline update check were all the same issue.
src/utils/sentry/expoErrorFingerprint.ts gives them a key:
['expo-coded-error', <module.function | first line of the message>]The failing function comes from the platform wording — Android says Call to function 'ExpoUpdates.checkForUpdateAsync' has been rejected., iOS says Calling the 'getValueWithKeyAsync' function has failed. iOS synchronous functions report through debugDescription, which adds a class-name prefix and a throw site, so both shapes are accepted.
The key is what failed, not why. The native cause — everything after → Caused by: — is deliberately excluded. Causes carry object identity hashes, file paths and uuids that differ on every occurrence; the Android gallery failure alone has 122 variants, so including the cause would shatter one bug across 122 issues. Every cause of a given call has the same fix anyway. The cause is still in the message, one click away.
When the message has no function-call wording, the first line becomes the key — but only if the throwable also has an expo-shaped ERR_* code. Without that gate any error with a string code gets pulled in: @react-native-firebase uses messaging/unregistered, axios uses ERR_NETWORK, and both would lose their existing grouping. That fallback is capped at 80 characters so an unrecognised message cannot carry volatile data into the key.
A fingerprint set at the call site always wins — applyExpoErrorFingerprint never overwrites one.
Reading it in Sentry
Fingerprinted events carry expo_error_key (the failing function, or the capped first line) and expo_error_code (the expo ERR_* code, when present). Group by the key in Explore → Discover to see the real shape of expo failures:
environment:production has:expo_error_keyhas:expo_error_key is also how you tell whether a client is running this code at all.
Coupling to expo's wording
The parser reads two literal formats out of expo's native source. Across 15 expo-modules-core releases they have changed once in 4.6 years, both are asserted exactly in expo's own CI, and both are byte-identical in SDK 56, SDK 57 and main. The one historical change did not appear in a CHANGELOG, so release notes will not warn you.
If the wording changes, getExpoErrorFingerprint stops matching and returns undefined, and Sentry falls back to its own grouping — the state described at the top of this page. Nothing silently merges two different bugs. After an expo SDK bump this grep is enough of a check:
grep -r "has been rejected\|function has failed" node_modules/expo-modules-coreWhat is deliberately not reported
One call site drops its error instead of creating an issue: src/utils/updates/useOtaUpdateMonitor.ts. The hourly OTA check fails whenever an engineer is out of signal, which is expected rather than a defect — it retries on its own, nothing reads the result, and the engineer sees nothing.
It needs no breadcrumb of our own either: the SDK ships expoUpdatesListenerIntegration as a default integration, which subscribes to expo-updates' native state machine and already records Update check failed, Update download failed, Rollback directive received and the successful transitions under the expo.updates category.
The channel switcher next door does the opposite and reports every failure, including a reloadAsync that rejects after the update was fetched. It is non-production tooling, but QA and E2E triage start from Sentry when a build will not take a channel, and the channel-switch-failed fingerprint keeps those events in one issue of their own rather than inside the expo grouping.
Two more things that come free from the same family of default integrations, worth knowing before you add your own: expoContextIntegration attaches the ota_updates context (channel, update id, runtime version, launch duration) to every event, and it captures an event of its own when the app starts in emergency launch — Expo Updates emergency launch: <reason> — which is the signal that an update shipped and failed to boot.
The debug drawer's own OTA controls (OtaUpdateConfig, staging only) still capture to Sentry with a source tag — that is deliberate, and it is why staging keeps some OTA noise that production does not.
Checks after the expo change ships
The change takes effect for a client once it has the build; older clients keep reporting into ENGINEER-APP-4Y until they upgrade.
Explore → Discover, errors dataset,
environment:production has:expo_error_key, grouped byexpo_error_key. Any rows mean the new path is live on real devices. Still empty after six hours with confirmed adoption means it is not working.Same view,
expo_error_key:"ExpoUpdates.checkForUpdateAsync"must return zero in production. A row here means some call site other than the update monitor still captures the OTA check. ThefetchUpdateAsyncequivalent appears in staging only — that is the debug drawer.The
Unknown error: …family drops by roughly 90%, not to zero:shareDocument.tsandshareInvoice.tsxcapture download failures that produce byte-identical messages. Leftovers there are a real, user-facing residual rather than a sign the change failed — fingerprinting those two call sites is the follow-up.Archive
ENGINEER-APP-4Yonce the new issues are established, with "until this occurs again after 50 times in one hour". Do not resolve it — resolving turns the next event from an old client into a regression and re-alerts.
Measure the volume drop as a rate, not a count. Per-release event counts in this project track how many engineers are on each version, not code quality — a two-day-old release looks clean purely because few people run it.