Firebase Integration

Firebase Crashlytics — Error Reporting & Diagnostics

16 min Lesson 13 of 13

Firebase Crashlytics — Error Reporting & Diagnostics

Production apps crash. The difference between a professional app and an amateur one is how quickly you find out, how much context you have, and how fast you fix it. Firebase Crashlytics is a lightweight, real-time crash reporter that groups issues by root cause, highlights the most impactful ones, and gives you the breadcrumb trail — custom keys, log messages, and stack traces — needed to reproduce and resolve them.

Adding the Dependency

Add firebase_crashlytics to pubspec.yaml alongside the core Firebase package:

# pubspec.yaml
dependencies:
  firebase_core: ^3.6.0
  firebase_crashlytics: ^4.1.3

Run flutter pub get and ensure Firebase.initializeApp() is called in main() before any other Firebase usage.

Note: On Android, Crashlytics requires the google-services.json file and the Google Services Gradle plugin. On iOS it requires GoogleService-Info.plist. Both are downloaded from the Firebase console when you register your app.

Wiring Up Fatal & Non-Fatal Error Handlers

The most important step is routing all unhandled exceptions — from Flutter, from the Dart isolate, and from platform threads — into Crashlytics. Do this once in main():

import 'dart:async';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'firebase_options.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  // Pass all uncaught Flutter framework errors to Crashlytics.
  FlutterError.onError =
      FirebaseCrashlytics.instance.recordFlutterFatalError;

  // Pass all uncaught asynchronous errors from the root isolate
  // (e.g. errors thrown inside runApp or Future chains).
  PlatformDispatcher.instance.onError = (error, stack) {
    FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
    return true; // returning true suppresses the default error handler
  };

  // Wrap runApp in a guarded zone so synchronous errors are also caught.
  runApp(const MyApp());
}
Tip: In debug builds you may want to keep Crashlytics disabled so test crashes do not pollute your production data. Call FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(!kDebugMode) right after initializeApp.

Recording Non-Fatal Errors

Not every error crashes the app. Network timeouts, failed JSON parses, and handled exceptions are still worth tracking as non-fatal events. Call recordError with fatal: false (the default) inside your catch blocks:

Future<void> loadUserProfile(String uid) async {
  try {
    final doc = await FirebaseFirestore.instance
        .collection('users')
        .doc(uid)
        .get();
    if (!doc.exists) throw Exception('User $uid not found');
    _profile = UserProfile.fromMap(doc.data()!);
  } catch (e, stack) {
    // Non-fatal: the UI can show a fallback, but we still want a report.
    await FirebaseCrashlytics.instance.recordError(
      e,
      stack,
      reason: 'loadUserProfile failed for uid=$uid',
      fatal: false,
    );
    _profile = UserProfile.empty();
  }
}

Custom Keys & Log Messages

Raw stack traces often lack the context needed to reproduce a crash. Crashlytics lets you attach up to 64 custom key–value pairs and write a circular log buffer (last 64 KB) that is included with every report:

// Attach key–value metadata (updated as app state changes).
await FirebaseCrashlytics.instance.setCustomKey('user_role', 'premium');
await FirebaseCrashlytics.instance.setCustomKey('subscription_tier', 3);
await FirebaseCrashlytics.instance.setCustomKey('locale', 'ar_SA');

// Write timestamped breadcrumbs to the log buffer.
FirebaseCrashlytics.instance.log('Navigated to checkout screen');
FirebaseCrashlytics.instance.log('Applied coupon: SAVE20');
FirebaseCrashlytics.instance.log('Payment provider: Stripe');

// Optionally identify the user (use an opaque ID, never PII).
await FirebaseCrashlytics.instance.setUserIdentifier('user_8f3a91b');
Warning: Never store personally identifiable information (PII) — full names, emails, phone numbers — in custom keys, log messages, or the user identifier. Use opaque internal IDs only. Crashlytics data is stored by Google and subject to your privacy policy.

Triggering a Test Crash

After wiring everything up, send a test crash to verify the pipeline before shipping:

// Add a temporary button during development ONLY.
// Remove before releasing to production.
ElevatedButton(
  onPressed: () => FirebaseCrashlytics.instance.crash(),
  child: const Text('Test Crash'),
),

Force-kill and relaunch the app. Within a few minutes the crash appears in the Firebase console under Crashlytics > Issues.

Reading Reports in the Firebase Console

Every Crashlytics issue page shows:

  • Stack trace — deobfuscated (if you upload dSYMs / ProGuard mappings)
  • Custom keys — the snapshot of key–value pairs at crash time
  • Logs — the last 64 KB of breadcrumbs leading to the crash
  • Device & OS info — model, OS version, RAM, orientation
  • Session metadata — app version, foreground/background, crash-free users %
Tip: Use the Velocity alerts feature to get emailed when a new issue affects more than N% of your daily active users within a short window — essential for catching regressions in a new release.

Summary

Firebase Crashlytics turns opaque crashes into actionable diagnostics. The integration checklist is: add the package, call initializeApp, hook FlutterError.onError and PlatformDispatcher.instance.onError to recordFlutterFatalError / recordError, enrich reports with custom keys and log breadcrumbs, and monitor the Firebase console. With this in place your team is notified of regressions within minutes, not days.