Implementing Error Tracking in Flutter
Introduction
In the previous article, we explored the architecture and reasoning behind our Flutter error tracking system — how logs, priorities, and handlers work together to create a resilient monitoring pipeline.
Now, let’s bring that architecture to life.
In this guide, we’ll implement:
- A centralized Crashlytics wrapper for unified logging
- Custom error handlers (FlutterErrorHandler, ZoneErrorHandler, SentryHandler)
- Integration with Sentry for advanced tracking and insights
Step 1: Setting Up Crashlytics Wrapper
- Our Crashlytics wrapper acts as the central hub for all logging and error reporting.
- It exposes a consistent API for the whole app, enriches each error with actionable context (function name, priority, and custom extras), and forwards it to Sentry with stable grouping and clean breadcrumbs.
- Under the hood, it emits structured, timestamped console logs (via logging + dart:developer) and automatically scrubs sensitive data (tokens, emails, IDs, credentials) before any message, stack trace, or context is recorded—helping you stay privacy-first by default.
📁 File: lib/config/crashlytics.dart
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:logging/logging.dart';
import 'dart:developer' as d;
enum IssuePriority { critical, high, medium, low }
class Crashlytics {
static final Crashlytics instance = Crashlytics._internal();
Crashlytics._internal();
static final logger = Logger('Crashlytics');
String get dateTimeHours =>
'${DateTime.now().hour}:${DateTime.now().minute.toString().padLeft(2, '0')}:${DateTime.now().second.toString().padLeft(2, '0')}';
void log(String message) {
final scrubbedMessage = IgnoreLogs.scrubSensitiveData(message);
logger.info(scrubbedMessage);
d.log('$dateTimeHours $scrubbedMessage');
}
Future<void> recordError({
required IssuePriority priority,
required String description,
required dynamic exception,
StackTrace? stack,
String? functionName,
Map<String, dynamic>? extraData,
}) async {
var logException = '$description, exception: $exception';
var logStack = stack.toString();
logException = IgnoreLogs.scrubSensitiveData(logException);
logStack = IgnoreLogs.scrubSensitiveData(logStack);
await Sentry.captureException(
logException,
stackTrace: StackTrace.fromString(logStack),
withScope: (scope) {
scope
..fingerprint = [logException]
..level = priority == IssuePriority.critical
? SentryLevel.fatal
: priority == IssuePriority.high
? SentryLevel.error
: priority == IssuePriority.medium
? SentryLevel.warning
: SentryLevel.info
..setTag('function', functionName)
..setTag('priority', priority.value)
..setContexts('Error Details', {
'error': logException,
'stack': logStack,
'priority': priority.value,
...extraData,
});
scope.breadcrumbs.add(
Breadcrumb(
message: 'Exception: $logException',
level: scope.level ?? SentryLevel.error,
timestamp: DateTime.now(),
),
);
},
);
}
}
class IgnoreLogs {
IgnoreLogs._();
static final IgnoreLogs instance = IgnoreLogs._();
static const sensitivePatterns = [
// Authentication & Tokens
Pattern('password', 'password:.*?,'),
Pattern('secret', 'secret:.*?,'),
Pattern('token', 'token:.*?,'),
Pattern('deviceToken', 'deviceToken:.*?,'),
Pattern('bearer', 'bearer:.*?,'),
Pattern('authorization', 'authorization:.*?,'),
Pattern('refreshToken', 'refreshToken:.*?,'),
Pattern('accessToken', 'accessToken:.*?,'),
// API & Keys
Pattern('api_key', 'api_key:.*?,'),
Pattern('apikey', 'apikey:.*?,'),
Pattern('private_key', 'private_key:.*?,'),
Pattern('privatekey', 'privatekey:.*?,'),
// Credentials
Pattern('credentials', 'credentials:.*?,'),
Pattern('auth', 'auth:.*?,'),
// Payment Information
Pattern('credit_card', 'credit_card:.*?,'),
Pattern('cvv', 'cvv:.*?,'),
// Personal Information
Pattern('ssn', 'ssn:.*?,'),
Pattern('email', 'email:.*?,'),
Pattern('userId', 'userId:.*?,'),
Pattern('fcmToken', 'fcmToken:.*?,'),
Pattern('uid', 'uid:.*?,'),
Pattern('phone', 'phone:.*?,'),
];
static String scrubSensitiveData(String message) {
if (message.isEmpty || kDebugMode) return message;
var scrubbedMessage = message;
for (final pattern in sensitivePatterns) {
// Match the pattern name followed by any characters up to a UUID or similar value
final regex = RegExp('${pattern.name}:?\\s*[^,\\s]+');
scrubbedMessage = scrubbedMessage.replaceAll(regex, '');
// Clean up extra commas and spaces
scrubbedMessage = scrubbedMessage.replaceAll(RegExp(r',\s*,'), ',');
scrubbedMessage = scrubbedMessage.replaceAll(RegExp(r'\s+'), ' ').trim();
// Remove trailing comma if it exists
if (scrubbedMessage.endsWith(',')) {
scrubbedMessage = scrubbedMessage.substring(
0,
scrubbedMessage.length - 1,
);
}
}
return scrubbedMessage;
}
}
class Pattern {
const Pattern(this.name, this.regex);
final String name;
final String regex;
}
Step 2: Flutter Error Handler
This handler captures UI and framework-level errors — things like layout overflows, build errors, and widget lifecycle issues.
📁 File: lib/handlers/bootstrap/flutter_error_handler.dart
import 'package:flutter/material.dart';
import '../../config/crashlytics.dart';
enum FlutterErrorType {
layout,
animation,
rendering,
platform,
lifecycle,
state,
network,
unknown
}
class FlutterErrorHandler {
final fc = Crashlytics.instance;
void initialize() {
FlutterError.onError = (FlutterErrorDetails error) {
final errorType = _categorizeError(error);
_handleError(errorType, error);
};
}
FlutterErrorType _categorizeError(FlutterErrorDetails error) {
final startTime = DateTime.now();
fc.log('[INFO][FlutterErrorHandler] → _categorizeError');
fc.log('[INFO][FlutterErrorHandler] • Analyzing error details');
final errorMessage = error.toString().toLowerCase();
final exception = error.exception;
fc.log('[INFO][FlutterErrorHandler] • Determining error type');
late FlutterErrorType type;
if (msg.contains('renderflex overflowed') ||
msg.contains('boxconstraints') ||
msg.contains('layout') ||
msg.contains('viewport')) {
type = FlutterErrorType.layout;
} else {
type = FlutterErrorType.unknown;
}
final duration = DateTime.now().difference(startTime);
fc.log(
'[INFO][FlutterErrorHandler] ← _categorizeError - completed - type: $errorType - duration: ${duration.inMilliseconds}ms',
);
return errorType;
}
void _handleError(FlutterErrorType errorType, FlutterErrorDetails error) {
final startTime = DateTime.now();
fc.log('[INFO][FlutterErrorHandler] → _handleError - type: $errorType');
fc.log('[INFO][FlutterErrorHandler] • Determining error message');
final errorMessage = switch (errorType) {
FlutterErrorType.layout => 'FlutterErrorHandler layout error occurred',
};
fc.log('[INFO][FlutterErrorHandler] • Determining error priority');
final priority = switch (errorType) {
FlutterErrorType.layout => IssuePriority.high,
};
fc.log('[INFO][FlutterErrorHandler] • Preparing additional data');
final additionalData = {
'error': error.toString(),
'stackTrace': error.stack.toString(),
'errorType': errorType.toString(),
'FlutterError.onError': 'true',
};
fc
..log('[INFO][FlutterErrorHandler] • Recording error')
..recordError(
priority: priority,
description: errorMessage,
exception: error.exception,
stack: error.stack ?? StackTrace.current,
functionName: 'FlutterError.onError',
extraData: additionalData,
);
final duration = DateTime.now().difference(startTime);
fc.log(
'[INFO][FlutterErrorHandler] ← _handleError - completed - duration: ${duration.inMilliseconds}ms',
);
}
}
💡 Tip: During development, you can still use FlutterError.presentError(details) to show red error screens.

