Crash Reporting and Symbolicated Stack Traces
Integrate Crashlytics and Sentry with deobfuscation mappings for actionable crash reports.
Crash Reporting and Symbolicated Stack Traces is a free Flutter Mobile Development lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Flutter Mobile Development learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Why Raw Crash Traces Are Useless
When you ship a Flutter app in release mode, the Dart compiler produces optimized AOT machine code and strips human-readable symbols. A crash that happens in production no longer reports UserRepository.fetchProfile — it reports a hex offset like #00 abs 0000007f9c2a1b40.
- Debug builds keep symbols, so traces are readable locally.
- Release builds obfuscate and strip, so the on-device trace is just addresses.
- To turn those addresses back into method names you need symbolication (native iOS/Android symbols) and deobfuscation (Dart symbol mapping).
This lesson wires up Crashlytics and Sentry so every production crash arrives fully symbolicated and actionable.
Obfuscation and the Symbol Map File
Flutter obfuscation is opt-in. You enable it at build time and Flutter writes a Dart symbol map per target architecture into a directory you choose.
--obfuscaterenames Dart identifiers to short tokens.--split-debug-infowrites the mapping files (e.g.app.android-arm64.symbols) so you can reverse the obfuscation later.
You must archive these symbol files per release. Without the exact file that matches the shipped binary, the trace can never be deobfuscated.
# Build an obfuscated Android release and keep the Dart symbols
flutter build appbundle --release \
--obfuscate \
--split-debug-info=build/symbols/v1.4.0
# iOS equivalent
flutter build ipa --release \
--obfuscate \
--split-debug-info=build/symbols/v1.4.0Two Layers: Native vs Dart Symbols
A Flutter crash report has two distinct symbolication problems, and confusing them is the #1 reason traces stay unreadable.
- Native layer (iOS dSYM, Android NDK
.sosymbols): resolves crashes inside the engine, plugins, and platform code. Handled by Crashlytics' native symbol upload. - Dart layer (the
--split-debug-infofiles): resolves crashes inside your Dart business logic after obfuscation.
Crashlytics symbolicates the native frames automatically once you upload dSYMs/NDK symbols, but the Dart frames still need the Flutter symbol file run through flutter symbolize.
Initializing Firebase Crashlytics
Crashlytics must be initialized before runApp and wired into Flutter's error hooks so nothing slips through.
FlutterError.onErrorcaptures synchronous framework errors.PlatformDispatcher.instance.onErrorcaptures async errors that escape the zone.
Routing both into recordFlutterFatalError / recordError guarantees every uncaught Dart error reaches Crashlytics.
import 'dart:ui';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
final crashlytics = FirebaseCrashlytics.instance;
FlutterError.onError = crashlytics.recordFlutterFatalError;
PlatformDispatcher.instance.onError = (error, stack) {
crashlytics.recordError(error, stack, fatal: true);
return true;
};
runApp(const MyApp());
}Uploading Native Symbols to Crashlytics
For native frames, Crashlytics needs the build's symbol files uploaded to Firebase.
- iOS: the
FirebaseCrashlyticsrun script uploads dSYMs; for bitcode or missing dSYMs use theupload-symbolstool. - Android: the
firebase-crashlyticsGradle plugin uploads NDK symbols whennativeSymbolUploadEnabledis true.
These uploads happen at build/release time, not at runtime, so they belong in your CI pipeline.
// android/app/build.gradle
plugins {
id 'com.google.firebase.crashlytics'
}
android {
buildTypes {
release {
firebaseCrashlytics {
// Upload Android NDK native symbols
nativeSymbolUploadEnabled true
unstrippedNativeLibsDir 'build/app/intermediates/merged_native_libs'
}
}
}
}Deobfuscating Dart Frames with flutter symbolize
Crashlytics cannot read your Dart --split-debug-info map. When a crash's Dart frames show obfuscated tokens, you export the raw trace and run it through the Flutter SDK tool.
flutter symbolizetakes the obfuscated stack trace plus the exact symbol file for that ABI and version.- The output replaces hex offsets and short tokens with real Dart method names and line numbers.
This is why archiving the per-release symbol directory is non-negotiable — you need the file matching the crashing build.
# stack.txt holds the obfuscated trace copied from Crashlytics
flutter symbolize \
-i stack.txt \
-d build/symbols/v1.4.0/app.android-arm64.symbols
# Output now shows real frames, e.g.:
# #00 UserRepository.fetchProfile (package:app/data/user_repository.dart:42)Adding Sentry Alongside Crashlytics
Sentry is a popular alternative or companion to Crashlytics. The Flutter SDK wraps your app and auto-captures unhandled errors, but the real value is its debug symbol pipeline.
SentryFlutter.initinstalls the integrations and lets you set sampling, release, and environment.- Wrapping
runAppinappRunnerensures the zone-level error handler is active.
Set the release string to name+buildNumber so Sentry can match the crash to the correct uploaded symbols.
import 'package:flutter/material.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
Future<void> main() async {
await SentryFlutter.init(
(options) {
options.dsn = 'https://examplePublicKey@o0.ingest.sentry.io/0';
options.tracesSampleRate = 0.2;
options.environment = 'production';
options.release = 'com.coddykit.app@1.4.0+140';
},
appRunner: () => runApp(const MyApp()),
);
}Uploading Debug Symbols to Sentry
Sentry deobfuscates automatically server-side once you upload the right artifacts with sentry-cli (or the Sentry Dart plugin during the build).
- Upload the native dSYM / ELF debug files via
debug-files upload. - Upload the Flutter Dart symbols (the
--split-debug-infooutput) so obfuscated Dart frames resolve.
Because Sentry matches by debug ID and release, uploads from CI must use the same --split-debug-info directory that produced the shipped binary.
# Upload native + Dart debug information files for this release
sentry-cli debug-files upload \
--org coddykit --project flutter-app \
build/symbols/v1.4.0
# Associate the release so Sentry can match incoming events
sentry-cli releases new com.coddykit.app@1.4.0+140
sentry-cli releases finalize com.coddykit.app@1.4.0+140Enriching Reports with Context and Breadcrumbs
A symbolicated trace tells you where; context tells you why. Both SDKs let you attach metadata so a crash is reproducible.
- Custom keys: feature flags, user tier, current route.
- Breadcrumbs / logs: a trail of recent actions leading to the crash.
- User identifier: to gauge how many users a crash affects.
Never log PII. Use opaque IDs and feature-relevant flags only.
Future<void> tagCrashContext(FirebaseCrashlytics c) async {
await c.setUserIdentifier('user_8f3a');
await c.setCustomKey('subscription', 'pro');
await c.setCustomKey('active_route', '/checkout');
await c.log('Tapped Pay button with cart total 49.99');
}Recording Handled (Non-Fatal) Errors
Not every error should crash the app. A failed network call or a caught parsing error is a non-fatal event you still want visibility into.
- Crashlytics:
recordError(e, st, fatal: false). - Sentry:
Sentry.captureException(e, stackTrace: st).
This standalone Dart example shows the catch-and-report pattern you would feed into either SDK from a service layer.
void reportHandled(Object error, StackTrace stack) {
// In production this would call recordError / captureException.
print('NON-FATAL: $error');
print('Top frame: ${stack.toString().split('\n').first}');
}
Map<String, dynamic> parseProfile(String raw) {
if (!raw.startsWith('{')) {
throw const FormatException('Profile payload is not JSON');
}
return {'ok': true};
}
void main() {
try {
parseProfile('not-json');
} catch (e, st) {
reportHandled(e, st);
}
print('App keeps running after handled error');
}Making Symbol Upload Part of CI
The single biggest cause of unreadable production crashes is a broken release process: someone ships a build but forgets to upload or archive its symbols. Automate it.
- Build with
--obfuscate --split-debug-info=build/symbols/$VERSION. - Upload native symbols (Crashlytics Gradle plugin / iOS run script) and Dart symbols (
sentry-cli) in the same CI job. - Persist
build/symbols/$VERSIONas a CI artifact retained for as long as the version can crash in the wild.
Tie the version string used everywhere — Sentry release, Crashlytics setCustomKey, and the symbol directory name — to one source of truth so matching never drifts.
# CI snippet (bash) — one job that builds and ships symbols
VERSION=$(grep '^version:' pubspec.yaml | awk '{print $2}')
flutter build appbundle --release \
--obfuscate --split-debug-info=build/symbols/$VERSION
# Dart + native symbols to Sentry
sentry-cli debug-files upload --org coddykit --project flutter-app \
build/symbols/$VERSION
# Keep the map for later flutter symbolize runs
tar -czf symbols-$VERSION.tgz build/symbols/$VERSIONQuick Check: Why Dart Frames Stay Obfuscated
You shipped a Flutter release built with --obfuscate --split-debug-info. Native frames in Crashlytics are readable, but the Dart frames in your business logic still show short tokens and hex offsets. What is the correct fix?
Recap: An Actionable Crash Pipeline
You now have an end-to-end pipeline that turns cryptic production crashes into fixable bug reports.
- Build release with
--obfuscate --split-debug-info=build/symbols/$VERSIONand archive that directory per release. - Capture via
FlutterError.onError+PlatformDispatcher.onError(Crashlytics) and/orSentryFlutter.initwithappRunner. - Symbolicate two layers: native (dSYM/NDK uploads) and Dart (
flutter symbolizefor Crashlytics, automatic server-side for Sentry after symbol upload). - Enrich with user IDs, custom keys, and breadcrumbs — no PII.
- Automate symbol upload in CI and keep one version string as the single source of truth.
The golden rule: a crash report is only as good as the symbol file you kept for that exact build.
Frequently asked questions
Is the “Crash Reporting and Symbolicated Stack Traces” lesson free?
Yes — the full text of “Crash Reporting and Symbolicated Stack Traces” is free to read here on the web, and the Flutter Mobile Development course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Flutter Mobile Development course, upgrade to CoddyKit PRO.
What will I learn in “Crash Reporting and Symbolicated Stack Traces”?
Integrate Crashlytics and Sentry with deobfuscation mappings for actionable crash reports. You practise Flutter Mobile Development with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start Flutter Mobile Development?
No prior experience is required. Flutter Mobile Development on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Crash Reporting and Symbolicated Stack Traces” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this Flutter Mobile Development lesson?
Yes. Every Flutter Mobile Development lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Build Flavors and Environment Configuration
- Automated Pipelines with Fastlane and GitHub Actions
- Crash Reporting and Symbolicated Stack Traces
- Remote Config, Feature Flags, and Staged Rollouts