Implementing Error Tracking in Flutter

A comprehensive guide to implementing error tracking in your Flutter applications.

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:

  1. A centralized Crashlytics wrapper for unified logging
  2. Custom error handlers (FlutterErrorHandler, ZoneErrorHandler, SentryHandler)
  3. 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.

Flutter Error Handler

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,
    );

  }
}

Zone Error Handler

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,

  1. FlutterErrorHandler for framework issues,
  2. ZoneErrorHandler for async failures,
  3. 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.