Step 3: Zone Error Handler
To catch asynchronous errors (the ones that escape try-catch), use a Zone.
📁 File: lib/handlers/bootstrap/zone_error_handler.dart
import '../../config/crashlytics.dart';
enum ZoneErrorType {
asyncOperation,
stateManagement,
networkOperation,
resourceAccess,
initialization,
platformChannel,
nullCheck,
timeout,
unknown,
}
class ZoneErrorHandler {
ZoneErrorHandler() {
fc.log('ZoneErrorHandler initialized');
}
final fc = Crashlytics.instance;
Future<void> handleError(Object error, StackTrace stack) async {
fc.log('ZoneErrorHandler handleError called');
final errorType = _categorizeError(error);
await _processError(errorType, error, stack);
}
ZoneErrorType _categorizeError(Object error) {
final errorMessage = error.toString().toLowerCase();
// Handle null check errors
if (errorMessage.contains('null check operator') ||
errorMessage.contains('null value')) {
return ZoneErrorType.nullCheck;
}
return ZoneErrorType.unknown;
}
Future<void> _processError(
ZoneErrorType errorType,
Object error,
StackTrace stack,
) async {
final errorMessage = switch (errorType) {
ZoneErrorType.asyncOperation =>
'ZoneErrorHandler asynchronous operation error occurred',
};
final priority = switch (errorType) {
ZoneErrorType.asyncOperation => IssuePriority.high,
};
// Extract additional error details
final additionalData = {
'error': error.toString(),
'stackTrace': stack.toString(),
'errorType': errorType.toString(),
'runZonedGuarded': 'true',
};
await fc.recordError(
priority: priority,
description: errorMessage,
exception: error,
stack: stack,
functionName: 'runZonedGuarded',
extraData: additionalData,
);
}
}

