Logging, Observability, Analytics & Feature Flags
Monitoring production health, understanding user behaviour, and shipping safely with flags
Open interactive version (quiz + challenge)Real-world analogy
Observability is like a pilot's cockpit — analytics tells you where the plane is going (user journeys), logging tells you what the engines are doing (system events), crash reporting tells you when something breaks (incidents), and feature flags let you flick switches mid-flight without landing the plane (deployments).
What is it?
Observability in Flutter apps combines crash reporting, analytics, logging, and feature flags to understand production health, user behaviour, and ship new features safely with controlled rollouts.
Real-world relevance
A SaaS collaboration app tracks workspace_created, message_sent, and file_uploaded events in Amplitude for funnel analysis. Feature flags in Remote Config gate the new AI summarization feature to 5% of Pro users. Crashlytics custom keys (workspaceId, userRole) help reproduce workspace-specific crashes in minutes.
Key points
- Structured logging — Use structured logs (JSON key-value pairs) instead of plain print statements. In Flutter: the logger package or talker package provides log levels (verbose, debug, info, warning, error, fatal). In production, pipe logs to a backend service. Never log PII (names, emails, payment data).
- Firebase Crashlytics — FlutterError.onError and PlatformDispatcher.instance.onError should forward errors to Crashlytics. Use Crashlytics.instance.recordError for caught errors. Set custom keys (userId, screen, feature) before crashes for context. Separate fatal vs non-fatal errors.
- Analytics event design — Good analytics events answer business questions: not 'button_tap' but 'checkout_started {plan: annual, source: paywall_v2}'. Define an event taxonomy upfront. Use consistent naming: noun_verb (signup_completed, message_sent, payment_failed). Track funnels, not just pageviews.
- Firebase Analytics vs Amplitude vs Mixpanel — Firebase Analytics: free, integrates with AdWords/BigQuery, limited real-time querying. Amplitude: strong funnel analysis, cohort analysis, free tier generous. Mixpanel: event-based, powerful retention analysis, SQL access on paid plans. For Flutter: firebase_analytics, amplitude_flutter, mixpanel_flutter packages.
- Feature flags with Remote Config — Firebase Remote Config allows changing app behaviour without a release. Use for: feature rollouts (enable for 10% users), A/B testing, kill switches (disable broken feature), config values (paywall prices, timeout durations). Always define safe defaults in code.
- Remote Config best practices — Fetch + activate at app start (with cacheExpiration). Use minimum fetch interval to avoid hitting quota (default 12h in prod, 0 in debug). Define parameter groups for organization. Document every flag with owner, purpose, and expiry date — flags accumulate and become technical debt.
- A/B testing basics — Firebase A/B Testing uses Remote Config under the hood. Define a metric (retention D1, purchase conversion), create variants, set audience (% of users or user segment), let it run to statistical significance (usually 2-4 weeks, minimum 1000 users per variant). Don't peek early.
- Crash triage workflow — Step 1: Crashlytics alert → check crash-free user rate (target >99.5%). Step 2: Identify top crash by impacted users. Step 3: Read stack trace + custom keys. Step 4: Reproduce locally. Step 5: Fix and verify in staging. Step 6: Release with crash rate monitoring. Step 7: Close issue in Crashlytics.
- ANR analysis — ANR (Application Not Responding) on Android occurs when the main thread is blocked >5s. Firebase Crashlytics captures ANRs. Common causes: synchronous network calls on main thread, heavy computation without compute() isolate, blocking I/O. Use Android Vitals in Play Console for additional ANR data.
- Memory leak detection — Use flutter_memory_monitor or DevTools Memory tab. Common leaks: StreamSubscription not cancelled, AnimationController not disposed, timers not cancelled, large images in memory. LeakTracker (Flutter SDK built-in, debug mode) auto-detects widget lifecycle leaks.
- Monitoring production health dashboard — Key metrics to track: crash-free user rate (>99.5%), ANR rate (<0.47% for Play Store featuring), p50/p95 API latency, DAU/MAU ratio (engagement health), error rate by endpoint. Use Firebase Performance Monitoring for network request tracing.
- Privacy and consent for analytics — GDPR requires consent before tracking EU users. Firebase Analytics respects Analytics Collection Enabled flag. Implement a consent gate at first launch. Consider consent management platforms (OneTrust, Usercentrics). Never track without consent — fines are severe and app stores enforce this.
Code example
// Comprehensive observability setup
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:firebase_remote_config/firebase_remote_config.dart';
import 'package:talker_flutter/talker_flutter.dart';
final talker = TalkerFlutter.init();
Future<void> setupObservability() async {
// Crashlytics — catch all Flutter errors
FlutterError.onError = (errorDetails) {
FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails);
};
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};
}
// Analytics service with event taxonomy
class Analytics {
static final _analytics = FirebaseAnalytics.instance;
static Future<void> workspaceCreated({
required String plan,
required int memberCount,
}) async {
await _analytics.logEvent(
name: 'workspace_created',
parameters: {'plan': plan, 'member_count': memberCount},
);
talker.info('workspace_created plan=$plan members=$memberCount');
}
static Future<void> paymentFailed({
required String reason,
required String plan,
}) async {
await _analytics.logEvent(
name: 'payment_failed',
parameters: {'reason': reason, 'plan': plan},
);
// Also record as non-fatal for investigation
FirebaseCrashlytics.instance.recordError(
'PaymentFailed: $reason',
null,
fatal: false,
information: ['plan: $plan'],
);
}
}
// Feature flags with Remote Config
class FeatureFlags {
static final _config = FirebaseRemoteConfig.instance;
static Future<void> initialize() async {
await _config.setConfigSettings(RemoteConfigSettings(
fetchTimeout: const Duration(seconds: 10),
minimumFetchInterval: const Duration(hours: 12),
));
// Safe defaults — app works without network
await _config.setDefaults({
'ai_summary_enabled': false,
'max_file_upload_mb': 25,
'paywall_variant': 'control',
});
await _config.fetchAndActivate();
}
static bool get aiSummaryEnabled => _config.getBool('ai_summary_enabled');
static int get maxFileUploadMb => _config.getInt('max_file_upload_mb');
static String get paywallVariant => _config.getString('paywall_variant');
}Line-by-line walkthrough
- 1. FlutterError.onError catches errors inside the Flutter framework — widget build failures, assertion errors, rendering exceptions.
- 2. PlatformDispatcher.instance.onError catches errors in async code and platform-level errors not caught by Flutter — the two together give full coverage.
- 3. recordFlutterFatalError vs recordError: fatal errors crashed the app; non-fatal were caught but worth tracking. Crashlytics groups these separately in the dashboard.
- 4. Analytics.workspaceCreated logs to both Firebase Analytics and the local talker logger — production log for analysis, local log for debugging.
- 5. payment_failed is also recorded as a non-fatal Crashlytics error with context — this surfaces payment issues in the Crashlytics dashboard alongside crashes for prioritisation.
- 6. RemoteConfigSettings sets fetchTimeout (network call timeout) and minimumFetchInterval (how often to hit the server — avoid quota exhaustion).
- 7. setDefaults ensures the app behaves correctly even on first launch before Remote Config fetches — critical for flags that gate core features.
- 8. fetchAndActivate() fetches new values AND activates them atomically — fetching without activating leaves stale values until the next app restart.
Spot the bug
class FeatureFlags {
static final _config = FirebaseRemoteConfig.instance;
static Future<void> initialize() async {
await _config.fetchAndActivate();
}
static bool get newDashboard => _config.getBool('new_dashboard');
}
// In main():
await FeatureFlags.initialize();
if (FeatureFlags.newDashboard) {
runApp(const NewDashboardApp());
} else {
runApp(const LegacyApp());
}Need a hint?
This crashes on first install and sometimes fails silently. Two things are wrong.
Show answer
Bug 1: No setDefaults call — on first install, before any fetch completes, getBool('new_dashboard') returns false (the SDK default for missing bool keys) but this is accidental behaviour, not intentional. If the default should be true, the app incorrectly shows the legacy UI. Fix: call setDefaults({'new_dashboard': false}) with explicit intent before fetchAndActivate. Bug 2: No setConfigSettings call — without minimumFetchInterval configuration, the SDK uses its default (12h in production, unlimited in debug) but more importantly there is no fetchTimeout set. If the network is slow, fetchAndActivate can hang indefinitely. Fix: add RemoteConfigSettings with a fetchTimeout of 10 seconds so the app startup isn't blocked on a slow Remote Config server — it falls back to cached/default values gracefully.
Explain like I'm 5
Imagine your app is a spaceship. Logging is the flight recorder that writes down everything that happens. Crashlytics is the emergency beacon that screams if something breaks. Analytics is the mission report that tells you how the journey went. Feature flags are the control switches that let ground control turn things on and off while you're flying.
Fun fact
Spotify uses feature flags to deploy code to 100% of users but enable features for 0% initially — every new feature ships dark (deployed but inactive) and is turned on gradually. This decouples deployment from release, dramatically reducing incident risk.
Hands-on challenge
Set up a complete observability stack for a Flutter app: (1) Configure Crashlytics to catch both Flutter framework errors and platform errors. (2) Create an Analytics class with at least 3 semantically named events (with parameters). (3) Set up Remote Config with 3 feature flags and safe defaults. (4) Implement a crash triage checklist for a hypothetical NullPointerException in a payment screen. (5) Define what a healthy crash-free user rate target should be.
More resources
- Firebase Crashlytics Flutter setup (FlutterFire)
- Firebase Remote Config Flutter (FlutterFire)
- Talker — Flutter logging package (pub.dev)
- Firebase Analytics Flutter (FlutterFire)
- Feature flag best practices (Martin Fowler)