Analytics As Commands: An Opinionated Approach To Robust Analytics Data Gathering In Flutter - Part One

Analytics As Commands: An Opinionated Approach To Robust Analytics Data Gathering In Flutter - Part One

Introduction

Analytics is an integral part of any project because it is essential to know how users interact with the product in order to improve their experiences in future releases. It is also important to gather data for business insights and various other reasons.

The complexity of analytics usually depends on the scale of the project. For instance, the approach to gathering data in an application with 10-20 screens will differ from that of an app with 50 screens or more. Also, the number of user interactions increases the scale of the complexity of analytics data collection. For small-scale projects, it may be sufficient to handle analytics internally through custom pipelines, e.g., by keeping data on your server and doing the computation manually.

However, in medium to large applications, it is often preferable to hand over the analytics to third-party services like Firebase Analytics, Mixpanel, Segment, etc. And if you are a clean coder, you should know that your application's business logic should not rely directly on third-party dependencies.

Even if your team has decided to handle analytics internally, the analytics architecture should be decoupled and abstracted from the business logic so as to keep the business logic pure. These considerations beg the question of the best way to handle analytics properly and the pitfalls to watch out for when planning your next analytics channel.

In this 2 parts article, I discussed some of the common approaches to analytics data gathering, and the demerits of these approaches, and I propose a simple and robust solution suitable for projects of all sizes. I should also mention that some programming concepts mentioned in this article may be a bit difficult to grasp for beginners. But isn't learning difficult things what makes us more than beginners? Enjoy!

Let us skip to the code now, shall we?

Common Approaches to analytics data gathering

1. Using third-party dependencies directly

In a simple project, analytics can be as simple as using the analytics dependency directly. For instance, to log a "purchase_done" event with Firebase Analytics in Flutter, we can easily add the following piece of code to the Buy button onPressed function:

...
// we use the third-party dependency directly in our business logic
await FirebaseAnalytics.instance.logEvent(
    name: "purchase_done",
    parameters: {
        "payment_status": paymentStatus,
        "payment_gateway": paymentGateway,
    },
);

Then we can do this everywhere else we need to track events. See, easy peasy. You should NEVER write code like this! Why? Remember, as clean coders, we don't want our business logic to rely directly on third-party dependencies, and the code right there is married to our button and everywhere that code appears.

If we need to migrate our analytics to another service in the future, we would need to look for everywhere we used FirebaseAnalytics in the codebase and change it manually, this is a maintenance nightmare and should never happen under any circumstance. As clean coders, our codebase should be open to extension but closed for modifications.

2. Using a Wrapper around dependencies

We can take the code above and improve on it by creating a FirebaseAnalyticsService class that takes the FirebaseAnalytics singleton as a dependency and declares the methods to log our analytics events like below.

import 'package:firebase_analytics/firebase_analytics.dart';

// wrapper around the third-party dependency
class FirebaseAnalyticsService {
  final FirebaseAnalytics _analytics;

  FirebaseAnalyticsService(this._analytics);

  // declare methods to log our events
  Future<void> logLogin() async {
    await _analytics.logLogin(loginMethod: "email");
  }

  // declare methods to log our events
  Future<void> logPurchaseDone({
    required bool paymentStatus,
    required String? paymentGateway,
  }) async {
    await analytics.logEvent(name: 'purchasedone', parameters: {
      'payment_status': paymentStatus,
      'payment_gateway': paymentGateway,
    });
  }
}

And then we can use our declared class like so:

// we can also manage this class with a DI manager like getIt
final firebaseAnalyticsService = FirebaseAnalyticsService(FirebaseAnalytics.instance);


// then we can log the event by calling the methods on the wrapper
firebaseAnalyticsService.logPurchaseDone(
    paymentStatus: true, 
    paymentGateway: "Stripe",
);

This approach is one of the most popular among Flutter developers albeit not the best one. What we have done is merely created a wrapper around the Firebase Analytics dependency, but we have not totally decoupled our code from the integration of the dependency, because if there is any need for us to migrate to another analytics channel in the future, we will need to create a new wrapper around the new dependency and our codebase is still not safe from modifications.

For instance, to use another analytics channel called SmartAnalytics, we will need to copy the above firebase analytics wrapper and edit the methods to use the new analytics channel, then we will need to declare and replace the initial firebase analytics wrapper everywhere in our code (you can see that the initial FirebaseAnalyticsService is commented out, to signify editing).

// import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:smart_analytics/smart_analytics.dart';

// class FirebaseAnalyticsService {
class SmartAnalyticsService {

