Analytics & Crash Reporting

Medium PriorityAsked in ~55% of senior interviews

4 min read

Operations

LayerPurposeTools
Crash reportingCapture every unhandled exception with stack + contextCrashlytics, Sentry, Bugsnag
Analytics eventsUnderstand what users doFirebase Analytics, Mixpanel, Amplitude
Breadcrumbs / logsAnnotate crash reports with contextCrashlytics log, Sentry breadcrumbs
Performance tracesCold-start, network, custom durationsFirebase 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

PracticeWhy
Standard naming (object_action: checkout_started, video_played)Searchable, consistent
Required + optional properties documented in a specCross-team alignment
Distinguish screen views from user actionsDifferent funnels
Include source/origin on actionsKnow 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 / tokensPrivacy + compliance

RegionRequirement
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

  1. 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.

  2. 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: [...])).

  3. How do you handle consent / opt-out cleanly? Wrap your analytics client in a ConsentGate that returns a real or no-op client based on stored consent. On consent change, swap implementations. Combine with FirebaseAnalytics.setAnalyticsCollectionEnabled(false) to fully disable at the SDK level. Test both states.


How helpful was this content?

Please sign in to rate this article.