| name | crash-reporting-strategy |
| description | 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. |
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