No error leaves
uncaught, none leaves unlogged.
A walk through error handling as an architecture, not an afterthought. We start with the catch points that stop anything from escaping, build a typed error model your features speak in, then wire one logger that ships every failure to PostHog with the context to actually debug it.
Two halves: a net and a vocabulary.
Good error handling is two things working together. An outer safety net, a small set of global handlers so nothing crashes silently or escapes unlogged. And an inner vocabulary, typed domain errors your code passes around deliberately, so a failure is a value you designed for, not a surprise that blows up three layers away.
Every widget wraps its own calls, swallows what it doesn't understand, and shows a generic "something went wrong." Errors get logged in five formats or not at all. The same failure looks different on every screen.
No single source of truth. Debugging means guessing.
Infrastructure exceptions are caught at the repository edge and translated into a small set of typed Failures. Anything that still escapes hits a global handler that routes to one logger. Every error reaches PostHog the same way, with the same context.
Predictable to surface, trivial to trace.
The shape of the whole system
Read top to bottom, this is the path of a single failure. Every chapter that follows builds one of these rows.
- Throw: something fails, a socket drops, a parse breaks, a widget overflows.
- Catch: either you catch it on purpose at a boundary, or a global handler catches what you didn't.
- Map: the low-level exception becomes a typed Failure your app understands.
- Surface: the UI shows a real message and a way forward (retry, go back), never a raw stack trace.
- Log: the same failure is reported to PostHog with screen, user, and breadcrumbs attached.
Know where each error is caught.
Flutter throws errors in several different places, and each place has a different catch point. Wire the wrong one and the error slips past. Pick an error kind below to see where it surfaces and how you handle it.
Catch what you didn't expect.
Three hooks cover everything that escapes your own try/catch blocks. Wire all three at startup so an unexpected error becomes a logged event and a graceful screen, never a silent crash. This is the outermost layer, and you set it once.
// core/errors/error_bootstrap.dart import 'dart:isolate'; import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; void installErrorHandlers(ErrorReporter reporter) { // 1. Flutter framework errors: build, layout, paint. FlutterError.onError = (FlutterErrorDetails details) { FlutterError.presentError(details); // keep the console output reporter.report( details.exception, details.stack ?? StackTrace.current, context: {'library': details.library ?? 'flutter'}, ); }; // 2. Everything async that escapes a zone: the catch-all. PlatformDispatcher.instance.onError = (error, stack) { reporter.report(error, stack, fatal: true); return true; // true = handled, don't re-throw }; // 3. Errors thrown on the root isolate. Isolate.current.addErrorListener(RawReceivePort((List<dynamic> pair) { reporter.report(pair.first, pair.last as StackTrace? ?? StackTrace.current, fatal: true); }).sendPort); // Bonus: a friendly widget instead of the grey error box. ErrorWidget.builder = (details) => AppErrorBox( message: kReleaseMode ? 'This screen hit a problem.' : details.exceptionAsString(), ); }
Call it from main before the app runs, after you've built the reporter:
// main.dart Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); final reporter = await buildReporter(); // sets up PostHog, see Ch.08 installErrorHandlers(reporter); runApp(const ProviderScope(child: MyApp())); }
In debug, FlutterError.presentError still prints the red screen and console dump, which you want while developing. In release, the same hook quietly reports to your logger and ErrorWidget.builder shows your own box instead of the default grey one. The user never sees a stack trace; you still get the full report.
What about runZonedGuarded?
You'll see older guides wrap runApp in runZonedGuarded. Since Flutter 3.3, PlatformDispatcher.instance.onError covers the same uncaught-async case with less ceremony, so the three hooks above are the current default. Reach for a zone only when you need to capture print output or run setup code that itself might throw before the handlers are installed.
Make failure a value.
Instead of letting raw DioExceptions and PostgrestExceptions travel up into your widgets, define a small sealed hierarchy of Failures your whole app speaks. A sealed class means the compiler forces you to handle every case when you switch on it, so a new failure type can't be silently ignored.
// core/errors/failure.dart sealed class Failure { const Failure(this.message); final String message; // already user-safe, no stack traces } class NetworkFailure extends Failure { const NetworkFailure(super.message, {this.statusCode}); final int? statusCode; } class CacheFailure extends Failure { const CacheFailure(super.message); } class ValidationFailure extends Failure { const ValidationFailure(super.message, {this.field}); final String? field; } class AuthFailure extends Failure { const AuthFailure(super.message); } class UnexpectedFailure extends Failure { const UnexpectedFailure([super.message = 'Something went wrong.']); }
One mapper, not one per repository
The translation from raw exception to Failure is the same logic everywhere, so it belongs in a single place in core/, not as a private method copied into every repository. A top-level function that switches on the runtime type handles Dio, Supabase, and the common Dart exceptions in one spot. Add a new infrastructure exception once, here, and every repository benefits.
// core/errors/failure_mapper.dart :: the ONLY file that imports Dio + Supabase import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; Failure failureFrom(Object error) => switch (error) { Failure f => f, // already mapped, pass through DioException e => _fromDio(e), AuthException e => AuthFailure(e.message), // Supabase auth PostgrestException e => NetworkFailure(e.message), // Supabase data SocketException _ => const NetworkFailure('No internet connection.'), TimeoutException _ => const NetworkFailure('The request timed out.'), FormatException _ => const ValidationFailure('Received malformed data.'), _ => const UnexpectedFailure(), }; Failure _fromDio(DioException e) => switch (e.type) { DioExceptionType.connectionTimeout || DioExceptionType.receiveTimeout => const NetworkFailure('The request timed out. Check your connection.'), DioExceptionType.badResponse => NetworkFailure('The server returned an error.', statusCode: e.response?.statusCode), DioExceptionType.connectionError => const NetworkFailure('Could not reach the server.'), _ => const NetworkFailure('Network request failed.'), };
Prefer calling it on the type? Expose the same logic as a factory on the sealed class, factory Failure.from(Object e) => failureFrom(e), and write Failure.from(e) at call sites. Either way, the Failure passes straight through if it's already mapped, so calling it twice is harmless.
Two ways to return a failure
You can keep throwing (now with typed errors) or return an explicit Result. Both are valid; the difference is whether failure is visible in the function's type. Flip between them:
// core/errors/result.dart :: failure is in the return type sealed class Result<T> { const Result(); } class Ok<T> extends Result<T> { const Ok(this.value); final T value; } class Err<T> extends Result<T> { const Err(this.failure); final Failure failure; } // The caller cannot forget the failure case: final result = await repo.sync(); switch (result) { Ok(:final value) => show(value), Err(:final failure) => show(failure.message), }
Failure is impossible to miss: it's in the type, and the switch won't compile until you handle Err. Best for important operations where silently dropping an error would be costly.
// Throwing typed failures :: shorter, but failure is invisible in the signature Future<SyncReport> sync() async { final res = await client.post('/sync'); if (res.statusCode != 200) { throw NetworkFailure('Sync rejected.', statusCode: res.statusCode); } return SyncReport.fromJson(res.data); } // The caller must remember to catch: try { final report = await repo.sync(); show(report); } on Failure catch (f) { show(f.message); }
Less boilerplate, and it composes naturally with Riverpod's AsyncValue (a thrown Failure lands in AsyncError automatically). The cost: nothing in the type tells the caller this can fail. Fine for code already running inside an AsyncNotifier or a guarded zone.
Throw typed failures inside controllers and let AsyncValue carry them to the UI. Use Result<T> at repository boundaries for operations where the caller genuinely needs to branch on success vs failure (payments, sync, destructive actions). Don't adopt a heavyweight functional library for this; a sealed Result in a 20-line file is enough.
Translate at the edge.
This is the single most important discipline in the whole guide. The repository is the one place that knows about Dio, sockets, and SQL. So it's the one place that catches their exceptions and maps them to your Failure vocabulary. Past this line, nothing upstream ever sees a DioException again.
// features/sync/repo/sync_repository.dart class SyncRepository { SyncRepository(this._ref); final Ref _ref; // reads apiClient + reporter internally Future<Result<SyncReport>> sync() async { try { final client = _ref.read(apiClientProvider); final res = await client.post('/sync'); return Ok(SyncReport.fromJson(res.data)); } catch (e, st) { // report the raw error + stack here, while they're still in scope _ref.read(errorReporterProvider).report(e, st, context: {'endpoint': '/sync'}); // then hand the UI a typed, user-safe Failure via the shared mapper return Err(failureFrom(e)); } } }
One catch, one report, one failureFrom. Every repository in the app follows this exact three-line shape, and none of them owns mapping logic. When you add a new backend exception type, you edit failure_mapper.dart once and all repositories pick it up.
What this buys you
- Controllers and widgets only ever match on Failure subtypes, never on Dio internals. Swap Dio for http tomorrow and nothing upstream changes.
- The report-to-PostHog call lives here too, where the raw exception and stack are still in scope, before they're flattened into a friendly message.
- The user-facing message is written once, in the mapper, in plain language. No screen has to invent its own wording.
Show a way forward.
An error on screen is a moment for direction, not an apology. With Riverpod, the controller turns a Failure into AsyncError, and the view renders one of three states. Errors get a message and a retry; they never show a raw exception.
// features/sync/controllers/sync_controller.dart (plain Dart, no code-gen) class SyncController extends AsyncNotifier<SyncReport?> { @override Future<SyncReport?> build() async => null; // idle until run() Future<void> run() async { state = const AsyncLoading(); final result = await ref.read(syncRepositoryProvider).sync(); state = switch (result) { Ok(:final value) => AsyncData(value), Err(:final failure) => AsyncError(failure, StackTrace.current), }; } } final syncControllerProvider = AsyncNotifierProvider<SyncController, SyncReport?>(SyncController.new);
The view watches the state, listens for failures to fire a snackbar, and renders an error view with a retry button:
// features/sync/views/sync_screen.dart final state = ref.watch(syncControllerProvider); // Side effect on failure: a snackbar. No rebuild, fires once per change. ref.listen(syncControllerProvider, (prev, next) { if (next case AsyncError(:final error)) { final msg = error is Failure ? error.message : 'Something went wrong.'; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); } }); return state.when( loading: () => const Center(child: CircularProgressIndicator()), data: (report) => report == null ? const SyncIdle() : SyncSummary(report: report), error: (e, _) => ErrorView( message: e is Failure ? e.message : 'Something went wrong.', onRetry: () => ref.read(syncControllerProvider.notifier).run(), ), );
The shortcut: AsyncValue.guard
When a controller just wraps a throwing call, AsyncValue.guard handles the loading/data/error transitions for you, so you don't write the try/catch by hand:
Future<void> run() async { state = const AsyncLoading(); // guard() runs the body, returns AsyncData on success or AsyncError on throw state = await AsyncValue.guard(() => ref.read(syncRepositoryProvider).syncOrThrow()); }
The error view says what happened and what to do: "Could not reach the server. Check your connection and try again," with a Retry button. Not "Error: DioException [connection error]." The mapper in Chapter 5 already wrote the human sentence; the view just renders it next to an action.
Every failure, one exit.
All paths converge here. Define a single ErrorReporter interface so your repositories and global hooks depend on an abstraction, not on PostHog directly. The PostHog implementation is the only file that imports the SDK, which keeps reporting swappable and testable.
// core/errors/error_reporter.dart :: the abstraction everything depends on abstract interface class ErrorReporter { Future<void> report( Object error, StackTrace stack, { Map<String, Object>? context, bool fatal, }); void breadcrumb(String message); Future<void> identifyUser(String id); } // core/errors/posthog_error_reporter.dart :: the ONLY file that imports PostHog class PostHogErrorReporter implements ErrorReporter { @override Future<void> report( Object error, StackTrace stack, { Map<String, Object>? context, bool fatal = false, }) async { // NEVER do Posthog().capture('$exception', ...). Use captureException. await Posthog().captureException( error: error, stackTrace: stack, properties: {if (context != null) ...context, 'fatal': fatal}, ); } @override void breadcrumb(String message) => Posthog().addExceptionStep(message); @override Future<void> identifyUser(String id) => Posthog().identify(userId: id); } // expose it as a provider so repositories can read it final errorReporterProvider = Provider<ErrorReporter>((ref) => PostHogErrorReporter());
Log the wins, not just the wrecks
Exceptions are only half of what PostHog is for. It's an analytics tool first, so the successful path deserves events too. A sync_completed next to a sync_started is what lets you measure a real success rate and build a funnel, and spot the exact step where users fall out. Errors use captureException; product events use capture. Give them a sibling abstraction so the split stays clean:
// core/telemetry/analytics.dart :: success + lifecycle events abstract interface class Analytics { Future<void> track(String event, {Map<String, Object>? properties}); } // core/telemetry/posthog_analytics.dart class PostHogAnalytics implements Analytics { @override Future<void> track(String event, {Map<String, Object>? properties}) => Posthog().capture(eventName: event, properties: properties ?? const {}); } final analyticsProvider = Provider<Analytics>((ref) => PostHogAnalytics());
Now the controller emits a product event on the way in and on success, while the failure still flows through AsyncError exactly as before (the exception was already reported inside the repo, so there's no double-logging here):
// features/sync/controllers/sync_controller.dart Future<void> run() async { final analytics = ref.read(analyticsProvider); state = const AsyncLoading(); analytics.track('sync_started'); final result = await ref.read(syncRepositoryProvider).sync(); switch (result) { case Ok(:final value): analytics.track('sync_completed', properties: { 'records': value.count, 'duration_ms': value.elapsedMs, }); state = AsyncData(value); case Err(:final failure): state = AsyncError(failure, StackTrace.current); } }
With sync_started, sync_completed, and the $exception event all flowing, PostHog can show you a funnel from attempt to success and a failure rate per app version. A spike in sync_started with no matching sync_completed tells you something broke in the happy path, often before a single user reports it.
Keep the two interfaces separate (one Analytics, one ErrorReporter) so each has a single job, or merge them into one Telemetry facade if you'd rather have a single entry point. Both are reasonable; just pick one and be consistent.
Screen views for free, through GoRouter
You don't hand-track navigation. PostHog ships PosthogObserver, a NavigatorObserver that emits a $screen event on every route change. GoRouter accepts it through its observers list, so wiring it is one line, plus one rule you cannot skip.
// core/router/app_router.dart import 'package:go_router/go_router.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; final appRouter = GoRouter( // records a $screen event automatically on every navigation observers: [ PosthogObserver( // optional: count full pages only, skip dialogs and overlays routeFilter: (route) => route is PageRoute, ), ], routes: [ GoRoute( name: 'SyncScreen', // REQUIRED: an unnamed route is NOT recorded path: '/sync', builder: (context, state) => const SyncScreen(), ), GoRoute( name: 'ProfileScreen', path: '/profile/:id', builder: (context, state) => ProfileScreen(id: state.pathParameters['id']!), ), ], );
The observer reads the screen name from the route, so every GoRoute needs a name:. Leave it off and that screen is silently skipped, no event, no error, just a gap in your funnel. Name them in PascalCase or whatever convention you like, but name them all.
Using a ShellRoute for bottom-nav tabs? The root observer only watches the root navigator, so switching tabs inside the shell won't fire. Add a PosthogObserver() to the ShellRoute's own observers list as well.
This pays off twice. For analytics, you get screen-to-screen funnels without touching a single widget. For debugging, every $screen lands in the user's timeline just before any $exception they hit, so the crash arrives with a free trail of where they'd just been. You rarely need to breadcrumb navigation by hand once this is on.
One ordering constraint to know: with session replay enabled, PostHogWidget must be the root and MaterialApp.router its child, so the recorder wraps the whole navigator. Not required for error tracking or screen views on their own, but worth wiring correctly from the start if replay is on your roadmap.
Watch a failure travel
Pick where the error starts, then throw it. No matter the origin, it converges on the one report() call and leaves as a single PostHog $exception event. The panel on the right is the event PostHog receives.
Don't await the report on a hot path or in build; let it run in the background so logging never blocks the UI. The SDK already queues events and flushes them, including while offline, so a dropped connection won't lose the error.
Setup, autocapture, and context.
PostHog can wire itself into the same three Flutter hooks from Chapter 3 automatically, or you can route them by hand through your reporter. Here's the setup, the autocapture flags, and how to attach the context that turns a stack trace into a debuggable issue.
Install
# pubspec.yaml
dependencies:
posthog_flutter: ^5.0.0
Initialize once, in main
// main.dart import 'package:flutter/foundation.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; Future<ErrorReporter> buildReporter() async { final config = PostHogConfig('phc_yourProjectApiKey') ..host = 'https://eu.i.posthog.com' // or us.i.posthog.com ..debug = kDebugMode ..captureApplicationLifecycleEvents = true; // Let PostHog auto-capture the three global hooks for you. config.errorTrackingConfig.captureFlutterErrors = true; config.errorTrackingConfig.capturePlatformDispatcherErrors = true; config.errorTrackingConfig.captureIsolateErrors = true; config.errorTrackingConfig.captureNativeExceptions = true; // SDK 5.22.0+ // Mark your own package so its frames are highlighted in the UI. config.errorTrackingConfig.inAppIncludes.add('package:myapp'); await Posthog().setup(config); return PostHogErrorReporter(); }
The autocapture flags
Each flag maps onto exactly one hook from Chapter 3. That's the whole connection: PostHog's autocapture is those same hooks, wired for you.
| errorTrackingConfig flag | Captures |
|---|---|
| captureFlutterErrors | Flutter framework errors, the FlutterError.onError hook (build, layout, paint). |
| capturePlatformDispatcherErrors | Uncaught Dart runtime errors, the PlatformDispatcher.onError hook. Not supported on web. |
| captureIsolateErrors | Errors on the main isolate. Not supported on web. |
| captureNativeExceptions | Native crashes: Android Java/Kotlin and Apple platforms. Needs SDK 5.22.0+. |
If you enable autocapture and also set FlutterError.onError by hand to call captureException, you'll report the same crash twice. Choose: let autocapture own the global hooks (simplest), and use manual captureException only for errors you catch and handle yourself, like in the repository. Or wire the hooks yourself for full control over context, and leave the matching autocapture flags off.
Attach context that makes errors debuggable
A bare stack trace is hard to act on. Identify the user, leave breadcrumbs as they move, and pass properties at capture time. PostHog groups events into issues and ties each to the user's session.
// when the user signs in: tie every later exception to them await reporter.identifyUser(user.id); // stable id, not an email // as they navigate: lightweight breadcrumbs on the timeline reporter.breadcrumb('opened SyncScreen'); reporter.breadcrumb('tapped Sync now'); // at capture time: properties you can filter and group issues by reporter.report(error, stack, context: { 'screen': 'SyncScreen', 'endpoint': '/sync', 'severity': 'error', });
Redact before it leaves the device
Use beforeSend to strip anything sensitive from events on the client, before they're sent. One place to enforce that PII never reaches your analytics:
// in your PostHogConfig setup (SDK 5.13.0+) config.beforeSend = (event) { event?.properties.remove('email'); event?.properties.remove('auth_token'); return event; // return null to drop the event entirely };
For structured, non-exception logs, the SDK exposes Posthog().logger.info / warn / error (and captureLog for full control), which batch and persist offline like events do. Handy for tracing a flow that didn't throw but went somewhere you want to see later.
Ways it goes quietly wrong.
Error handling fails silently by nature, so these are the ones that bite weeks later. Skim them now.
The empty catch block
A bare catch (_) {} is how bugs hide for months. If you catch, you either handle it, return a typed Failure, or report it. Never nothing.
throw e instead of rethrow
Writing catch (e) { throw e; } resets the stack trace to this line, hiding where the error actually came from. Use bare rethrow to preserve the original trace.
Catching Error, not just Exception
Error (like TypeError or StateError) signals a bug in your code, not a runtime condition to recover from. Let those hit the global handler and get reported. Catch Exception subtypes you can actually handle.
Double-reporting
Autocapture plus a manual hook on the same error means two events per crash and noisy issue counts. Pick one path for the global hooks, as in Chapter 8.
Logging PII
Emails, tokens, and full request bodies don't belong in analytics. Identify users by a stable id, and use beforeSend to strip sensitive fields before they leave the device.
Showing raw exceptions to users
"DioException [bad response]: 500" is not a message. Map every failure to a plain sentence in the repository, and render that. The stack goes to PostHog, not the screen.
Skipping inAppIncludes
Without it, your stack traces drown in framework frames. Add your package so PostHog highlights the lines that are actually yours.
Never testing the failure path
Override the repository with one that returns Err(NetworkFailure(...)) and assert the UI shows the error view and retry. The unhappy path is the one users hit on a bad connection.