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.
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:
The app helps you "tick all the boxes" before releasing your Flutter apps. Itâs basically a pre-filled TODO list.
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:
- Create a new app
- Edit an existing app
- Delete an app
- 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:
- 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.
- 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.
- 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:
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:
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:
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:
- AnÂ
AnalyticsClient
 interface that defines all the events we want to track. - AnÂ
AnalyticsFacade
 that implementsÂAnalyticsClient
, and delegates calls to multiple clients. - AÂ
LoggerAnalyticsClient
 for local devâlogs events to the console. - 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:
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
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:
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:
Hereâs what it looks like in my 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. đ