Step 4: Manual Error Handling
For operations where failure is expected (e.g., network requests, parsing), always record errors manually.
import '../../config/crashlytics.dart';
class PaymentProcessor {
final fc = Crashlytics.instance;
Future<PaymentResult> processPayment({
required String orderId,
required String currency,
}) async {
final startTime = DateTime.now();
fc.log('[INFO][PaymentProcessor] → processPayment - orderId: $orderId');
try {
fc.log('[INFO][PaymentProcessor] • Validating payment details');
await _validatePayment(orderId, amount);
fc.log('[INFO][PaymentProcessor] • Processing transaction');
final transaction = await _processTransaction(orderId, amount, currency);
fc.log('[INFO][PaymentProcessor] • Updating order status');
await _updateOrderStatus(orderId, 'completed');
final duration = DateTime.now().difference(startTime);
fc.log('[INFO][PaymentProcessor] ← processPayment - completed - orderId: $orderId, duration: ${duration.inMilliseconds}ms');
return PaymentResult.success(transaction);
} on PaymentValidationException catch (e, s) {
final duration = DateTime.now().difference(startTime);
await fc.recordError(
priority: IssuePriority.high,
description: '[ERROR][PaymentProcessor] × processPayment validation failed - duration: ${duration.inMilliseconds}ms',
exception: e,
stack: s,
functionName: 'processPayment',
extraData: {
'orderId': orderId,
'amount': amount.toString(),
'currency': currency,
'errorType': 'validation',
},
);
return PaymentResult.failure('Validation failed: ${e.message}');
}
}
Step 5: Error Categorization and Metadata
In SentryHandler is a pre-send gatekeeper that classifies known transient errors and, when appropriate, drops or downgrades them before they ever reach Sentry.
📁 File: lib/handlers/bootstrap/sentry_handler.dart
import '../../config/crashlytics.dart';
enum SentryErrorType {
networkConnectivity,
timeout,
dnsResolution,
handshakeException,
connectionRefused,
serverError,
clientError,
authError,
tokenRefreshError,
socketError,
resourceNotFound,
unknown
}
/// Handler for processing Sentry events and errors
class SentryHandler {
SentryHandler() {
fc.log('[INFO][SentryHandler] → initialize');
}
static final fc = Crashlytics.instance;
/// Process an event before sending to Sentry
/// Categorizes network errors and modifies properties accordingly
static SentryEvent? beforeSend(SentryEvent event, {dynamic hint}) {
fc.log('[INFO][SentryHandler] • beforeSend');
final errorType = _categorizeError(event);
if (_isNetworkRelatedError(errorType)) {
return _processNetworkError(event, errorType);
}
return event;
}
/// Categorize the error based on exception details
static SentryErrorType _categorizeError(SentryEvent event) {
// Extract error message from exceptions
final String errorMessage;
if (event.exceptions?.isNotEmpty ?? false) {
errorMessage = (event.exceptions!.first.value ?? '').toLowerCase();
} else {
errorMessage = '';
}
// Handshake exceptions
if (errorMessage.contains('handshake') ||
errorMessage.contains('connection terminated during handshake')) {
return SentryErrorType.handshakeException;
return SentryErrorType.unknown;
}
/// Process network errors with special handling
static SentryEvent _processNetworkError(
SentryEvent event,
SentryErrorType errorType,
) {
final errorCategory = _getErrorCategory(errorType);
final isPermanent = _isPermanentError(errorType);
final level = _getErrorLevel(errorType);
final requiresUserAction = _requiresUserAction(errorType);
// Build tags with error information
final newTags = <String, String>{
...event.tags ?? {},
'error_category': errorCategory,
'is_crash': 'false',
'error_type': errorType.toString().replaceFirst('SentryErrorType.', ''),
'requires_user_action': requiresUserAction.toString(),
'is_permanent': isPermanent.toString(),
};
// Create modified event with new properties
return event.copyWith(
level: level,
tags: newTags,
);
}
}
Step 6: Initialize Sentry & Global Error Handlers
Initialize Sentry and global error handlers so all UI and async errors flow through FlutterErrorHandler, ZoneErrorHandler, and SentryHandler.beforeSend, with PII disabled and Sentry widgets enabled for breadcrumbs, interactions, and screenshots.
📁 File: lib/main.dart
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:sentry_logging/sentry_logging.dart';
import '../../handlers/bootstrap/flutter_error_handler.dart';
import '../../handlers/bootstrap/zone_error_handler.dart';
import '../../handlers/sentry/sentry_handler.dart';
Future<void> main() async {
final zoneErrorHandler = ZoneErrorHandler();
await runZonedGuarded<Future<void>>(
() async {
// Initialize error handlers
FlutterErrorHandler().initialize();
SentryHandler();
await SentryFlutter.init(
(options) {
options
..dsn = kDebugMode ? '' : SecureConfig.sentryDsn
..addIntegration(LoggingIntegration())
..debug = false
..enableAutoSessionTracking = true
..enableAutoPerformanceTracing = true
..diagnosticLevel = SentryLevel.info
..tracesSampleRate = 1.0
..profilesSampleRate = 1.0
..enableUserInteractionTracing = true
..maxBreadcrumbs = 250
..sendDefaultPii = false
..beforeSend = (event, hint) =>
SentryHandler.beforeSend(event, hint: hint);
},
appRunner: () {
runApp(
SentryScreenshotWidget(
child: SentryUserInteractionWidget(
child: DefaultAssetBundle(
bundle: SentryAssetBundle(),
child: app,
),
),
),
);
},
);
},
(error, stack) async {
await zoneErrorHandler.handleError(error, stack);
},
);
}
Conclusion
With a central Crashlytics wrapper,
- FlutterErrorHandler for framework issues,
- ZoneErrorHandler for async failures,
- and a pre-send SentryHandler to normalize/trim events, you now have a single, privacy-first pipeline from log → categorize → enrich → report.
What you get:
- Consistent logs and breadcrumbs tied to real actions
- Clear priorities mapped to Sentry levels for predictable alerts
- Pre-send filtering to drop/downgrade noisy, transient errors
- PII-safe payloads with meaningful tags/contexts for fast triage
- Full coverage of UI + async paths with performance and interaction traces
Thank you for reading, I hope it is helpful. If you have any questions, feel free to ask on LinkedIn.