Time Limited Offer

50% off Flutter in Production

Spring Sale ends May 8th

View Offer

Flutter App Analytics: Scalable Architecture & Firebase Setup

Picture this: You've told your friends and family about your amazing new app (😄). Maybe you’ve even built a waitlist of eager users. You’re excited—and ready to hit “Publish.”

But wait


How will you know what’s working?

  • How many users complete onboarding?
  • How many sign up and create an account?
  • How many reach the paywall—and convert?

Without analytics, you’re flying blind.

You’re making product decisions based on vibes, not data. That’s a fast way to stall growth, waste dev time, or worse—lose your users.

The Importance of Analytics

Analytics give you the data you need to grow. They help you:

  • Measure engagement: Who’s using your app, how often, and for how long?
  • Understand retention: Where are users dropping off—and why?
  • Track feature usage: What’s popular? What’s ignored?
  • Optimize revenue: Monitor purchases, subscriptions, and churn.
  • Know your users: Demographics, devices, platforms.
  • Follow the journey: Map user flows across the app and spot friction points.

With this info, you can stop guessing and start making product decisions that actually improve your app.

Example Mixpanel dashboard showing primary user metrics for my Flutter Tips app
Example Mixpanel dashboard showing primary user metrics for my Flutter Tips app

What You'll Learn

In this article, I’ll show you how to track analytics in your Flutter app—from basic event logging to a scalable architecture that works with multiple providers like Firebase and Mixpanel.

Here’s what we’ll cover:

  • What to track: Choosing the right events—and why they matter.
  • How to structure it: Simple and scalable architectures for event tracking.
  • Firebase Analytics setup: How to wire everything up in a real app.

Let’s dive in. 👇

This article will guide you through the fundamentals. If you want to go deeper, check out my Flutter in Production course, which contains an entire module about app analytics.

Introduction to Event Tracking

Ask five developers how they implemented analytics, and you’ll likely get five different answers (I know because I did).

As usual with software engineering, there’s no one-size-fits-all solution—just tradeoffs.

So before we dive into the code, let’s zoom out and answer a few key questions:

  • What events should we track?
  • How should we track them?
  • What requirements should our solution meet?

Once we’ve nailed that down, we’ll pick a suitable architecture.

What Events Do We Need to Track?

In simple terms, events represent interactions between the user and your app.

Take my Flutter Ship app as an example:

Flutter Ship App Demo

The app helps you "tick all the boxes" before releasing your Flutter apps. It’s basically a pre-filled TODO list.

Flutter Ship App web demo. Open in a separate window

If you wanted to add analytics to this app, what would you track?

Focus on events that:

  • Help the user succeed (e.g. completing tasks)
  • Help your business succeed (e.g. user signups, retention, monetization)

For this app, meaningful events are:

  1. Create a new app
  2. Edit an existing app
  3. Delete an app
  4. Complete a task

Everything else is optional. Don’t track for the sake of tracking.


Tracking Events with Firebase Analytics and Mixpanel

Now, suppose you want to track these events in your Flutter app.

If you're using the firebase_analytics package, you can do it like this:

final analytics = FirebaseAnalytics.instance; analytics.logEvent('app_created'); analytics.logEvent('app_updated'); analytics.logEvent('app_deleted'); analytics.logEvent('task_completed', parameters: {'count': count});

Or, if you're using the mixpanel_flutter package, this is the way:

final mixpanel = await Mixpanel.init( Env.mixpanelProjectToken, trackAutomaticEvents: true, ); mixpanel.track('App Created'); mixpanel.track('App Updated'); mixpanel.track('App Deleted'); mixpanel.track('Task Completed', properties: {'count': count});

You can already spot the problem: different APIs, different syntax.

❌ Scattering these calls all over your codebase is a bad idea. ❌

Here's why:

  • You lock yourself into specific SDKs
  • You duplicate event names everywhere (easy to mistype, hard to refactor)
  • You lose type safety and IDE support
  • You can’t reuse logic or dispatch to multiple vendors
  • You mix business logic with implementation details

