Analytics & Crash Reporting
4 min read
Operations
| Layer | Purpose | Tools |
|---|---|---|
| Crash reporting | Capture every unhandled exception with stack + context | Crashlytics, Sentry, Bugsnag |
| Analytics events | Understand what users do | Firebase Analytics, Mixpanel, Amplitude |
| Breadcrumbs / logs | Annotate crash reports with context | Crashlytics log, Sentry breadcrumbs |
| Performance traces | Cold-start, network, custom durations | Firebase Performance, Sentry Performance |
Pair the funnels: when a crash happens, the report shows the last N events the user did → instant debugging.
Code in action
class Observability {
static Future<void> init() async {
// Crash funnels (both!)
FlutterError.onError = (details) {
FlutterError.presentError(details); // console
FirebaseCrashlytics.instance.recordFlutterFatalError(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};
}
// Identity & user properties
static Future<void> identify({required String userId, String? plan}) async {
await FirebaseCrashlytics.instance.setUserIdentifier(userId);
await FirebaseAnalytics.instance.setUserId(id: userId);
if (plan != null) {
await FirebaseAnalytics.instance.setUserProperty(name: 'plan', value: plan);
}
}
// Structured events
static Future<void> track(String event, {Map<String, Object>? props}) =>
FirebaseAnalytics.instance.logEvent(name: event, parameters: props);
static Future<void> screen(String name) =>
FirebaseAnalytics.instance.logScreenView(screenName: name);
// Breadcrumbs — shown in crash reports
static Future<void> log(String msg) =>
FirebaseCrashlytics.instance.log(msg);
}
// Auto screen tracking via go_router
final router = GoRouter(
observers: [
FirebaseAnalyticsObserver(analytics: FirebaseAnalytics.instance),
],
routes: [...],
);
Abstract the SDK (you'll want to swap providers)
abstract class AnalyticsClient {
Future<void> track(String event, {Map<String, Object>? props});
Future<void> screen(String name);
Future<void> identify({required String userId, Map<String, Object>? traits});
}
class FirebaseAnalyticsClient implements AnalyticsClient { ... }
class MixpanelAnalyticsClient implements AnalyticsClient { ... }
class NoopAnalyticsClient implements AnalyticsClient { ... } // for tests / opt-out
Inject AnalyticsClient via DI. Now switching providers is a one-line change.
Event taxonomy — make tracking actually useful
| Practice | Why |
|---|---|
Standard naming (object_action: checkout_started, video_played) | Searchable, consistent |
| Required + optional properties documented in a spec | Cross-team alignment |
| Distinguish screen views from user actions | Different funnels |
Include source/origin on actions | Know where users came from |
Track failures explicitly (payment_failed with reason) | Debug funnels, not just successes |
Cap cardinality (don't put userId in properties) | Avoids analytics quota issues |
| Never log PII / passwords / tokens | Privacy + compliance |
Privacy and consent
| Region | Requirement |
|---|---|
| EU (GDPR) | Explicit consent before tracking; offer opt-out |
| iOS 14.5+ | App Tracking Transparency prompt for cross-app tracking (IDFA) |
| Android 13+ | Per-app notification settings, scoped storage |
| CCPA / others | "Do not sell my data" opt-out |
Implementation: gate AnalyticsClient behind a ConsentService. Pre-consent: NoopAnalyticsClient. Post-consent: real client. Persist the choice; respect changes.
Common mistakes to avoid
❌ Only wiring FlutterError.onError, not PlatformDispatcher.onError
You'll miss every uncaught async error in production.
❌ Logging PII in analytics events
"User Alice (alice@x.com) bought premium" → privacy nightmare.
✅ Track user IDs only; resolve PII via your server.
❌ Adding tracking everywhere without a spec
Engineering ships events; product can't find them; data team can't join them.
✅ Maintain a tracking plan / dictionary.
❌ Calling debug + production analytics the same
Inflates real metrics with dev noise.
✅ Disable in debug, or route to a separate dev project.
❌ Forgetting to set user identifier for crash reports
Crashes come in faceless; you can't reach out to affected users.
✅ setUserIdentifier(userId) on login.
❌ Triggering analytics on every build()
Tracking event on rebuild → thousands of "screen_viewed" per session.
✅ Route observer for screens; events from explicit user actions.
❌ Hard-coding "Firebase" everywhere
Switching to Sentry / Mixpanel requires touching every screen.
✅ Abstract behind an interface.
Interview follow-ups
-
Why wire BOTH
FlutterError.onErrorandPlatformDispatcher.onError?FlutterError.onErrorcatches synchronous framework errors (build/layout/paint/gesture).PlatformDispatcher.onErrorcatches uncaught async errors at the Dart VM level. Production apps need both — async errors (failed network, dropped futures) are the most common source of "silent" crashes that need to be reported. -
How do you balance analytics granularity vs cost? Track actions and outcomes, not every UI event. Aggregate on the server. Use sampling for high-volume events (e.g., 10% of scroll events). For Firebase Analytics, watch the per-event-name cardinality limit (500). For paid services (Mixpanel), monitor event volume against your plan.
-
How do you debug "this crash report is useless — no info"? Add breadcrumbs before risky operations (
Observability.log('Loading user $id')), set user properties (plan tier, app version), enable native symbolication (Crashlytics auto-symbolicates if you upload dSYM/mapping files), and add structured metadata to errors (recordError(e, s, information: [...])). -
How do you handle consent / opt-out cleanly? Wrap your analytics client in a
ConsentGatethat returns a real or no-op client based on stored consent. On consent change, swap implementations. Combine withFirebaseAnalytics.setAnalyticsCollectionEnabled(false)to fully disable at the SDK level. Test both states.
How helpful was this content?
Please sign in to rate this article.