+1 (415) 480-3939

Flutter Observability in Production: Crash Reporting, Tracing, and Symbolicated Release Builds

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:

  1. Crashes and uncaught Dart errors — native crashes, Dart exceptions, and platform-channel failures.
  2. Handled errors — the ones you catch and swallow. These are where silent data loss lives.
  3. Traces — app start, route transitions, network calls, and a handful of business transactions (login, checkout, sync).
  4. 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:

NeedCrashlytics + Firebase PerformanceSentry (sentry_flutter)
Native crash fidelity (JVM/NDK, iOS)ExcellentGood
Dart-level error groupingWeak-ishExcellent
Traces with custom spansFixed traces + HTTPFull transaction/span model
Self-hosting / data residencyNoYes
Release health (crash-free sessions)YesYes

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 release string 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 in SentryUser. 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 checkout regresses 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.
  • beforeSend demonstrably 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.