  //  final FirebaseAnalytics _analytics;
  final SmartAnalytics _analytics;

  //  FirebaseAnalyticsService(this._analytics);
  SmartAnalyticsService(this._analytics);


  Future<void> logLogin() async {
    // await _analytics.logLogin(loginMethod: "email");
    await _analytics.registerLogin(loginMethod: "email");
  }


  Future<void> logPurchaseDone({
    required bool paymentStatus,
    required String? paymentGateway,
  }) async {
    // await analytics.logEvent(name: 'purchasedone', parameters: {
    //    'payment_status': paymentStatus,
    //    'payment_gateway': paymentGateway,
    //  });

    await analytics.registerEvent(
        name: 'purchasedone', 
        parameters:     {
          'payment_status': paymentStatus,
          'payment_gateway': paymentGateway,
        });
  }
}

That was easy, right? No! It might be easy to accomplish when you have small changes to make, but imagine a scenario where you are tracking about 200 events in your app, spread across different files, and using different methods, and you have injected this into multiple features where you will be needing to change them all. I don't want to imagine that mess.

Can we take this a step further? Yes, we can. How?

3. Using a layer of abstraction

We can simply create an analytics logger abstract class (or interface) on which our business logic can depend, and create a concrete implementation for any third-party analytics logger.

Example:

// the analytics interface our business logic will depend on
// that will declare the events we want to log and which 
// concrete implementations must implement
abstract class AnalyticsService {
  // and it will declare the events we want to log
  Future<void> logLogin();

  // and it will declare the events we want to log
  Future<void> logPurchaseDone({
    required bool paymentStatus,
    required String? paymentGateway,
  });
}

Then we can create concrete implementations like this:

// a concrete implementation for FirebaseAnalytics
class FirebaseAnalyticsService implements AnalyticsService {
  final FirebaseAnalytics _analytics;

  FirebaseAnalyticsService(this._analytics);

  @override
  Future<void> logLogin() async {
    await _analytics.logLogin(loginMethod: "email");
  }


  @override
  Future<void> logPurchaseDone({
    required bool paymentStatus,
    required String? paymentGateway,
  }) async {
    await analytics.logEvent(
        name: 'purchasedone', 
        parameters: {
          'payment_status': paymentStatus,
          'payment_gateway': paymentGateway,
        }
     );
  }
}

Now we are approaching "clean code land." Our business logic will only rely on an abstract service class, while we pass a concrete implementation to any class that needs analytics. If you are using a service locator (also known as a Dependency Injection manager) like getIt, the implementation will look like this:

getIt.registerLazySingleton<AnalyticsService>(() => FirebaseAnalyticsService(FirebaseAnalytics.instance));

// then we can use the abstract class in our business logic
// without caring about which service is actually doing the logging
getIt<AnalyticsService>().logPurchaseDone(
    paymentStatus: paymentStatus, 
    paymentGateway: paymentGateway,
);

If we need to change the analytics channel in the future, we can simply create a new service that implements our AnalyticsService class and replace the FirebaseAnalyticsService with the new service in our dependency injection manager (or service locator). This way, our business logic still uses the abstract analytics service class and does not need to know that our analytics channel has changed. Our code is now closed for modifications.

// if we need to use a new service called SmartAnalyticsService,
// we can simply create a new class that implements our 
// AnalyticsService interface, without the need to refactor or delete  
// any existing services.
class SmartAnalyticsService implements AnalyticsService {

  final SmartAnalytics _analytics;

  SmartAnalyticsService(this._analytics);

  @override
  Future<void> logLogin() async {
    await _analytics.registerLogin();
  }

  @override
  Future<void> logPurchaseDone({
    required bool paymentStatus,
    required String? paymentGateway,
  }) async {
    await _analytics.registerEvent(
      name: 'purchase_done',
      parameters: {
        'payment_status': paymentStatus,
        'payment_gateway': paymentGateway,
      },
    );
  }
}



// then we pass this new implementation to our service locator, instead of the previous FirebaseAnalyticsService
getIt.registerLazySingleton<AnalyticsService>(() => SmartAnalyticsService(SmartAnalytics()));

This last approach is a big improvement over the first approach as it gives us some flexibility to swap services without modifications and it is often sufficient for small to medium-scale analytics needs.

Demerits of the Common Approaches

