Crash Reporting Strategy
Instructions
Crash reports are the most reliable signal from real users. Without a disciplined strategy they become noise. Good crash reporting gives you an actionable ranked list, readable stack traces, and an SLO you can hold yourself to.
1. Pick a Reporter (or two)
| Reporter |
Strengths |
Notes |
| Firebase Crashlytics |
Free, deep Android + iOS support |
Default for most teams |
| Sentry |
Unified across mobile, web, backend; release health; rich context |
Pair with Sentry for tracing/logging |
| Bugsnag |
Release stability scores |
Paid |
| Instabug, Embrace, Shake |
Session replay, user-context |
Heavier footprint |
| Xcode Organizer + Play Console |
Always-on, no SDK needed |
Limited grouping, no pre-release |
Running two reporters is reasonable (e.g., Crashlytics for crash signal + Sentry for release health + logs). Two is the max.
2. Symbolication
Unsymbolicated stack traces are useless. Upload debug symbols on every release.
- iOS: upload
dSYMs after every archive. Crashlytics and Sentry both have Fastlane plugins. Bitcode is deprecated; modern builds no longer require the bitcode compiler re-symbolication dance.
- Android: upload ProGuard/R8 mapping and native symbol files (
.so debug symbols) per build.
- Flutter: upload
--split-debug-info symbols.
- React Native: upload Hermes bundle + source map for the JS side, plus native symbols for the native side.
Automate upload in the pipeline; fail the release if upload fails.
3. Release Tagging
Every event carries:
app.version and app.build.
env (dev, staging, prod).
flavor if applicable.
releaseStage (beta, production).
deviceModel, osVersion, locale.
userId as a hashed id, never PII.
- Current screen / feature flag state on crash.
Crashlytics.crashlytics().setUserID(hashedUserId)
Crashlytics.crashlytics().setCustomValue(flags.value("checkout_v2"), forKey: "checkout_v2")
FirebaseCrashlytics.getInstance().setUserId(hashedUserId)
FirebaseCrashlytics.getInstance().setCustomKey("checkout_v2", flags.bool("checkout_v2"))
4. Breadcrumbs and Logs
- Record navigation events, network requests (method + path only, no query strings with secrets), and important state transitions as breadcrumbs.
- Attach the last 20-50 breadcrumbs to each crash.
- Do not log PII, tokens, or message content.
- Use
CLS_LOG / SentryBreadcrumb or equivalent; avoid sending every print to the reporter.
5. Handled Exceptions
Not every error is a crash. Handle errors in domain code, report the serious ones:
FirebaseCrashlytics.recordException(...) or Sentry.captureException(...) for errors that indicate a bug, not user input.
- Tag them with severity so they do not dominate the crash list.
- Never report user-cancelled or offline-expected errors.
6. SLOs
Define crash SLOs and display them on a dashboard.
- Crash-free sessions: 99.8% (typical target for a mature consumer app; higher for fintech/health).
- Crash-free users: 99.5%.
- ANR rate (Android): below 0.47% (Play Console threshold).
- Foreground hang rate (iOS): monitor via Xcode Organizer "Hangs" metric.
Gate rollouts on these (see staged-rollouts).
7. Triage Workflow
- Daily triage during active release. New or regressed issues get an owner within 24 hours.
- Group issues by stack trace fingerprint; the reporter does this, but review for false groups.
- Rank by user impact, not raw count. A crash affecting 0.5% of users in onboarding beats 50 crashes in a settings corner.
- Assign owners per area (payments, sync, onboarding) so issues route fast.
- Version hygiene: do not fight crashes from two versions ago; focus on current + previous.
8. Actionable Rules
- Every new-in-release issue must be acknowledged before the next rollout step.
- Every top-10 issue has an assignee, a hypothesis, and a reproduction attempt.
- Every fix must reference the issue ID in the commit message.
- Crashes that cannot be reproduced get a defensive fix (null check, guard) only if the context justifies it, never a silent swallow.
9. Beware of Blind Spots
- Early-boot crashes: many reporters require the SDK to be initialized before the crash. Pair with a native
uncaughtException logger or platform breadcrumbs.
- Background processes: some reporters do not capture crashes in separate processes (widgets, app extensions,
isolates). Configure per process.
- ProcessLifecycle on Android differs from Activity lifecycle. Use the broader one for app-level breadcrumbs.
- React Native: JS errors and native errors are different worlds. Capture both.
- Flutter: Dart errors via
FlutterError.onError and PlatformDispatcher.instance.onError (async errors) separately.
void main() {
runZonedGuarded(() {
FlutterError.onError = (details) => Crashlytics.recordError(details.exception, details.stack);
PlatformDispatcher.instance.onError = (e, s) { Crashlytics.recordError(e, s); return true; };
runApp(const MyApp());
}, (e, s) => Crashlytics.recordError(e, s));
}
10. Anti-Patterns
- Shipping without symbol upload. You will regret it.
- Logging PII or secrets in breadcrumbs.
- Silencing crashes with broad
catch blocks to clean up dashboards.
- Chasing old-version tails. Fix current and previous; accept drift beyond that.
- Ignoring ANRs because "they are not crashes". Users see them the same way.
- One reporter with no native-level coverage on a platform.
Checklist
1---2name: crash-reporting-strategy3description: Crash reporting for mobile - symbolication, SLOs, tagging, triage workflow, and comparing Crashlytics, Sentry, Bugsnag, Firebase, and native (APM) reporters. Use when setting up or improving crash reporting.4---56# Crash Reporting Strategy78## Instructions910Crash reports are the most reliable signal from real users. Without a disciplined strategy they become noise. Good crash reporting gives you an actionable ranked list, readable stack traces, and an SLO you can hold yourself to.1112### 1. Pick a Reporter (or two)1314| Reporter | Strengths | Notes |15| --- | --- | --- |16| Firebase Crashlytics | Free, deep Android + iOS support | Default for most teams |17| Sentry | Unified across mobile, web, backend; release health; rich context | Pair with Sentry for tracing/logging |18| Bugsnag | Release stability scores | Paid |19| Instabug, Embrace, Shake | Session replay, user-context | Heavier footprint |20| Xcode Organizer + Play Console | Always-on, no SDK needed | Limited grouping, no pre-release |2122Running two reporters is reasonable (e.g., Crashlytics for crash signal + Sentry for release health + logs). Two is the max.2324### 2. Symbolication2526Unsymbolicated stack traces are useless. Upload debug symbols on every release.2728- **iOS**: upload `dSYM`s after every archive. Crashlytics and Sentry both have Fastlane plugins. Bitcode is deprecated; modern builds no longer require the bitcode compiler re-symbolication dance.29- **Android**: upload **ProGuard/R8 mapping** and **native symbol files (`.so` debug symbols)** per build.30- **Flutter**: upload `--split-debug-info` symbols.31- **React Native**: upload **Hermes bundle + source map** for the JS side, plus native symbols for the native side.3233Automate upload in the pipeline; fail the release if upload fails.3435### 3. Release Tagging3637Every event carries:3839- `app.version` and `app.build`.40- `env` (dev, staging, prod).41- `flavor` if applicable.42- `releaseStage` (beta, production).43- `deviceModel`, `osVersion`, `locale`.44- `userId` as a hashed id, never PII.45- Current screen / feature flag state on crash.4647```swift48Crashlytics.crashlytics().setUserID(hashedUserId)49Crashlytics.crashlytics().setCustomValue(flags.value("checkout_v2"), forKey: "checkout_v2")50```5152```kotlin53FirebaseCrashlytics.getInstance().setUserId(hashedUserId)54FirebaseCrashlytics.getInstance().setCustomKey("checkout_v2", flags.bool("checkout_v2"))55```5657### 4. Breadcrumbs and Logs5859- Record navigation events, network requests (method + path only, no query strings with secrets), and important state transitions as breadcrumbs.60- Attach the last 20-50 breadcrumbs to each crash.61- Do not log PII, tokens, or message content.62- Use `CLS_LOG` / `SentryBreadcrumb` or equivalent; avoid sending every `print` to the reporter.6364### 5. Handled Exceptions6566Not every error is a crash. Handle errors in domain code, report the serious ones:6768- `FirebaseCrashlytics.recordException(...)` or `Sentry.captureException(...)` for errors that indicate a bug, not user input.69- Tag them with severity so they do not dominate the crash list.70- Never report user-cancelled or offline-expected errors.7172### 6. SLOs7374Define crash SLOs and display them on a dashboard.7576- **Crash-free sessions**: 99.8% (typical target for a mature consumer app; higher for fintech/health).77- **Crash-free users**: 99.5%.78- **ANR rate** (Android): below 0.47% (Play Console threshold).79- **Foreground hang rate** (iOS): monitor via Xcode Organizer "Hangs" metric.8081Gate rollouts on these (see `staged-rollouts`).8283### 7. Triage Workflow8485- **Daily triage** during active release. New or regressed issues get an owner within 24 hours.86- Group issues by stack trace fingerprint; the reporter does this, but review for false groups.87- **Rank by user impact**, not raw count. A crash affecting 0.5% of users in onboarding beats 50 crashes in a settings corner.88- **Assign owners** per area (payments, sync, onboarding) so issues route fast.89- **Version hygiene**: do not fight crashes from two versions ago; focus on current + previous.9091### 8. Actionable Rules9293- Every new-in-release issue must be acknowledged before the next rollout step.94- Every top-10 issue has an assignee, a hypothesis, and a reproduction attempt.95- Every fix must reference the issue ID in the commit message.96- Crashes that cannot be reproduced get a defensive fix (null check, guard) only if the context justifies it, never a silent swallow.9798### 9. Beware of Blind Spots99100- **Early-boot crashes**: many reporters require the SDK to be initialized before the crash. Pair with a native `uncaughtException` logger or platform breadcrumbs.101- **Background processes**: some reporters do not capture crashes in separate processes (widgets, app extensions, `isolate`s). Configure per process.102- **ProcessLifecycle on Android** differs from Activity lifecycle. Use the broader one for app-level breadcrumbs.103- **React Native**: JS errors and native errors are different worlds. Capture both.104- **Flutter**: Dart errors via `FlutterError.onError` and `PlatformDispatcher.instance.onError` (async errors) separately.105106```dart107void main() {108 runZonedGuarded(() {109 FlutterError.onError = (details) => Crashlytics.recordError(details.exception, details.stack);110 PlatformDispatcher.instance.onError = (e, s) { Crashlytics.recordError(e, s); return true; };111 runApp(const MyApp());112 }, (e, s) => Crashlytics.recordError(e, s));113}114```115116### 10. Anti-Patterns117118- Shipping without symbol upload. You will regret it.119- Logging PII or secrets in breadcrumbs.120- Silencing crashes with broad `catch` blocks to clean up dashboards.121- Chasing old-version tails. Fix current and previous; accept drift beyond that.122- Ignoring ANRs because "they are not crashes". Users see them the same way.123- One reporter with no native-level coverage on a platform.124125## Checklist126127- [ ] A primary crash reporter is integrated; optional secondary is justified.128- [ ] Symbol uploads happen on every release for every platform.129- [ ] Events tagged with version, build, env, flag state, and hashed user id.130- [ ] Breadcrumbs record navigation, network, and state transitions; no PII.131- [ ] Crash-free session / user SLOs are defined and displayed.132- [ ] Triage workflow has named owners and daily cadence during releases.133- [ ] Blind spots covered: early-boot, background processes, JS/native split.134- [ ] Fixes reference issue IDs in commits for traceability.135- [ ] Handled exceptions are reported selectively with severity tags.