You've shipped your Flutter app to thousands of users, but you have no idea what's breaking in production. Crash reporting and analytics solve this blindness—they let you see what's actually happening after release, not just what happens in your test environment. This day covers setting up crash reporting, tracking non-fatal errors, collecting meaningful analytics, and respecting user privacy.
Crash reporting — Crashlytics or Sentry with symbols uploaded
A crash in production is only a problem if you know about it. Without crash reporting, users silently uninstall your app while you remain oblivious. The two industry standards for Flutter are Firebase Crashlytics and Sentry. Both capture uncaught exceptions, native crashes, and frame rate drops. The key difference: Crashlytics integrates tightly with Firebase, while Sentry is platform-agnostic and often favored by teams already using it for web or backend services.
I typically reach for Crashlytics for mobile-only apps and Sentry when I'm monitoring multiple services. Setting up Crashlytics takes minutes with FlutterFire, but the real work is uploading your symbol files. Without symbols, crash logs show unhelpful hex addresses instead of method names. For Android, you need to upload your Proguard mapping files. For iOS, you need dSYM bundles. Miss this step and your stack traces are useless.
Here's a minimal Crashlytics setup with symbol handling:
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
// Pass all uncaught errors to Crashlytics
FlutterError.onError = (FlutterErrorDetails details) {
FirebaseCrashlytics.instance.recordFlutterError(details);
};
// Catch asynchronous errors
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};
runApp(const MyApp());
}For iOS, dSYM upload happens automatically when you build with flutter build ios --release if you've linked your iOS project to Firebase. For Android, you need to add the Gradle plugin and ensure your proguard rules are kept. In your Android app/build.gradle:
plugins {
id 'com.google.firebase.crashlytics'
}
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}One scenario I encountered: our ride-hailing app crashed silently on Android 10 devices when the location permission was revoked mid-ride. The native code threw an exception we weren't catching. Crashlytics showed the stack trace immediately—we fixed it in the next patch. Without symbols uploaded, we'd have seen only memory addresses and spent hours debugging blind.
Non-fatal errors — Log handled errors to find hidden problems
Not every error crashes your app. Many are caught, logged, and silently swallowed. A user can't place an order because network validation failed—they just see an empty error message and assume the app is broken. These non-fatal errors are often more common than crashes and reveal genuine usability problems that crash logs miss.
Log all handled errors to your crash reporting service. This gives you visibility into pain points users are hitting that don't cause crashes. The patterns matter: if 5% of orders fail at payment validation, that's a revenue problem worth fixing immediately.
Here's how to log non-fatal errors to Crashlytics while still handling them gracefully:
Future<void> processPayment(Order order) async {
try {
final response = await _paymentService.charge(order);
if (response.statusCode != 200) {
final error = 'Payment failed: ${response.body}';
FirebaseCrashlytics.instance.recordError(
error,
StackTrace.current,
fatal: false,
reason: 'payment_validation_failed',
);
_showErrorDialog('Payment could not be processed. Please try again.');
return;
}
_showSuccessDialog('Payment successful!');
} catch (e, stackTrace) {
FirebaseCrashlytics.instance.recordError(e, stackTrace, fatal: false);
_showErrorDialog('Network error. Please check your connection.');
}
}With Sentry, you'd do something similar but with breadcrumbs to track user actions leading up to the error:
import 'package:sentry_flutter/sentry_flutter.dart';
Future<void> processPayment(Order order) async {
Sentry.addBreadcrumb(
Breadcrumb(message: 'User initiated payment', level: SentryLevel.info),
);
try {
final response = await _paymentService.charge(order);
if (response.statusCode != 200) {
await Sentry.captureMessage(
'Payment validation failed',
level: SentryLevel.warning,
);
_showErrorDialog('Payment could not be processed.');
return;
}
} catch (e, stackTrace) {
await Sentry.captureException(e, stackTrace: stackTrace);
_showErrorDialog('Network error.');
}
}I log non-fatal errors with a reason tag so I can filter them in the dashboard. In a CRM app, I'd log whenever a sync fails, whenever a contact couldn't be saved to the local database, whenever an image failed to upload. These logs become the foundation for prioritizing what to fix next.
Analytics — Track key funnels, not every tap
The urge to track everything is strong. Every button tap, every screen view, every text input. Resist it. Too much data creates noise and makes your dashboards useless. Instead, focus on key funnels—the sequences of actions that matter to your business.
For an e-commerce app, your funnels are: browse → add to cart → checkout → payment → order complete. For a SaaS tool, they're: sign up → onboarding → create first resource → invite team member. Track these conversions and drop-off points. If 80% of users sign up but only 20% complete onboarding, onboarding is your bottleneck.
Firebase Analytics and Amplitude are the common choices. Firebase is free and integrates with Crashlytics and Remote Config. Amplitude costs more but has superior funnel analysis and cohort tools.
Here's a practical Firebase Analytics setup focused on key events:
import 'package:firebase_analytics/firebase_analytics.dart';
class CheckoutFlow {
final FirebaseAnalytics _analytics = FirebaseAnalytics.instance;
Future<void> trackCheckoutStarted(double cartValue) async {
await _analytics.logEvent(
name: 'checkout_started',
parameters: {
'cart_value': cartValue,
'item_count': 3,
},
);
}
Future<void> trackPaymentInitiated(String paymentMethod) async {
await _analytics.logEvent(
name: 'payment_initiated',
parameters: {
'payment_method': paymentMethod,
},
);
}
Future<void> trackOrderCompleted(String orderId, double value) async {
await _analytics.logPurchase(
value: value,
currency: 'USD',
transactionId: orderId,
);
}
Future<void> trackCheckoutAbandoned(String reason) async {
await _analytics.logEvent(
name: 'checkout_abandoned',
parameters: {
'abandonment_reason': reason,
},
);
}
}When designing analytics, ask yourself: what decision would this metric help me make? If you can't answer that, don't track it. I worked on a travel app where we tracked search queries, filter selections, and booking completion rates. The data showed 40% of users were searching for hotels with no reviews—we prioritized getting review data, and bookings increased 12%. That's analytics with purpose.
Privacy — Respect consent and platform privacy rules
Crash reporting and analytics collect user data. iOS 14+ requires App Tracking Transparency—you must ask permission before tracking across apps. GDPR in Europe requires explicit consent before any analytics. Users can opt out on Android via Advertising ID settings. Ignoring these rules invites app rejection and legal exposure.
Minimize what you send: don't log personal information like email addresses or credit card numbers in analytics events. Don't log full request payloads containing sensitive data. In crash reports, be careful with log lines that might contain user input. Sanitize what you can.
Here's how to respect user privacy in Firebase:
import 'package:firebase_analytics/firebase_analytics.dart';
class AnalyticsManager {
final FirebaseAnalytics _analytics = FirebaseAnalytics.instance;
Future<void> initialize(bool userConsent) async {
// Disable analytics collection until consent is given
await _analytics.setAnalyticsCollectionEnabled(userConsent);
}
Future<void> requestAnalyticsConsent() async {
// Show user a consent dialog
final hasConsent = await _showConsentDialog();
await _analytics.setAnalyticsCollectionEnabled(hasConsent);
if (hasConsent) {
// Only set user ID if they consent
await _analytics.setUserId(id: _getUserId());
}
}
// Never log sensitive data
Future<void> trackSignup() async {
// Good: generic event without user identifiers
await _analytics.logSignUp(signUpMethod: 'email');
}
Future<void> trackPurchase(double amount, String category) async {
// Good: aggregated data, no user identifiers
await _analytics.logEvent(
name: 'product_purchased',
parameters: {
'value': amount,
'category': category,
},
);
}
Future<void> trackBadData(String email) async {
// BAD: never do this
// await _analytics.logEvent(
// name: 'user_action',
// parameters: {'email': email}, // PII!
// );
}
}For iOS, you'll need to request ATT permission via a package like app_tracking_transparency:
import 'package:app_tracking_transparency/app_tracking_transparency.dart';
Future<void> requestTrackingPermission() async {
final status = await AppTrackingTransparency.requestTrackingAuthorization();
if (status == TrackingStatus.authorized) {
// User granted permission, enable analytics
await _analytics.setAnalyticsCollectionEnabled(true);
} else {
// User denied, disable analytics
await _analytics.setAnalyticsCollectionEnabled(false);
}
}In production, I always make analytics optional and default to off until the user explicitly consents. This respects privacy and often impresses privacy-conscious users.
Common mistakes
- Forgetting symbol upload: Uploaded crash logs without dSYM or proguard files. Stack traces became meaningless hex sequences. Always verify symbols in your crash dashboard before calling a release done.
- Logging sensitive data: Logged full API responses containing user tokens, then sent them to a third-party analytics service. Audit your log statements before shipping.
- Tracking everything equally: Implemented analytics for every button tap but no funnel tracking. Dashboards filled with noise, no actionable insights. Start with 3-5 key events and expand based on questions you want answered.
- Ignoring non-fatal errors: Only looked at crashes, missed the pattern of payment failures occurring on slow networks. Non-fatal errors are often more valuable than crashes.
- No crash reproduction environment: Couldn't reproduce crashes locally because production environment differed (different API versions, timezone, locale). Use Remote Config to mirror production conditions in debug builds.
Key takeaways
- Set up crash reporting immediately post-launch. Crashlytics or Sentry. Upload your symbols or your stack traces are useless.
- Log non-fatal errors separately from crashes. They often reveal hidden usability problems users silently experience.
- Track key business funnels, not every user action. Five well-chosen metrics beat fifty random ones.
- Respect user privacy. Ask for consent on iOS, sanitize logs of sensitive data, disable analytics by default.
- Review your analytics dashboard weekly in the first month post-launch. Patterns emerge quickly and guide your priorities.






Comments
Loading comments…