Crash Reporting and Monitoring in Flutter: What I Set Up Before Launch
How I set up crash reporting and monitoring in Flutter with Crashlytics or Sentry: error hooks, symbol uploads, privacy-safe context, alerts, analytics, weekly reviews.

The worst way to find out your app is crashing is a one-star review that says “doesn’t even open.” The second worst is a client forwarding you that review and asking what happened, while you have no stack trace, no device info and no idea how many people are affected.
Most users never report a crash. They just leave. Crash reporting is how you hear from the people who didn’t bother to tell you.
When I take over an existing app, adding crash reporting is often my first change if it isn’t already there. On new apps it goes in before the first build reaches testers. Here’s the setup I use, with Firebase Crashlytics as the main example and notes for Sentry.
Crashlytics or Sentry?
Both are solid, and both have good Flutter SDKs:
- Firebase Crashlytics (
firebase_crashlytics) is free, simple, and a natural fit if you already use Firebase for auth, push or analytics. Its crash-free users metric and velocity alerts cover what most apps need. - Sentry (
sentry_flutter) is stronger on context: breadcrumbs, release health, performance tracing and rich issue workflows. It also works across your backend, which is handy if you want one place for both app and API errors.
If the app is already on Firebase, I usually start with Crashlytics. If the team wants one tool across mobile, web and backend, Sentry is a good choice. The principles below apply to both.
Catching every error
Flutter has two main places where uncaught errors end up, and you need to hook both:
- Framework errors (build, layout, paint) go to
FlutterError.onError. - Uncaught asynchronous errors go to
PlatformDispatcher.instance.onError.
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Don't send reports from debug builds.
await FirebaseCrashlytics.instance
.setCrashlyticsCollectionEnabled(!kDebugMode);
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true; // handled
};
runApp(const App());
}
You’ll still see runZonedGuarded in older tutorials. Since PlatformDispatcher.onError became available, I only use a zone when I specifically need zone-level behavior, such as capturing print calls. It also adds a classic trap: ensureInitialized() and runApp() must run in the same zone, or you get a zone mismatch warning.
With Sentry, SentryFlutter.init installs these handlers for you, and you pass your runApp call as the appRunner.
Readable stack traces: symbols and dSYMs
If you build release apps with obfuscation (and you should), your stack traces are gibberish until you upload symbols:
flutter build appbundle --obfuscate --split-debug-info=build/symbols
flutter build ipa --obfuscate --split-debug-info=build/symbols
# Upload the Dart symbols to Crashlytics (once per platform build)
firebase crashlytics:symbols:upload --app=<FIREBASE_APP_ID> build/symbols
On iOS you also need the dSYM files for native frames. The Crashlytics setup adds a build phase that uploads them, but it’s worth checking in the console after your first release build. Missing dSYMs are the most common reason iOS crashes show up as unreadable addresses.
Do this in CI, not by hand. A manual step gets forgotten on exactly the release that crashes. (My pipeline setup is in Flutter CI/CD with Azure DevOps.) Sentry has the same idea through its Dart plugin, which uploads debug symbols and source context as part of the build.
Non-fatal errors are where the value is
Crashes are the obvious problems. The quieter ones are caught exceptions: a failed payment confirmation, a JSON field that’s suddenly null, a sync that silently gave up. The app doesn’t crash, but the user’s experience is broken.
I log those as non-fatals at the boundaries where I already catch exceptions, usually in repositories:
try {
await _api.confirmBooking(id);
} catch (error, stack) {
FirebaseCrashlytics.instance.recordError(
error,
stack,
reason: 'confirmBooking failed',
fatal: false,
);
rethrow; // let the UI show its error state
}
Don’t log everything. Expected situations, like no network or a user cancelling a login, are noise. Log the things you’d want to fix.
Context without personal data
A stack trace tells you where it broke. Context tells you why. I add:
- A user identifier, hashed. It lets you count affected users and find related issues, without storing who they are.
- Custom keys for state that matters: subscription status, current feature flag values, locale, whether the user is offline.
- Breadcrumbs (
login Crashlytics, breadcrumbs in Sentry) for key steps: screen views, “started checkout”, “sync finished”.
Future<void> setCrashContext(AppUser user) async {
final hashedId = sha256.convert(utf8.encode(user.id)).toString();
final crashlytics = FirebaseCrashlytics.instance;
await crashlytics.setUserIdentifier(hashedId);
await crashlytics.setCustomKey('plan', user.isPremium ? 'premium' : 'free');
await crashlytics.log('Session started');
}
The hard rule: no personal data. No emails, names, phone numbers, addresses, tokens or health information in logs, keys or breadcrumbs. It’s a privacy risk and a store compliance problem, and you’ll have to disclose it. (sha256 above comes from the crypto package.) There’s more on this in the mobile app security checklist for Flutter.
Release health and crash-free users
The single most useful number is crash-free users per release: the share of users who didn’t hit a crash. Both Crashlytics and Sentry show it per version, so you can compare a new release with the last one.
That’s what makes staged rollouts work. Release to a small percentage of users on Google Play (or use phased release on the App Store), watch crash-free users for a day or two, and only then go to everyone. If the number drops, halt the rollout and fix it before most users ever see the problem.
Alerts that people actually read
Dashboards don’t help if nobody looks at them. I set up a small number of alerts:
- New fatal issue in the latest release.
- Regressed issue: something marked fixed that came back.
- Velocity alert: an issue affecting an unusual number of users quickly.
Send them to wherever the team already works, like email or a team chat channel. Keep them few. If alerts fire constantly, people mute them, and then you’re back to learning about crashes from reviews.
Analytics for funnels
Crashes tell you when the app breaks. Analytics tell you where users give up even when nothing breaks. I log a handful of events for the key funnel, for example sign-up, onboarding and first purchase:
await FirebaseAnalytics.instance.logEvent(
name: 'checkout_started',
parameters: {'items': cart.length},
);
If most people who start checkout never finish and there are no errors, that’s a UX problem worth looking at. Same rule as before: no personal data in event parameters.
Performance monitoring
Slow is the new crash. Firebase Performance Monitoring and Sentry’s tracing both give you app start time, network request durations and slow or frozen frames. I add custom traces around the few operations that matter, such as loading the home screen or completing a sync, so I can tell when a release makes them slower. When the numbers look bad, fixing jank in Flutter apps covers what I do next.
The weekly review habit
Tools are only half of it. The other half is a short, regular review. Once a week, I spend about half an hour on:
- Crash-free users for the current release versus the previous one.
- The top five issues by affected users. Are they new, fixed, or being worked on?
- New non-fatals. Is anything quietly failing?
- The key funnel. Did any step get worse?
- Slowest traces and screens.
Then I turn the top items into tickets. Small, consistent attention keeps the list short. Skipping it for two months is how you end up with a hundred open issues and no idea which matter.
Takeaways
- Set up crash reporting before the first tester build, not after launch.
- Hook both
FlutterError.onErrorandPlatformDispatcher.instance.onError; reach forrunZonedGuardedonly when you need it. - Upload Dart symbols and iOS dSYMs automatically in CI so traces are readable.
- Log non-fatals at real failure points, and add context with hashed IDs and no personal data.
- Use crash-free users with staged rollouts, and keep alerts few and meaningful.
- Review crashes, funnels and performance weekly.
The goal isn’t zero crashes on day one. It’s never being the last to know.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).