π¬ crash_forensics
The forensic crash reporter for Flutter β freezes a complete runtime snapshot the moment your app dies.
π§ The Problem
[FATAL] Null check operator used on a null value
#0 ProfileScreen._buildHeader (profile_screen.dart:87)
That's all you get. No context. No variable values. No idea what the user was doing. No clue why userModel was null. You deploy a fix, guess blindly, and hope it sticks.
π‘ The Solution
crash_forensics acts as the black box recorder of your Flutter app. The moment a crash occurs, it instantly captures and freezes:
| What | Details |
|---|---|
| π Stack Trace | Parsed line-by-line, app frames highlighted |
| π Last 100 log lines | Circular buffer, no memory waste |
| π Breadcrumbs | Every navigation, tap, lifecycle event before the crash |
| π Watched Variables | You mark variables with CrashForensics.watch() β they appear in the report |
| π± Device Snapshot | Platform, OS, screen, locale, battery, network |
| βοΈ Runtime Snapshot | Dart heap estimate, process memory, dart/app version |
| π Privacy Filter | Sensitive keywords automatically redacted |
Everything is saved as a clean, structured JSON file locally β no servers, no subscriptions, no internet required.
π¦ Installation
dependencies:
crash_forensics: ^1.0.0
flutter pub get
π Quick Start
Minimal setup (2 lines)
void main() {
ForensicsInitializer.run(
appRunner: () => runApp(const MyApp()),
);
}
Production setup (recommended)
void main() {
ForensicsInitializer.run(
config: ForensicsConfig.production(
appName: 'MyApp',
appVersion: '2.4.1',
),
appRunner: () => runApp(const MyApp()),
);
}
Full custom setup
void main() {
ForensicsInitializer.run(
config: ForensicsConfig(
appName: 'MyApp',
appVersion: '2.4.1',
environment: 'staging',
maxLogLines: 150,
maxBreadcrumbs: 75,
enableLocalReporting: true,
enableConsoleReporting: true,
verboseConsole: false,
maxStoredReports: 25,
sensitiveKeywords: ['password', 'token', 'auth', 'secret', 'api_key', 'bearer'],
enableRuntimeSnapshot: true,
enableDeviceInfo: true,
customReporters: [
HttpReporter(
endpoint: 'https://your-server.com/api/crashes',
headers: {'Authorization': 'Bearer your-token'},
),
],
),
appRunner: () => runApp(const MyApp()),
);
}
π Logging
Manual log messages
// Basic
CrashForensics.log('User tapped checkout button');
// With tag and level
CrashForensics.log(
'Payment API returned 402',
tag: 'PAYMENT',
level: LogLevel.warning,
);
CrashForensics.log(
'Cart is empty β unexpected state',
tag: 'CART',
level: LogLevel.error,
);
π Breadcrumbs
// Manual breadcrumb
CrashForensics.addBreadcrumb(
'User tapped "Checkout" button',
type: BreadcrumbType.userInteraction,
data: {'cart_items': 3, 'total': 49.99},
);
// Navigation is automatic if you add the observer:
MaterialApp(
navigatorObservers: [ForensicNavigatorObserver()],
...
)
π Watch Variables
Mark variables you care about β they will be captured in every crash report:
// In your state management / service layer
CrashForensics.watch('currentUserId', userId);
CrashForensics.watch('cartItemCount', cart.items.length);
CrashForensics.watch('paymentStep', _currentStep);
CrashForensics.watch('userModel', userModel?.toString() ?? 'null');
When a crash happens, you'll see in the report:
"watched_variables": {
"currentUserId": "443",
"cartItemCount": 3,
"paymentStep": "CONFIRM",
"userModel": "null"
}
π Reading Saved Reports
// Get all saved crash reports
final reports = await CrashForensics.getSavedReports();
for (final report in reports) {
print('--- Crash Report ---');
print('ID: ${report.id}');
print('Time: ${report.timestamp}');
print('Exception: ${report.exception.type}');
print('Message: ${report.exception.message}');
print('Last log: ${report.lastLogs.last.message}');
print('Breadcrumbs: ${report.breadcrumbs.length} events');
}
// Clear all reports
await CrashForensics.clearReports();
π HTTP Reporter (optional)
Send crash reports to your own server or webhook:
// In your ForensicsConfig:
customReporters: [
HttpReporter(
endpoint: 'https://your-server.com/api/crash',
headers: {
'Content-Type': 'application/json',
'X-App-Key': 'your-api-key',
},
timeout: const Duration(seconds: 15),
retryCount: 3,
),
],
π Sample Report JSON
{
"id": "cf_1748734521000_a3f9b",
"timestamp": "2026-05-31T14:35:21.000Z",
"environment": "production",
"app_name": "MyApp",
"app_version": "2.4.1",
"exception": {
"type": "_TypeError",
"message": "Null check operator used on a null value",
"context": "FlutterError.onError"
},
"stack_trace": [
{
"package": "my_app",
"file_path": "lib/screens/profile_screen.dart",
"file_name": "profile_screen.dart",
"line_number": 87,
"column_number": 23,
"function_name": "ProfileScreen._buildHeader",
"is_app_frame": true,
"is_flutter_frame": false,
"is_dart_core_frame": false
}
],
"last_logs": [
{
"timestamp": "2026-05-31T14:35:20.112Z",
"level": "info",
"tag": "AUTH",
"message": "User logged in: user_id=443"
},
{
"timestamp": "2026-05-31T14:35:21.001Z",
"level": "warning",
"tag": "API",
"message": "fetchUserProfile returned 404 for user_id=443"
}
],
"breadcrumbs": [
{
"timestamp": "2026-05-31T14:35:15.000Z",
"event": "Tapped My Profile button",
"type": "user_interaction",
"data": { "widget": "BottomNavBar", "index": 3 }
},
{
"timestamp": "2026-05-31T14:35:20.000Z",
"event": "Navigated to: /profile",
"type": "navigation",
"data": { "from": "/home", "to": "/profile" }
}
],
"runtime_snapshot": {
"rss_memory_mb": 187,
"max_rss_memory_mb": 210,
"dart_version": "3.4.0",
"app_version": "2.4.1",
"app_build_number": "47",
"is_debug_mode": false,
"is_release_mode": true,
"watched_variables": {
"currentUserId": "443",
"userModel": "null",
"isLoggedIn": true,
"currentRoute": "/profile"
}
},
"device_info": {
"platform": "android",
"os_version": "Android 14 (API 34)",
"device_model": "Pixel 7 Pro",
"device_brand": "Google",
"is_physical_device": true,
"locale": "ar_SA",
"timezone": "Asia/Riyadh",
"screen_width_px": 1440,
"screen_height_px": 3120,
"device_pixel_ratio": 3.5,
"battery_level": 67,
"is_charging": false,
"network_type": "wifi"
}
}
βοΈ Comparison
| Feature | crash_forensics | Firebase Crashlytics | Sentry |
|---|---|---|---|
| Free forever | β | β οΈ Limited | β οΈ Limited |
| Works offline | β | β | β |
| Local storage | β | β | β |
| Watch variables | β | β | β |
| Breadcrumbs | β | β | β |
| Circular log buffer | β | β | β |
| Privacy-first | β (local only) | β Google servers | β External |
| No SDK bloat | β | β | β οΈ |
| Custom reporters | β | β | β οΈ |
| Pure Dart/Flutter | β | β (Firebase) | β οΈ |
π‘οΈ Privacy & Security
- All data is stored locally by default β nothing leaves the device without your explicit
HttpReporter - Sensitive keywords are automatically redacted from all report fields:
password,token,secret,api_key,bearer, etc. - You control the full list via
ForensicsConfig.sensitiveKeywords - Optional AES-256 encryption for stored reports via
ReportEncryptor
π± Platform Support
| Platform | Local Storage | Device Info | Battery | Network |
|---|---|---|---|---|
| Android | β | β | β | β |
| iOS | β | β | β | β |
| macOS | β | β | β | β |
| Windows | β | β | β οΈ N/A | β |
| Linux | β | β | β οΈ N/A | β |
| Web | β οΈ In-memory | β οΈ Limited | β | β |
π License
MIT β see LICENSE
π€ Contributing
Issues and PRs are welcome! See CONTRIBUTING.md for guidelines.
"Don't just tell me the app died β tell me how it lived in its final moments."
Libraries
- crash_forensics
- crash_forensics β The forensic crash reporter for Flutter.