Analytics As Commands: An Opinionated Approach To Robust Analytics Data Gathering In Flutter - Part Two
This is the second part and continuation of the 2-part article that discusses the limitations of common approaches to analytics data gathering, especially in large projects, and proposes a robust solution for projects of all sizes. While you can read this article as a standalone article, it is best that you refer back to Part One here to get the context and also understand the problem statement.
In the first part of this article, we saw some of the limitations of using popular approaches to analytics data collection, now let us discuss the solution.
Analytics as a Command
Overview
This approach borrows from the Command Design Pattern. We treat each analytics event as a command, which we then pass to a command handler to execute. Our business logic only knows it needs to send an event command to a generic handler, the handler only knows it needs to invoke a command and the command itself knows what it needs to do to log the event accordingly. Okay, enough of human language, let’s dive into the code.
The architecture.
1. AnalyticsLogger
This class is the abstract class that our program understands and relies on. It is the main object that knows how to handle an event. It is the class that the external analytics loggers must implement before they can be used in our application.
// this is the interface that all third-party analytics
// loggers must implement
// e.g FirebaseAnalyticsLogger, MixpanelAnalyticsLogger etc
abstract class AnalyticsLogger {
// we declare the basic methods that must be implemented
void logSignUp();
void logLogin();
void logLogout();
void logEvent({
required String name,
Map<String, dynamic>? parameters,
});
}
2. AnalyticsLogCommand
We treat each event as a command, which means each event will have to be a command object that can be executed throughout the code. So we create an abstract AnalyticsLogCommand
class. This abstract command class will have a single execute
method that accepts a list of analytics loggers (AnalyticsLogger
objects).
import 'package:my_project/src/core/analytics/analytics_logger.dart';
// this is the analytics command interface must implement
// e.g LogPurchaseDoneAnalyticsCommand, LogLoginAnalyticsCommand etc
abstract class AnalyticsLogCommand {
// the execute method takes a list of Analytics loggers,
// We are coming back to this shortly
void execute({required List<AnalyticsLogger> loggers});
}
3. AnalyticsCommandHandler
This is the object responsible for executing our analytics commands. It is a class that takes in a list of analytic loggers and provides a single method invoke
which takes an AnalyticsLogCommand and calls the execute
function with the list of provided loggers.
import 'package:my_project/src/core/analytics/analytics_logger.dart';
import 'package:my_project/src/core/analytics/analytics_command.dart';
// this is the interface that our application relies on
abstract class IAnalyticsCommandHandler {
void invoke(AnalyticsLogCommand command);
}
// this is the concrete implementation of the analytics handler
class AnalyticsCommandHandler implements IAnalyticsCommandHandler {
final List<AnalyticsLogger> _loggers;
// we pass the loggers in a list to the command handler
// via the constructor or any other means
AnalyticsCommandHandler({
required List<AnalyticsLogger> loggers,
}) : _loggers = loggers ;
@override
void invoke(AnalyticsLogCommand command) {
return command.execute(loggers: _loggers);
}
}
And these are all the base components our analytics architecture needs.
Implementation Example
To demonstrate the beauty of this approach, let us do an integration for an authentication feature analytics logging with Firebase Analytics.
1. Create Concrete implementation for analytics service:
After we have created the core components of the architecture, let us create a concrete implementation of our analytics logger for Firebase analytics.
// concrete implementation of AnalyticsLogger for Firebase analytics
class FirebaseAnalyticsLogger extends AnalyticsLogger {
// we pass in the firebase analytics dependency
final FirebaseAnalytics _analytics;
FirebaseAnalyticsLogger(this._analytics);
// then we implement all the methods and
// provide implementations with firebase analytics
@override
void logSignUp() {
_analytics.logSignUp();
}
@override
void logLogin() {
_analytics.logLogin(loginMethod: 'email');
}
@override
void logLogout() {
_analytics.logEvent(name: 'logout');
}
@override
void logEvent({
required String name,
Map<String, dynamic>? parameters,
}) {
_analytics.logEvent(name: name, parameters: parameters);
}
}
2. Register the AnalyticsLogger in our analytics command handler
We simply instantiate our command handler with the AnalyticsLogger
concrete implementation. Notice that since our command handler accepts a list of loggers, we can pass in multiple loggers here too. If you are using getIt as your service locator, the implementations looks like this:
// if you are using a service locator like getIt
getIt.registerLazySingleton<IAnalyticsCommandHandler>(
() => AnalyticsCommandHandler(
loggers: [
FirebaseAnalyticsLogger(
FirebaseAnalytics.instance,
),
],
),
);
// or you can create your analytics logger however you wish, it doesn't matter as long as you can call it when you need it.
3. Create individual analytics commands
You can choose to structure these commands as you deem fit. You can create an authentication analytics command directory and declare each command in separate files, or you can declare all your commands in a single file per feature layer. These concrete commands can accept necessary parameters as required.
class LogSignInCommand extends AnalyticsLogCommand {
@override
Future<void> execute({required List<AnalyticsLogger> loggers}) async {
// since we are relying on an interface, we can loop
// through all the loggers and call the methods safely
for (final logger in loggers) {
logger.logLogin();
}
}
}
class LogSignUpCommand extends AnalyticsLogCommand {
@override
Future<void> execute({
required List<AnalyticsLogger> loggers,
}) async {
for (final logger in loggers) {
logger.logSignUp();
}
}
}
class LogLogoutCommand extends AnalyticsLogCommand {
@override
Future<void> execute({
required List<AnalyticsLogger> loggers,
}) async {
for (final logger in loggers) {
logger.logLogout();
}
}
}
class LogPasswordResetCommand extends AnalyticsLogCommand {
@override
Future<void> execute({
required List<AnalyticsLogger> loggers,
}) async {
for (final logger in loggers) {
logger.logEvent(name: 'password_reset');
}
}
}
class LogEmailVerifiedCommand extends AnalyticsLogCommand {
@override
Future<void> execute({
required List<AnalyticsLogger> loggers,
}) async {
for (final logger in loggers) {
logger.logEvent(name: 'email_verified');
}
}
}
...
4. Finally, pass the AnalyticsCommand to the Command handler in our business logic.
// where we need to log a login event in our business logic
Future<void> loginUser(...){
...
// This is used here to simplify this article, to make this
// function more testable, i.e test that we are actually
// logging login events, we can inject our analytics
// command handler into the constructor instead
// of calling our service locator directly as I did here.
// we pass the LogSignInCommand to the Command handler
// notice that our business logic is relying on
// IAnalyticsCommandHandler, the interface, and not
// AnalyticsCommandHandler, the implementation
getIt<IAnalyticsCommandHandler>().invoke(LogSignInCommand());
}
// where we need to log the email verified event in our business logic
Future<void> verifyEmail(...){
...
getIt<IAnalyticsCommandHandler>().invoke(
LogEmailVerifiedCommand(),
);
}
And that is all we need to do to start logging our analytics event from anywhere in our application.
Advantages of the Analytics as Commands Approach
1. Migration to another Analytics Channel
If we need to change from Firebase analytics to MixPanel for instance, we only need to create a new implementation of the analytics logger for the new channel and pass it to our analytics command handler:
// create a new analytics logger for Mixpanel
class MixpanelAnalyticsLogger extends AnalyticsLogger {
final Mixpanel _mixpanel;
FirebaseAnalyticsLogger(this._mixpanel);
@override
void logSignUp() {
_mixpanel.logSignUp();
}
@override
void logLogin() {
_mixpanel.logLogin();
}
@override
void logLogout() {
_mixpanel.logEvent(name: 'logout');
}
@override
void logEvent({
required String name,
Map<String, dynamic>? parameters,
}) {
_mixpanel.logEvent(name: name, parameters: parameters);
}
}
// then pass it to the registration of IAnalyticsCommandHandler in our business logic
getIt.registerLazySingleton<IAnalyticsCommandHandler>(
() => AnalyticsCommandHandler(
loggers: [
MixpanelAnalyticsLogger(
Mixpanel(), // your mixpanel dependency
),
],
),
);
And that is all we need to migrate to a new analytics service. We do not need to touch our business logic at all or spend time modifying any of the existing loggers or commands.
2. Using multiple analytics channels
Remember that our AnalyticsCommand
's execute
method takes a list of loggers, this is precisely so that we don't limit our commands to one analytics channel and be able to use multiple channels if we decide to do that in the future.
So to use multiple channels, we just need to pass them to our AnalyticsCommandHandler class when creating it:
getIt.registerLazySingleton<IAnalyticsCommandHandler>(
() => AnalyticsCommandHandler(
loggers: [
// logger 1
FirebaseAnalyticsLogger(
FirebaseAnalytics.instance,
),
// logger 2
MixpanelAnalyticsLogger(
Mixpanel(),
),
],
),
);
You can see how clean and easy it is to swap analytics channels with this approach. We do not need to touch any of the commands we have written, touch our business logic where we create events, or touch any existing analytics logger.
3. Logging specific events to specific channels
If we need to log specific events to a specific channel, we can do that easily with this approach in various ways depending on our project structure and the nature of the analytics event.
One way to do this is if the event is feature-wide, say we want only to log payment events to only Mixpanel, we can create a new AnalyticsCommandHandler just for the payment feature.
// we can create a separate command handler for payments
// and pass it only the loggers we need
final paymentAnalyticshanddler = AnalyticsCommandHandler(
loggers: [
// then we can pass only the mixpanel.
MixpanelAnalyticsLogger(Mixpanel()),
],
);
// and use it in our business logic like so
paymentAnalyticshanddler.invoke(
LogOrderCanceledDuringPaymentCommand(
totalPrice: 100,
totalItems: 5,
)
)
Then we route all payment analytics commands to this specific command handler.
Another way is to provide all the loggers in the app-wide analytics command handler and filter the logger we need in the individual commands. E.g:
class LogOrderCanceledDuringPaymentCommand extends AnalyticsLogCommand
{
final int totalPrice;
final int totalItems;
// class constructor
LogOrderCanceledDuringPaymentCommand({
required this.totalPrice;
required this.totalItems;
});
@override
void execute({
required List<AnalyticsLogger> loggers,
}) {
try {
// we filter for the exact logger(s) we want
final firebaseLogger = loggers.firstWhere(
(logger) => logger is FirebaseAnalyticsLogger,
) as FirebaseAnalyticsLogger;
firebaseLogger.logEvent(
name: 'order_canceled_before_payment',
parameters: {
"totalPrice" : totalPrice,
"totalItems" : totalItems,
}
);
} catch (_) {
// we can choose to do something when we have an
// exception, e.g use a default logger
}
}
}
If we cannot find the specific logger for the event in the provided loggers, we simply ignore the events and our code continues as normal.
4. Clean and easily maintainable
A big advantage of this approach is that the implementation is clean. Each of our classes is clean, has single responsibilities, is closed for modifications, and extensions can be created as needed and when needed. If a new developer joins the project and needs to log analytics for new features they are working on, they can simply create new analytics commands and start sending them to existing command handlers to log, without needing to modify any existing files or know about the loggers.
Conclusion
This opinionated approach of using the command design pattern to handle analytics data gathering in flutter applications may be suitable for projects of all scales and can be modified to cater to specific requirements. As the project grows, analytics events can be created on the fly without affecting any other implementations. Also, unused events can be removed by simply removing the line where the command is invoked. And to remove or replace an analytics channel is a matter of enlisting or delisting them in the command handler.
I am glad you made it to this point. I used this architecture in a commercial project and I decided to share the approach and to hear other developers' comments and opinions on this. What do you think about the "analytics as commands" approach? What are the problems you see? Would you consider it for any project? I would love to hear from you down below.