Shipping a signed AAB is not the same thing as being ready for the first production crash. A lot of Flutter Android teams discover that gap the hard way. QA reports that the app "opens and immediately closes." Play Console shows a crash cluster, but the Java or Kotlin frames are obfuscated. The Flutter build used --obfuscate, so the Dart stack is compressed too. Someone connects a device to adb, but the process dies before the right log buffer is captured. The app was technically released, yet the release is not inspectable.
This article is for the stage after the basics are already in place. If you already have a repeatable build command, a stable package ID, and a release checklist like this earlier Flutter Android release article, the next capability to build is post-release crash triage. The goal here is narrow and practical: keep the right files for each release, catch errors at the main Flutter and platform boundaries, use the right adb command sequence, and validate that your local files match what Play Console is showing.
When to Use This Runbook
Use this runbook when the app already reaches internal testing, closed testing, or production, and your team is facing one of these problems:
- a release build crashes on startup and the QA note is too vague to reproduce,
- Play Console shows a cluster but the stack trace is only partially readable,
- Flutter release obfuscation removed the human-friendly Dart frames you expected,
- a plugin or native library might be involved and you need to decide whether the fault is in Dart code, Android code, or release configuration,
- or your CI pipeline ships builds successfully but does not preserve enough artifacts for later review.
This is not a beginner article about Flutter routing, state management, or the first Android release. It assumes you are already past the "can we build it" phase. The new capability is "can we explain a real crash with files, commands, and logs instead of guesses?"
What the Workflow Must Produce
A useful crash workflow does not start with a crash dashboard. It starts with a release archive. For each release version, you should be able to answer five questions quickly:
- Which
versionCodeandversionNameare affected? - Do I still have the matching Dart symbol files?
- Do I still have the matching R8
mapping.txtfile? - Is the crash happening in Dart, in a plugin, or in native Android code?
- Can I replay the stack deobfuscation locally before I change code?
If you cannot answer those five questions, the rest of the debugging session becomes slow and noisy. This is why the file layout matters as much as the error handler.
Project Structure That Makes Triage Possible
A small team does not need a huge observability platform on day one. It does need a stable place to store release artifacts.
your_app/
├─ lib/
│ ├─ bootstrap/app_bootstrap.dart
│ ├─ core/errors/app_error_reporter.dart
│ └─ core/logging/app_log.dart
├─ android/app/
│ ├─ build.gradle
│ └─ proguard-rules.pro
├─ build/symbols/android/release/
├─ release_artifacts/
│ └─ 1.4.0+87/
│ ├─ app-release.aab
│ ├─ mapping.txt
│ ├─ app.android-arm64.symbols
│ └─ notes.txt
└─ tool/crash_samples/
├─ dart_stack.txt
└─ r8_trace.txt
The important part is not the exact folder names. The important part is that every release version gets its own archive directory. That is the difference between "we built something last week" and "we can still triage last week's crash today." A mapping.txt file is overwritten by later release builds. A shared symbols directory is easy to pollute. A versioned archive gives you a reliable boundary.
Steps 1: Keep Dart Symbols and R8 Mapping Files for Every Release
The first step is mechanical but non-negotiable. If you obfuscate Dart code in release, keep the matching Flutter symbol files. If the Android build is optimized and obfuscated by R8, keep the matching mapping.txt too.
A practical release command can look like this:
flutter build appbundle --release --flavor prod --target lib/main_prod.dart --obfuscate --split-debug-info=build/symbols/android/release
$release = "1.4.0+87"
New-Item -ItemType Directory -Force "release_artifacts/$release" | Out-Null
Copy-Item "build/app/outputs/bundle/prodRelease/app-prod-release.aab" "release_artifacts/$release/app-release.aab"
Copy-Item "build/symbols/android/release/*" "release_artifacts/$release/" -Recurse
Copy-Item "android/app/build/outputs/mapping/prodRelease/mapping.txt" "release_artifacts/$release/mapping.txt"
If your app does not use flavors, replace prodRelease with release. The configuration idea is more important than the exact path:
- build release artifacts in one repeatable command,
- archive the AAB and symbol files together,
- and never treat the latest
build/folder as long-term storage.
If the app includes custom NDK code or native-heavy plugins, keep native debug symbols as well. The Android official guidance is to configure release builds to include SYMBOL_TABLE or FULL native debug symbols when needed. Even if you are not there yet, Dart symbols and mapping.txt already cover most first-line Flutter production triage.
Steps 2: Capture Errors at the Flutter and Platform Boundaries
A common pitfall is assuming that print() statements or one framework callback are enough. They are not. FlutterError.onError covers framework-managed exceptions, but it does not replace process-level or async boundary handling.
A better pattern is to centralize bootstrapping and route every high-level error entry point through one reporter.
import 'dart:async';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
FlutterError.onError = (FlutterErrorDetails details) {
FlutterError.presentError(details);
AppErrorReporter.recordFlutter(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
AppErrorReporter.recordZone(error, stack);
return true;
};
runZonedGuarded(() {
runApp(const MyApp());
}, (error, stack) {
AppErrorReporter.recordZone(error, stack);
});
}
You do not need a heavy external service to get value from this. A small structured log layer is already a major improvement over scattered console output.
import 'dart:developer' as developer;
class AppLog {
static void error(String event, Object error, StackTrace stack, Map<String, Object?> extra) {
developer.log(
event,
name: 'app.crash',
error: error,
stackTrace: stack,
time: DateTime.now(),
);
}
}
The review standard here is simple. The log should tell you which build is running, which route or action led to the failure, and what kind of exception escaped. It should not dump sensitive secrets, auth tokens, or full user payloads. A production crash log needs enough context to reproduce the issue, not enough data to create a privacy problem.
Steps 3: Use adb logcat to Confirm the Package and Version First
Before you read a stack trace in detail, confirm that the device is running the package and version you think it is. This sounds obvious, but teams lose a lot of time debugging the wrong install.
Start with this command set:
adb devices
adb shell dumpsys package com.example.app | Select-String "versionName|versionCode"
adb logcat -c
adb logcat -b crash -d > .\tool\crash_samples\android_crash.txt
If the process survives long enough, narrow the stream to the app PID:
$pid = (adb shell pidof -s com.example.app).Trim()
adb logcat --pid=$pid -v time
If the app dies too early for PID filtering to help, fall back to the crash buffer and a smaller tag set:
adb logcat -b crash -v threadtime
adb logcat -s flutter AndroidRuntime ActivityManager
This is where the first technical split becomes visible. If the logs show StateError, PlatformException, or a widget build path, start from the Dart side. If you see FATAL EXCEPTION, plugin package names, AndroidRuntime, or native frames, shift attention to the Android layer, the plugin version boundary, or the release configuration.
That distinction matters because different evidence files answer different questions. Flutter symbol files help with obfuscated Dart stacks. mapping.txt helps with R8-obfuscated Java or Kotlin stacks. Native symbols matter when .so libraries are involved. One stack can contain more than one layer.
Steps 4: Match Play Console Clusters to Local Files
Play Console is useful for impact and clustering, but local validation is what keeps your triage honest. Once you know the versionCode, open the matching release archive and verify that the symbol files really decode the crash.
For an obfuscated Dart stack, use flutter symbolize:
flutter symbolize -i .\tool\crash_samples\dart_stack.txt -d .\release_artifacts\1.4.0+87\app.android-arm64.symbols
For an obfuscated Java or Kotlin stack, use the Android SDK retrace tool:
& "$env:ANDROID_HOME\cmdline-tools\latest\bin\retrace.bat" .\release_artifacts\1.4.0+87\mapping.txt .\tool\crash_samples\r8_trace.txt
Validate three things before you trust the output:
- the file belongs to the same release version,
- the Dart symbols match the device ABI when ABI-specific files are used,
- and the archive was not silently replaced by a later CI run.
A practical review habit is to perform this local decode even when Play Console already shows a readable cluster. If local deobfuscation fails, your release archive policy is weak. If local deobfuscation succeeds, you have a reliable fallback for QA logs, support escalations, and third-party crash exports.
How to Validate the Workflow Before a Real Incident
Do not wait for a real customer crash to discover that your evidence chain is incomplete. Validate the workflow in internal testing.
A safe approach is to add one QA-only crash trigger behind a non-production flag or flavor, then run a small rehearsal:
- Build a release version and verify the archive contains the AAB, Dart symbols, and
mapping.txt. - Install it on a device and confirm the package version with
dumpsys package. - Trigger a controlled exception.
- Export the crash
logcatoutput. - Run
flutter symbolizeorretracelocally. - Review whether Play Console or your crash backend groups the same issue under the correct version.
If you already use dart-define-from-file or multiple release flavors, this rehearsal also validates that the intended environment made it into the app. Build correctness and triage correctness should be checked together.
Pitfall Review
The biggest pitfall is treating the release artifact as only the app bundle. That is incomplete. A shippable release without symbols is a slow-motion incident.
Other common pitfalls include:
- reusing the same symbols directory across versions,
- keeping only
FlutterError.onErrorand missing async or platform-boundary failures, - reading only the
mainlog buffer and skipping thecrashbuffer, - forgetting to log build metadata such as version, flavor, or request ID,
- and using the wrong
mapping.txtor the wrong ABI-specific symbol file.
Another subtle pitfall is over-logging. More log volume does not automatically mean better diagnosis. A useful production log is structured, small, and intentional. It should help you answer "what version failed, in which path, with which error class?" without exposing sensitive data.
Review Checklist for Every Release
Use a short review checklist after each release:
- Does the release archive contain the AAB, Dart symbols, and
mapping.txt? - Is the archive directory named by version so later builds cannot overwrite it?
- Does the bootstrap configuration still route framework, async, and platform errors into one reporter?
- Can a Windows workstation run the exact
adb,flutter symbolize, and retrace command sequence without hidden aliases? - Can QA logs, Play Console evidence, and local symbol files all point to the same
versionCode? - If you need to roll back, do you know which artifact directory and configuration notes belong to that release?
A release process becomes truly production-ready when it can explain failure, not just create binaries. Once this workflow is stable, the next natural step is broader stability review: ANRs, startup regressions, image decoding hotspots, memory peaks, and plugin upgrade boundaries. But the foundation is this smaller workflow first: preserve the right files, capture the right log, and validate the stack locally before you change code.