With Flutter's firebase_crashlytics, how do fatal and non-fatal reports differ, and how do log, setCustomKey and recordError add context?
answer
- recordError: fatal defaults to false
- non-fatals wait for the next fatal or launch
- only the last eight non-fatals kept
- setCustomKey: 64 keys, current value wins
- log: 64 kB rolling breadcrumbs
basics
~10 sFirebaseCrashlytics.instance.recordError(error, stack, fatal: false) records a non-fatal that is stored and sent with the next fatal report or launch; fatal: true reports immediately. setCustomKey and log attach state and breadcrumbs to later reports.
solid answer
~40 s`recordError(exception, stack, {reason, information, printDetails, fatal = false})` reports a caught error. By default it is a **non-fatal**: Crashlytics writes it to disk and sends it with the next fatal report or when the app restarts, and it keeps only the **eight most recent** non-fatals. With `fatal: true` the report is sent in real time, but the call does not stop the app. `recordFlutterError(details, fatal: ...)` wraps a `FlutterErrorDetails`, and `recordFlutterFatalError` is the fatal shortcut you assign to Flutter's error hook. Context comes from three calls. `setCustomKey(key, value)` stores `int`, `num`, `String` or `bool` state, up to 64 keys, and each report captures the value at that moment. `log(message)` adds breadcrumbs to a 64 kB rolling buffer. `setUserIdentifier(id)` ties reports to a pseudonymous user. Put unique values in keys, not in exception messages.
code
dart · 21 linesimport 'dart:io';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
Future<void> downloadLesson(String lessonId, Future<void> Function() fetch) async {
final crashlytics = FirebaseCrashlytics.instance;
await crashlytics.setCustomKey('current_lesson', lessonId);
await crashlytics.log('download start $lessonId');
try {
await fetch();
} on SocketException catch (e, st) {
// Non-fatal: the UI offers a retry, the session continues.
await crashlytics.recordError(
e,
st,
reason: 'lesson download',
information: ['offline retry offered'],
);
rethrow;
}
}go deeper
Know recordError(error, stack) for caught errors, that fatal defaults to false, and that setCustomKey and log add context to reports.
Explain when fatal and non-fatal reports are sent, the eight-non-fatal cap, and why unique values belong in keys rather than exception messages.
Show a reporting policy: what counts as fatal, which keys capture the state you need to reproduce, and how to keep debug noise and personal data out.
Decide what crash-free means for the product and how error classification, sampling and privacy rules are applied consistently across teams and releases.
## What Crashlytics records from Dart **firebase_crashlytics** is the FlutterFire plugin for Firebase Crashlytics. Native crashes are collected automatically. Dart errors are different: they rarely kill the process, so the plugin reports them only when your code hands them over. The two main calls are: - `recordError(dynamic exception, StackTrace? stack, {reason, Iterable<Object> information = const [], bool? printDetails, bool fatal = false})`: any caught error. A `null` or empty stack is replaced with `StackTrace.current`, and `printDetails` defaults to `kDebugMode`. - `recordFlutterError(FlutterErrorDetails details, {bool fatal = false})`: an error the Flutter framework caught, such as a build or layout exception. It also calls `FlutterError.presentError`. `recordFlutterFatalError(details)` is the same with `fatal: true`. It is the function the setup guide assigns to `FlutterError.onError`; how those global hooks are wired is covered in the error-handling topic. ## Fatal versus non-fatal | | `fatal: false` (default) | `fatal: true` | |---|---|---| | Console category | non-fatal issue | crash | | When it is sent | stored on disk; sent with the next fatal report or on restart | in real time, without a restart | | Retention on device | only the **8 most recent** non-fatals; the count resets when a fatal is sent | sent right away | | Does it end the app? | no | **no**: the flag only classifies the report | Two consequences: 1. A flood of caught errors in a loop keeps only the last eight. If you need all of them, you are logging, not crash-reporting. 2. Marking an error fatal is a **classification** choice, meaning "this broke the user's session". It feeds crash-free metrics, so use it for errors that really end a flow. ## Adding context - **`setCustomKey(String key, Object value)`**: the value must be `int`, `num`, `String` or `bool`, checked by an assertion, and is stored as a string. There are at most 64 pairs; new keys beyond that are ignored, and longer keys or values are truncated. Setting the same key again updates it, and each report captures the value **at the time of the event**. Example: `current_lesson`, `course_level`, `offline_mode`. - **`log(String message)`**: a breadcrumb included in the next report. Newlines are stripped, and the buffer is capped at 64 kB, dropping the oldest entries first. - **`setUserIdentifier(String id)`**: a pseudonymous ID shown on reports. It is truncated past 1,024 characters. Get consent where required, and clear it with an empty string. - **`reason` and `information`**: text shown with the specific error, such as `reason: 'while syncing lesson progress'`. - **Analytics breadcrumbs**: if firebase_analytics is in the app, its events and screen views appear in the report's logs automatically. ## Keep exception messages stable The guide warns against putting unique values, such as a user ID or a timestamp, into the exception message. Crashlytics groups issues by the error, so unique text splits one bug into thousands of issues and may cause reporting to be limited. Put the variable part in a custom key or in `information`. ## Debug builds `recordError` prints a banner and the stack in debug, because `printDetails` defaults to `kDebugMode`. Many teams also call `setCrashlyticsCollectionEnabled(!kDebugMode)`, which is persisted, so development noise never reaches the console. ## A worked example In a language-learning app, a lesson download fails with a `SocketException` while the user is offline: 1. Before the download, `setCustomKey('current_lesson', 'es-a1-07')` and `log('download start es-a1-07')`. 2. In the `catch`, `recordError(e, st, reason: 'lesson download')`. This is non-fatal, because the app shows a retry button. 3. If the player then throws during `build` and the user is stuck, the framework error goes through `recordFlutterFatalError`. That fatal report goes out immediately, carrying the earlier non-fatal with it.
- Your app records hundreds of caught errors per session, but the console shows only a few. Why?Non-fatal reports are stored on the device and sent with the next fatal report or on the next launch, and Crashlytics keeps only the eight most recent. Older ones are dropped. Crashlytics may also rate-limit reports sent off the device. For high-volume diagnostics, fix the error source or aggregate it; do not record every occurrence.
- Does recordError(e, st, fatal: true) terminate the app?No. The `fatal` flag only classifies the report as a crash and sends it in real time. The app keeps running unless the error itself ends it. Use the flag for errors that genuinely break the user's session, because fatal reports count against crash-free metrics.
Custom keys and logs are a flight recorder. setCustomKey is the instrument panel: it always shows the current reading, and a report photographs it at the moment of the event. log is the cockpit voice tape, a fixed-length loop that overwrites the oldest audio. A non-fatal is an incident note filed at the next stop; a fatal report is radioed in immediately. Neither brings the plane down by itself.
saying these in an interview costs you the question
- recordError with fatal: true makes the app exit like a native crash.
- Every non-fatal is uploaded the moment recordError is called.
- Crashlytics keeps every non-fatal recorded during a session.
- Put the user ID in the exception message so issues are searchable.
- setCustomKey stores the history of every value a key ever had.