In a larger project with more demands, we need to answer more questions. What if we need to use a combination of multiple channels? What if we want to use both Firebase Analytics in combination with Mixpanel? Or what if we need to log some events to mixpanel but not to Firebase Analytics, or vice versa? Or what if we need some events to also be routed to a new machine learning service in the cloud right from our app? What if our events grow from a mere 20 to 300 events, and we need to share 200 of these events between different analytics services? or we decide to share all 300 events between all services in the future? or we decide in the future that we no longer need the Mixpanel integration? Ha ha. Do you see why we need a more optimized approach for large-scale project demands?

But wait, slow down, slow down. Let us run through these issues together.

1. Maintenance concerns

Let us first look at a situation where we need to log up to 500 events. How do we manage this?

You may think that instead of creating methods for each event, why can't we just create a single method to handle all events, like so:

// we create an interface that our business logic can rely on
abstract class AnalyticsService {

    ...
    // and we declare a single method to log all events
  Future<void> logEvent(String name, Map<String, dynamic> parameters);
}

// our concrete implementations then override the AnalyticsService
// and provide the mechanism to log all events, generically 
class FirebaseAnalyticsService implements AnalyticsService {

    ...

  @override
  Future<void> logEvent(String name, Map<String, dynamic> parameters) async {
    await _analytics.logEvent(name: name, parameters: parameters);
  }

}

This idea of accepting generic events looks simple and straightforward, but it is not easy to maintain because we need to find a way to quickly manage and trace each event easily. So we need to identify and name each event.

Going by the abstract analytics service class approach, we can create a method for each event we log and then implement all those methods for each service we create. If we have 200 events, that is 200 methods to create and manage. And we will need to keep updating all analytics services implementations for each new event we track in the future.

You can argue that we can break this into multiple feature-based divisions (e.g have an AuthenticationAnalyticsService, PaymentAnalyticsService, etc), but this only chunks our tasks and maybe reduce the lines of code per file, we will still need to handle the 200 methods for each event somehow.

2. No straightforward support for multiple analytics integrations

Now let us examine a scenario where we need to use multiple analytics services together for some reasons (FirebaseAnalytics & SmartAnalytics in our case). How should we do that?

One way is to create concrete implementations of each analytics channel we want and just use them directly in our business logic,

// we create implementations for the two services
// and we use the two different services in our code
Future<void> _logLoginEvent()async {
      await Future.wait([
        _firebaseAnalyticsService.logLogin(),
        _smartAnalyticsService.logLogin(),
        ]);
}

or we can create a FirebaseAnalyticsAndSmartAnalyticsService, right? Which can then take the instance of each service and use them, like below:

// BAD IDEA
// create a union implementation for any combination of services we need
class FirebaseAnalyticAndSmartAnalyticsAnalyticsService extends AnalyticsService {
  final FirebaseAnalyticsService _firebaseAnalyticsService;
  final SmartAnalyticsService _smartAnalyticsService;


  @override
  Future<void> logLogin() async {
    await Future.wait<void>([
      _firebaseAnalyticsService.logLogin(),
      _smartAnalyticsService.logLogin()
    ]);
  }


  @override
  Future<void> logPurchaseDone({
        required bool paymentStatus, 
        required String? paymentGateway,
    }) async {

     _firebaseAnalyticsService.logPurchaseDone(
        paymentStatus: paymentStatus, 
        paymentGateway: paymentGateway    ,
    );

     await _smartAnalyticsService.registerEvent(
       name: 'purchase_done',
       parameters: {
         'payment_status': paymentStatus,
         'payment_gateway': paymentGateway,
       },
     );
  }
}

But you can see how messy this approach can quickly become, we will either need to call two different implementations of different services or we will need to write union implementations for all our analytics events. What if for some reason, our project demands that we use a third analytics channel for some events in the future? Then we will need to either call that new service everywhere we are presently doing the logging with existing services, or create another union implementation for all the 3 services, none of which is a good idea.

3. No clear support for conditional event logging

Another question is what if we need to log different events to different analytics channels? Say tomorrow, in addition to logging all events to MixPanel, we need to start logging some specific events like payment events directly to a separate cloud service for machine learning uses such as real-time recommendations or predictions.

How does our existing architecture cope with this? Do we need to create another MachineLearning service and filter which service we need to call and where? where will this filtering happen? in the business logic? in the concrete implementations? How many methods do we need to implement? do we implement the methods we do not need? I leave you to think about this.

It is clear that while the approaches to analytics data gathering mentioned above may be good enough for small - medium-sized projects, they may not be suitable for large projects. So how can we address all these problems while keeping our analytics data gathering architecture as simple, robust, and "clean" as possible? This brings us to the "Analytics as Commands" opinionated solution to all these problems, which is discussed extensively in the second part of this article. ( :

Continue to Part two here