We can do much better by defining a clear interface—one that separates event tracking from everything else.

Let’s formalize some requirements next.

App Analytics: Requirements

Before jumping into implementation, let’s define what we actually need from an analytics system.

Here are the key requirements:

  1. Separation of concerns: App code should never call vendor SDKs (like Firebase or Mixpanel) directly. Instead, we’ll route all event tracking through a clear interface.
  1. Support for multiple clients: We should be able to send events to more than one provider. For example, you might want to log to both Firebase and Mixpanel at the same time.
  1. Debug vs Release mode: In development, we only want to log events to the console. In release mode, real analytics providers should be used—with no changes to app logic.

Other things worth thinking about:

  • Screen view tracking
  • Opt-in/out toggles (e.g. for privacy/GDPR)
  • User identification (after login)
  • Multiple build flavors (dev/staging/prod)

But for now, we’ll focus on the two most important ones:

  • Keeping analytics isolated from app logic
  • Supporting multiple analytics providers

Next, let's walk through two possible architectures and break down their tradeoffs. 👇

Super-Simple Analytics Architecture

This setup is adapted from a suggestion I received on X (Twitter):

import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:mixpanel_flutter/mixpanel_flutter.dart'; import 'package:firebase_analytics/firebase_analytics.dart'; class AnalyticsClient { const AnalyticsClient(this._analytics, this._mixpanel); final FirebaseAnalytics _analytics; final Mixpanel _mixpanel; Future<void> track( String name, { Map<String, dynamic> params = const {}, }) async { if (kReleaseMode) { await _analytics.logEvent(name: name, parameters: params); await _mixpanel.track(name, properties: params); } else { log('$name $params', name: 'Event'); } } }

This is about as simple as it gets. It gives you a shared interface for tracking events:

  • In release mode, it logs to both Firebase and Mixpanel
  • In debug mode, it just prints to the console

If you're using Riverpod, tracking events looks like this:

final analytics = ref.read(analyticsClientProvider); analytics.track('app_created'); analytics.track('app_updated'); analytics.track('app_deleted'); analytics.track('task_completed', parameters: {'count': count});

As long as your event names follow the Firebase Analytics guidelines (1–40 alphanumeric characters or underscores), you’re good to go.

This approach is surprisingly flexible for how little code it requires. You can swap out analytics providers by updating a single class, and the rest of your app doesn’t care. 👍

But It Has Drawbacks

