Most Flutter teams we join can tell you their crash-free rate. Very few can answer the next question: why did that checkout screen take eleven seconds for 4% of users last Tuesday? Crash reporting is table stakes; observability — knowing what the app is doing in the field, for whom, and how slowly — is what actually shortens incident calls.
This tutorial wires up a production observability stack for a Flutter app: structured crash and error capture, breadcrumbs that survive isolates, performance tracing with OpenTelemetry-compatible headers, release-mode symbolication for obfuscated builds, and a sampling and privacy policy that keeps legal happy. It assumes Flutter 3.3x/3.4x.
What you actually need to instrument
Four signals, in order of payback:
- Crashes and uncaught Dart errors — native crashes, Dart exceptions, and platform-channel failures.
- Handled errors — the ones you catch and swallow. These are where silent data loss lives.
- Traces — app start, route transitions, network calls, and a handful of business transactions (login, checkout, sync).
- Logs/breadcrumbs — a short, structured trail attached to every report.
Anything else (custom dashboards, session replay) is optional. Get these four right first.
Choosing a stack
Two combinations cover most engagements:
| Need | Crashlytics + Firebase Performance | Sentry (sentry_flutter) |
|---|---|---|
| Native crash fidelity (JVM/NDK, iOS) | Excellent | Good |
| Dart-level error grouping | Weak-ish | Excellent |
| Traces with custom spans | Fixed traces + HTTP | Full transaction/span model |
| Self-hosting / data residency | No | Yes |
| Release health (crash-free sessions) | Yes | Yes |
Plenty of teams run both: Crashlytics for native crash coverage, Sentry for Dart errors and tracing. The cost is double SDK weight and duplicate noise, so decide deliberately. Below we use sentry_flutter as the primary and note the Crashlytics equivalent where relevant.
Step 1: capture everything, once, at the root
Flutter has three distinct error paths: the framework (FlutterError.onError), the Dart isolate (PlatformDispatcher.instance.onError), and native. Miss one and you lose whole classes of bug.
dependencies:
sentry_flutter: ^8.9.0
sentry_dio: ^8.9.0 # if you use Dio
logging: ^1.3.0
// lib/main.dart
Future<void> main() async {
await SentryFlutter.init(
(options) {
options.dsn = const String.fromEnvironment('SENTRY_DSN');
options.environment = const String.fromEnvironment('ENV', defaultValue: 'dev');
options.release = '$appName@$version+$buildNumber';
// Sampling: 100% of errors, a slice of traces.
options.tracesSampleRate = 0.1;
options.profilesSampleRate = 0.1; // iOS/macOS profiling
options.attachScreenshot = false; // see privacy section
options.sendDefaultPii = false;
options.beforeSend = (event, hint) => _scrub(event);
},
appRunner: () => runApp(
SentryWidget(child: const MyApp()),
),
);
}
SentryFlutter.init already hooks FlutterError.onError and the isolate error handler. If you are on Crashlytics instead, do it explicitly:
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};
Background isolates are a separate world
An error thrown inside a compute() callback or a workmanager task does not pass through your main-isolate handlers. Register there too:
@pragma('vm:entry-point')
void syncEntryPoint() {
Isolate.current.addErrorListener(RawReceivePort((List<dynamic> pair) async {
await Sentry.captureException(pair.first, stackTrace: pair.last);
}).sendPort);
// ... work
}
This single fix is often worth the whole exercise: background sync and push-handling isolates are where the unexplained data bugs hide.
Step 2: make handled errors visible
Catch-and-log is fine, as long as "log" means "log somewhere you look". Standardise on one repository-level helper so every layer reports the same shape.
Future<T> guarded<T>(
String op,
Future<T> Function() body, {
Map<String, Object?> context = const {},
}) async {
final span = Sentry.getSpan()?.startChild('repo.$op');
try {
final result = await body();
span?.status = const SpanStatus.ok();
return result;
} catch (e, st) {
span?.throwable = e;
span?.status = const SpanStatus.internalError();
await Sentry.captureException(
e,
stackTrace: st,
withScope: (scope) {
scope.setContexts('operation', {'name': op, ...context});
scope.level = SentryLevel.warning;
},
);
rethrow;
} finally {
await span?.finish();
}
}
Two habits make grouping usable: never re-wrap errors in a generic Exception('failed') (you destroy the stack fingerprint), and set an explicit fingerprint for errors you know are one family, e.g. scope.fingerprint = ['api', endpoint, statusCode.toString()].
Step 3: trace the transactions that matter
Resist instrumenting everything. Pick the flows a product owner would name, and give each a transaction with meaningful child spans.
final tx = Sentry.startTransaction('checkout', 'business',
bindToScope: true, description: 'Cart -> order confirmed');
try {
final cart = await tx.startChild('load.cart').run(cartRepo.load);
final quote = await tx.startChild('api.quote').run(() => api.quote(cart));
await tx.startChild('api.submit').run(() => api.submit(quote));
tx.status = const SpanStatus.ok();
} catch (e) {
tx.throwable = e;
tx.status = const SpanStatus.internalError();
rethrow;
} finally {
await tx.finish();
}
For routing, add the navigator observer so screen loads become transactions automatically — with go_router, pass observers: [SentryNavigatorObserver()] into the GoRouter constructor.
For HTTP, wrap the client rather than each call site: SentryHttpClient or dio.addSentry() attaches spans plus sentry-trace and baggage headers, so a mobile trace links to the backend trace for the same request. If your backend speaks OpenTelemetry, that propagation is the whole point: one trace ID from tap to database.
App start is measured for you (cold/warm start via the native SDK), but mark the moment your app is genuinely usable:
await SentryFlutter.reportFullyDisplayed();
Call this after first meaningful paint — post-auth-check, post-first-data — not in initState.
Step 4: breadcrumbs, not print()
Route logging (or your own logger) into the SDK so the last 50 events ride along with every report:
Logger.root.onRecord.listen((rec) {
Sentry.addBreadcrumb(Breadcrumb(
message: rec.message,
category: rec.loggerName,
level: rec.level.value >= 1000
? SentryLevel.error
: rec.level.value >= 900
? SentryLevel.warning
: SentryLevel.info,
data: {'seq': rec.sequenceNumber},
));
});
Rules that keep breadcrumbs useful: no user-entered text, no tokens, no full URLs with query strings, and one breadcrumb per state change rather than per frame.
Step 5: symbolication — or your release stacks are useless
This is the step teams skip and regret. A release build with --obfuscate produces stack traces full of hex offsets. You must upload the debug info produced at build time.
flutter build appbundle --release \
--obfuscate --split-debug-info=build/symbols/android
flutter build ipa --release \
--obfuscate --split-debug-info=build/symbols/ios
Then upload, in the same CI job that produced the binary:
dart pub global activate sentry_dart_plugin
# reads the `sentry:` block in pubspec.yaml: org, project, upload_debug_symbols
dart run sentry_dart_plugin
For Crashlytics, the analogous steps are the Gradle plugin with nativeSymbolUploadEnabled true, firebase crashlytics:symbols:upload for NDK symbols, and uploading iOS dSYMs from the archive.
Non-negotiables:
- The
releasestring in your SDK config must match the version the symbols were uploaded under. Derive both from the same CI variable. - Archive
build/symbols/as a CI artifact. If you lose it, old crashes are permanently unreadable. - Verify on a real release build before you ship: trigger a test crash and confirm the dashboard shows Dart file names and line numbers.
Step 6: sampling, quotas, and cost
Error volume is bursty — one bad release can burn a month of quota in an hour. Defend the budget:
options.tracesSampler = (ctx) {
final name = ctx.transactionContext.name;
if (name == 'checkout') return 1.0; // always trace revenue paths
if (name.startsWith('/debug')) return 0.0;
return 0.05;
};
options.maxBreadcrumbs = 50;
Drop, don't sample, known-noisy classes (network timeouts, user-cancelled operations) in beforeSend by returning null, and track those as counters instead so you still see trends.
Step 7: privacy, by construction
For any client with GDPR, HIPAA, or a serious security review in scope, decide this before the first event is sent:
sendDefaultPii = false, and never put email or phone inSentryUser. Use a salted, rotatable pseudonymous ID.- Scrub in
beforeSend: strip query strings, drop request bodies, redact headers, and remove exception messages that interpolate user data. Better still: don't interpolate user data into exception messages. - Screenshots and session replay stay off unless the client explicitly opts in, and then with masking on.
- Document data residency (EU vs US ingest region, or self-hosted) and the retention window in your handover notes.
SentryEvent? _scrub(SentryEvent event) {
final req = event.request;
return event.copyWith(
request: req?.copyWith(queryString: null, data: null, cookies: null),
user: event.user?.copyWith(ipAddress: null, email: null),
);
}
Assert the scrubbing in a unit test. It is the one piece of observability code a reviewer will ask you to prove.
Step 8: close the loop with alerts and release health
Data nobody watches is a cost centre. Minimum viable alerting:
- Crash-free sessions drops below your threshold (say 99.5%) on the newest release → page whoever is on release duty.
- New issue in latest release → channel notification with the owning team.
- p95 of
checkoutregresses more than 30% week over week → ticket, not a page. - Error-rate spike measured relative to the previous release, not an absolute number — absolute thresholds break at every traffic change.
Then make review a ritual: ten minutes at the start of each sprint, top five issues by affected users, each one fixed, fingerprinted, or explicitly muted with a reason. Issue lists that are never triaged stop being read within a month.
Verification checklist before you call it done
- A test crash from a release build appears with readable Dart line numbers.
- An error thrown in a background isolate shows up.
- A handled error from
guarded()arrives as a warning with operation context. - A mobile trace and its backend trace share one trace ID.
-
beforeSenddemonstrably strips query strings and PII. - Symbol upload runs in CI on every release build, and symbols are archived.
- Alerts fire to a channel a human actually reads.
Where teams get it wrong
The three recurring failures we see on audits: obfuscated releases with no symbol upload, so a year of crash data is unreadable; 100% trace sampling that exhausts quota until someone quietly disables the SDK; and no isolate error handling, so the most damaging bugs — background sync corrupting local data — never generate a single report.
Observability is not a dashboard purchase. It is a small amount of code at the root of the app, one helper used consistently by every layer, a CI step that never gets skipped, and a triage habit.
Need help instrumenting an existing Flutter app, or a second opinion on a release that keeps surprising you in production? AviaryApps supplies senior Flutter consultants who do this work for a living — get in touch and tell us what your dashboards aren't telling you.