This architecture starts to break down as your app grows:

  • No conditional tracking: You can’t log some events to Mixpanel but not Firebase. This is a problem if you're watching your event volume (and costs).
    • Hardcoded strings everywhere: Event names end up duplicated across the codebase. Easy to mistype. Hard to refactor. A big footgun on teams.
      • No single source of truth:There’s no centralized list of events or their expected parameters.
        • No autocomplete: You lose IDE support. If you had a type-safe API, you'd get helpful autocompletion:
          Analytics methods auto-completion in VSCode
          Analytics methods auto-completion in VSCode

          This setup may be good enough for solo devs or small apps where you want minimal friction and full control.

          But if you’re working in a larger codebase or with a team, it’s worth investing in something a bit more robust. Let’s look at a more scalable alternative. 👇

          More Complex Analytics Architecture

          If you want a type-safe analytics API with autocomplete and clear separation of concerns, here’s the approach I recommend.

          Start by defining an abstract AnalyticsClient interface. For the Flutter Ship app, it might look like this:

          abstract class AnalyticsClient { Future<void> trackAppCreated(); Future<void> trackAppUpdated(); Future<void> trackAppDeleted(); Future<void> trackTaskCompleted(int completedCount); }

          This way, all the events are defined in a single place, avoiding duplication and potential mistakes.

          But how do we support multiple clients?

          Here’s one way to structure it:

          App Analytics Architecture
          App Analytics Architecture

          How this works:

          • You define all your events in an abstract AnalyticsClient interface.
          • Each concrete implementation (e.g. LoggerAnalyticsClient, FirebaseAnalyticsClient, etc.) handles how those events are logged.
          • An AnalyticsFacade class implements AnalyticsClient too—but just dispatches to all registered clients.
          • Finally, the app uses a analyticsFacadeProvider to access the facade and call the appropriate tracking methods.

          Now, from your app code, tracking an event is as simple as:

          final analytics = ref.read(analyticsFacadeProvider); analytics.trackEditApp();

          And since each event is a dedicated method, you get autocomplete and type safety out of the box:

          Analytics methods auto-completion in VSCode
          Analytics methods auto-completion in VSCode

          How Do We Implement This?

          This is just the high-level architecture. To make it work, we’ll need to answer:

          • How do we register multiple clients in AnalyticsFacade?
          • How are events dispatched to each client?
          • What do concrete AnalyticsClient subclasses look like?

          Let’s walk through the implementation details next. 👇

          App Analytics: Implementation Details

          To implement the architecture we just discussed, we’ll need a few key building blocks:

          1. An AnalyticsClient interface that defines all the events we want to track.
          2. An AnalyticsFacade that implements AnalyticsClient, and delegates calls to multiple clients.
          3. A LoggerAnalyticsClient for local dev—logs events to the console.
          4. Additional clients like FirebaseAnalyticsClient, MixpanelAnalyticsClient, etc., that wrap vendor SDKs.

          To keep things organized, I recommend putting all of this in a monitoring folder:

          ‣ lib ‣ src ‣ monitoring ‣ analytics_client.dart ‣ analytics_facade.dart ‣ logger_analytics_client.dart ‣ firebase_analytics_client.dart

          Here’s how each part works:

          1. The AnalyticsClient Cnterface

          This is the contract. It defines all the events your app supports.

          abstract class AnalyticsClient { // Custom events for the Flutter Ship app. // TODO: Replace with your own events. Future<void> trackNewAppOnboarding(); Future<void> trackNewAppHome(); Future<void> trackAppCreated(); Future<void> trackAppUpdated(); Future<void> trackAppDeleted(); Future<void> trackTaskCompleted(int completedCount); }

          Since this class is abstract, all methods are declarations only—no implementations.

          2. The LoggerAnalyticsClient Class

          This one’s simple: it logs events to the console. Useful for local dev and tests before wiring up a real analytics backend:

          import 'dart:async'; import 'dart:developer'; import 'package:flutter_ship_app/src/monitoring/analytics_client.dart'; class LoggerAnalyticsClient implements AnalyticsClient { const LoggerAnalyticsClient(); static const _name = 'Event'; @override Future<void> trackNewAppHome() async { log('trackNewAppHome', name: _name); } @override Future<void> trackNewAppOnboarding() async { log('trackNewAppOnboarding', name: _name); } @override Future<void> trackAppCreated() async { log('trackAppCreated', name: _name); } @override Future<void> trackAppUpdated() async { log('trackAppUpdated', name: _name); } @override Future<void> trackAppDeleted() async { log('trackAppDeleted', name: _name); } @override Future<void> trackTaskCompleted(int completedCount) async { log('trackTaskCompleted(completedCount: $completedCount)', name: _name); } }

          We’ll add a FirebaseAnalyticsClient later. For now, focus on what you want to track—not how it’s sent.

          Is There a Better Way to Define Events?

          This approach works—but it’s not DRY. You’re copy-pasting a lot when adding new events:

          // copy-paste when creating new events @override Future<void> trackAppCreated() async { log('trackAppCreated', name: _name); }

          Alternatives worth considering:

          • Enums: Define an AppEvent enum and list all the possible events as values. This doesn't work if different events need different arguments.
          • Sealed classes: Use a base AppEvent sealed class and create a subclass every time we need a new event. More flexible, but verbose.
          • Freezed union types: Terse syntax and pattern matching—but requires codegen.

          Each option has tradeoffs. For now, we'll stick with our initial approach.

          3. The AnalyticsFacade Class

          The AnalyticsFacade implements the AnalyticsClient interface and forwards each method call to all registered clients.

          import 'package:flutter/foundation.dart'; import 'package:flutter_ship_app/src/monitoring/analytics_client.dart'; import 'package:flutter_ship_app/src/monitoring/logger_analytics_client.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'analytics_facade.g.dart'; // https://refactoring.guru/design-patterns/facade class AnalyticsFacade implements AnalyticsClient { const AnalyticsFacade(this.clients); final List<AnalyticsClient> clients; @override Future<void> trackAppOpened() => _dispatch( (c) => c.trackAppOpened(), ); @override Future<void> trackNewAppHome() => _dispatch( (c) => c.trackNewAppHome(), ); @override Future<void> trackNewAppOnboarding() => _dispatch( (c) => c.trackNewAppOnboarding(), ); @override Future<void> trackAppCreated() => _dispatch( (c) => c.trackAppCreated(), ); @override Future<void> trackAppUpdated() => _dispatch( (c) => c.trackAppUpdated(), ); @override Future<void> trackAppDeleted() => _dispatch( (c) => c.trackAppDeleted(), ); @override Future<void> trackTaskCompleted(int completedCount) => _dispatch( (c) => c.trackTaskCompleted(completedCount), ); Future<void> _dispatch( Future<void> Function(AnalyticsClient client) work, ) async { for (final client in clients) { await work(client); } } }

          The _dispatch method removes duplication and broadcasts each event to all the registered clients.

          Setting Up the Provider

          Here’s how to expose the AnalyticsFacade via Riverpod:

          @Riverpod(keepAlive: true) AnalyticsFacade analyticsFacade(Ref ref) { return const AnalyticsFacade([ if (!kReleaseMode) LoggerAnalyticsClient(), ]); }

          In debug builds, you get console logging. In release builds, you can add real clients like FirebaseAnalyticsClient (more on this later).

          This setup gives you a clear, modular, and scalable way to track analytics events—without coupling your app logic to any vendor-specific SDK.

          Which Architecture Is Better?

          The architecture we just built gives you:

          • A type-safe event tracking API
          • Autocomplete and IDE support
          • Clear separation of concerns
          • Support for multiple analytics backends

          Here’s a visual recap:

          App Analytics Architecture
          App Analytics Architecture

          It’s flexible, scalable, and production-ready.

          But it comes at a cost.

          Every time you add a new event, you need to:

          • Add a method to the AnalyticsClient interface
          • Implement it in the AnalyticsFacade
          • Implement it in every concrete client (e.g. Logger, Firebase, Mixpanel)

          When the Simple Approach Is Enough

          For small apps or quick MVPs, the simple approach may be enough:

          import 'dart:developer'; import 'package:flutter/foundation.dart'; import 'package:mixpanel_flutter/mixpanel_flutter.dart'; import 'package:firebase_analytics/firebase_analytics.dart'; class AnalyticsClient { const AnalyticsClient(this._analytics, this._mixpanel); final FirebaseAnalytics _analytics; final Mixpanel _mixpanel; Future<void> track( String name, { Map<String, dynamic> params = const {}, }) async { if (kReleaseMode) { await _analytics.logEvent(name: name, parameters: params); await _mixpanel.track(name, properties: params); } else { log('$name $params', name: 'Event'); } } }

          This gets the job done—fast. But you lose:

          • Autocomplete
          • Type safety
          • Centralized event definitions
          • Vendor-level control and filtering
          Analytics methods auto-completion in VSCode
          Analytics methods auto-completion in VSCode

          Bottom line: For anything beyond a throwaway prototype, go with the more structured architecture. It’s more verbose up front, but it pays off in maintainability, testability, and developer experience.

          Now let’s see how to actually call the analyticsFacadeProvider to track events in real app code.

          Tracking Custom Events

          We’ve defined an interface like this:

          abstract class AnalyticsClient { Future<void> trackNewAppOnboarding(); Future<void> trackNewAppHome(); Future<void> trackAppCreated(); Future<void> trackAppUpdated(); Future<void> trackAppDeleted(); Future<void> trackTaskCompleted(int completedCount); }

          But unless you actually call these methods, nothing gets tracked.

          So where should you put these calls?

          • In widgets?
          • In controllers?
          • Somewhere else?

          Let’s look at both options.

          Tracking Events in Widgets and Controllers

          Here’s a simple example—a + button on the home page:

          New app buttons on the home page
          New app buttons on the home page

          The event tracking code looks like this:

          IconButton( onPressed: () { // event tracking code unawaited(ref.read(analyticsFacadeProvider).trackNewAppHome()); // navigation code Navigator.of(context).pushNamed(AppRoutes.createApp); }, icon: Icon(Icons.add), )

          In this case, the analytics call is placed directly in the onPressed callback.

          💡 The unawaited function is used here to fire the event without blocking. These are fire-and-forget calls—you don’t need to wait for them. More on that here: Use unawaited for your analytics calls.

          But if your logic is more complex, it’s better to move analytics calls into a controller.

          Here’s an example from a custom controller class:

          /// This class holds the business logic for creating, editing, and deleting apps /// using the underlying AppDatabase class for data persistence. /// More info here: https://codewithandrea.com/articles/flutter-presentation-layer/ @riverpod class CreateEditAppController extends _$CreateEditAppController { @override void build() { // no-op } Future<void> createOrEditApp(App? existingApp, String newName) async { final db = ref.read(appDatabaseProvider); // * Update the DB if (existingApp != null) { await db.editAppName(appId: existingApp.id, newName: newName); } else { await db.createNewApp(name: newName); } // * Analytics code if (existingApp != null) { unawaited(ref.read(analyticsFacadeProvider).trackAppUpdated()); } else { unawaited(ref.read(analyticsFacadeProvider).trackAppCreated()); } } Future<void> deleteAppById(int appId) async { await ref.read(appDatabaseProvider).deleteAppById(appId); ref.read(analyticsFacadeProvider).trackAppDeleted(); } }

          This controller handles mutations and database interactions—so it’s the perfect place to also track related analytics events.

          Key Guidelines for Tracking Events

          • ❌ Never track events in build(). It can be called dozens of times per second during animations. Same goes for initState() and other lifecycle methods.
          • ✅ Track in widget callbacks like onPressed. If the logic is complex, move it out into a controller or service class.
          • ✅ Tracking in controllers is ideal. They’re UI-free, easier to test, and already house the logic that triggers the events.
          • ❌ Avoid tracking events in the data/networking layer. Events should be tracked at the source (UI layer) for better accuracy.
          • ✅ Use unawaited() for all analytics calls. Don’t block UI or care about results—just fire and forget.

          Firebase Analytics Integration

          So far, we’ve been using the LoggerAnalyticsClient to print events to the console.

          But for production apps, you’ll want to plug in a real analytics backend—like Firebase Analytics, Mixpanel, or PostHog.

          Thanks to our AnalyticsFacade, you can do this without changing your app code. Just add a new client, register it, and you’re done. That’s the power of separation of concerns.

          Let’s walk through integrating Firebase Analytics.

          Adding Firebase to your Flutter app

          Follow the official guide to add Firebase to your Flutter app.

          If your app uses multiple flavors (e.g. dev, staging, prod), the setup is more involved. I cover that in detail here:

          Creating the FirebaseAnalyticsClient Class

          This is a straightforward implementation of the AnalyticsClient interface using the Firebase SDK:

          import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:flutter_ship_app/src/monitoring/analytics_client.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'firebase_analytics_client.g.dart'; class FirebaseAnalyticsClient implements AnalyticsClient { const FirebaseAnalyticsClient(this._analytics); final FirebaseAnalytics _analytics; @override Future<void> trackNewAppHome() async { await _analytics.logEvent(name: 'new_app_home'); } @override Future<void> trackNewAppOnboarding() async { await _analytics.logEvent(name: 'new_app_onboarding'); } @override Future<void> trackAppCreated() async { await _analytics.logEvent(name: 'app_created'); } @override Future<void> trackAppUpdated() async { await _analytics.logEvent(name: 'app_updated'); } @override Future<void> trackAppDeleted() async { await _analytics.logEvent(name: 'app_deleted'); } @override Future<void> trackTaskCompleted(int completedCount) async { await _analytics.logEvent( name: 'task_completed', parameters: {'count': completedCount}, ); } } @Riverpod(keepAlive: true) FirebaseAnalyticsClient firebaseAnalyticsClient(Ref ref) { return FirebaseAnalyticsClient(FirebaseAnalytics.instance); }

          Registering the Client with the Facade

          Update your analyticsFacadeProvider to include the Firebase client:

          @Riverpod(keepAlive: true) AnalyticsFacade analyticsFacade(Ref ref) { final firebaseAnalyticsClient = ref.watch(firebaseAnalyticsClientProvider); return AnalyticsFacade([ firebaseAnalyticsClient, if (!kReleaseMode) const LoggerAnalyticsClient(), ]); }

          And just like that—no changes to the rest of your app—your events are now being sent to Firebase Analytics.

          You’ll see them show up in the Firebase Console:

          First active user showing in the Firebase Analytics dashboard
          First active user showing in the Firebase Analytics dashboard

          Here’s what it looks like in my Flutter Tips app:

          Firebase Analytics dashboard for the Flutter Tips app
          Firebase Analytics dashboard for the Flutter Tips app

          Flutter App Analytics: Summary

          In this article, we covered everything you need to get started with production-grade analytics in your Flutter app:

          • ✅ Why analytics is essential for shipping and growing with confidence
          • ✅ How to think about and choose the right events to track
          • ✅ A simple architecture for quick wins
          • ✅ A scalable, type-safe architecture for larger apps and teams
          • ✅ How to track events from widgets and controllers
          • ✅ How to integrate Firebase Analytics with zero impact on app logic

          But hold on—there’s more to shipping analytics in real-world apps.

          Before you hit publish, consider:

          • Tracking screen views: Use a navigation observer to capture screen transitions automatically.
          • Opt-in/out analytics: Let users disable tracking if required (e.g. for GDPR compliance).
          • User identification: Link events to logged-in users for cross-device tracking and funnel analysis.
          • Mixpanel or PostHog integration: Firebase is free, but tools like Mixpanel offer powerful filtering and reporting—and are much more privacy-friendly.

          To go deeper into these topics—and way beyond analytics—check out my latest course. 👇

          Flutter in Production

          When it comes to shipping and maintaining apps in production, there are many important aspects to consider:

          • Preparing for release: splash screens, flavors, environments, error reporting, analytics, force update, privacy, T&Cs.
          • App Submissions: app store metadata & screenshots, compliance, testing vs distribution tracks, dealing with rejections.
          • Release automation: CI workflows, environment variables, custom build steps, code signing, uploading to the stores.
          • Post-release: error monitoring, bug fixes, addressing user feedback, over-the-air updates, feature flags & A/B testing.

          My latest course will help you get your app to the stores faster and with fewer headaches.

          If you’re interested, you can learn more and enroll here. 👇

          Want More?

          Invest in yourself with my high-quality Flutter courses.

          Flutter Foundations Course

          Flutter Foundations Course

          Learn about State Management, App Architecture, Navigation, Testing, and much more by building a Flutter eCommerce app on iOS, Android, and web.

          Flutter & Firebase Masterclass

          Flutter & Firebase Masterclass

          Learn about Firebase Auth, Cloud Firestore, Cloud Functions, Stripe payments, and much more by building a full-stack eCommerce app with Flutter & Firebase.

          The Complete Dart Developer Guide

          The Complete Dart Developer Guide

          Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Last updated to Dart 2.15.

          Flutter Animations Masterclass

          Flutter Animations Masterclass

          Master Flutter animations and build a completely custom habit tracking application.