Robust analytics in Flutter

Robust analytics in Flutter
Photo by Luke Chesser / Unsplash

In today's data-driven age, understanding how users interact with your product is essential. Analytics Reporting isn't just about collecting data, but about doing it effectively, systematically, and in a manner that can be extrapolated for actionable insights. This comprehensive guide seeks to demystify the process of creating custom events, setting up reporting services, and building interceptors in a manner that is both extensible and clear.

For the purpose of this article, we will be focusing on Firebase Analytics as our primary analytics service. Its widespread popularity and familiarity among developers make it an ideal choice for our discussions. However, it's worth noting that the principles and techniques discussed here can be adapted for use with any analytics service of your choosing.

As we dive deeper into the subject, we'll be tackling three commonly encountered challenges in the world of analytics:

  1. The prevalent issue of Dependency Injection (DI) being overlooked.
  2. The tendency to create events on-the-fly without dedicated classes.
  3. A lack of a standardized methodology for defining custom properties.

Analytics Architecture

All the architecture is divided into a few modules: AnalyticsReporter, AnalyticsInterceptor, AnalyticsProperty, AnalyticsEvent and finally AnalyticsBuilder. Let's start with AnalyticsEvent.


AnalyticsEvent

AnalyticsEvent is a superclass for all events in the application. It defines the name getter (used to recognise the event) and an optional method that can add additional properties to it. So it ends up looking like this:

abstract base class AnalyticsEvent {
  const AnalyticsEvent();

  String get name;

  /// Builds the properties for this event.
  void buildProperties(AnalyticsBuilder builder) {}
}

Suppose you need to create a ProductViewed event. Using the AnalyticsEvent superclass, the new event would look like this:

// Analytics event when user opened the product view.
final class ProductViewed extends AnalyticsEvent {
  const ProductViewed({
    required this.id,
    required this.from,
  });

  // The ID could be used later by our services
  // to provide useful data for customers.
  final String id;

  // Place user came from
  final String from;

  @override
  String get name => 'product_viewed';

  @override
  void buildProperties(AnalyticsBuilder builder) {
    builder.add(StringAnalyticsProperty('product_id', id));
    builder.add(StringAnalyticsProperty('from', from));
  }
}

The code above defines custom event ProductViewed with two fields: id and from and attaches them as custom properties in buildProperties method. The great thing is that you can add documentation here.

I believe this looks handsome and pretty clear. Let's move on, to the AnalyticsProperty and AnalyticsBuilder.

AnalyticsProperty

Properties are the data sent with the event. They should contain some useful information such as category, payment amount, time taken, etc. The superclass for all properties looks like this:

sealed class AnalyticsProperty<T extends Object> {
  const AnalyticsProperty(this.name, this.value, {this.meta});

  /// The name of this property.
  final String name;

  /// The value of this property.
  final T? value;

  /// Additional information about this property.
  /// 
  /// This can be used to provide additional context about the property.
  final Object? meta;

  /// Returns the value of this property in a form that is OK for engine.
  /// 
  /// If the value is not serializable, this field should be 
  /// overridden to return a serializable value.
  Object? get valueSerializable => value;  
}

It has a few main fields:

  • name - The name of the property, required.
  • value - The value of the property, could be null.
  • meta - Optional field to provide more context, useful for debugging or logging purposes (will not be sent to the analytics service).
💡
Note that FirebaseAnalytics only supports strings and numbers as properties. Therefore, the approach with these properties becomes immensely useful as it makes the code uniform.

There are some derives from this class for specific type:

final class IntAnalyticsProperty extends AnalyticsProperty<int> {
  IntAnalyticsProperty(super.name, super.value, {super.meta});
}

final class DoubleAnalyticsProperty extends AnalyticsProperty<double> {
  DoubleAnalyticsProperty(super.name, super.value, {super.meta});
}

final class StringAnalyticsProperty extends AnalyticsProperty<String> {
  StringAnalyticsProperty(super.name, super.value, {super.meta});
}

final class FlagAnalyticsProperty extends AnalyticsProperty<bool> {
  FlagAnalyticsProperty(super.name, super.value, {super.meta});

  @override
  Object? get valueSerializable {
    if (value == null) return null;

    return value! ? 'true' : 'false';
  }
}

final class EnumAnalyticsProperty<T extends Object> extends AnalyticsProperty<T> {
  EnumAnalyticsProperty(super.name, super.value, {super.meta});

  @override
  Object? get valueSerializable {
    if (value == null) return null;

    return describeEnum(value!);
  }
}

You may have noticed the valueSerializable getter. It exists to provide some basic transformations to objects that are wrapped in these properties. As mentioned in the callout before, analytics engines have limitations and it is crucial to have a way to standardise these limitations and the properties that can be attached.

Analytics Builder

The unit that collects the properties is called AnalyticsBuilder. It is a simple class with an add method that could be used to fill the properties. See:

/// A builder for analytics properties.
///
/// This class is used to build a map of properties
/// that can be sent to an analytics service.
final class AnalyticsBuilder {
  AnalyticsBuilder() : properties = [];

  /// The properties that have been added to this builder.
  final List<AnalyticsProperty> properties;

  /// Adds a property to this builder.
  void add(AnalyticsProperty property) => properties.add(property);

  /// Returns the properties as a map.
  ///
  /// This method should be called after all properties have been added.
  Map<String, Object?> toMap() {
    final result = <String, Object?>{};
    for (final property in properties) {
      result[property.name] = property.valueSerializable;
    }

    return result;
  }
}

The add method appends the property to an underlying list of properties. This list is later used by the toMap method to create a map of properties that the Reporter sends to the cloud along with the event.

AnalyticsReporter

Finally, the key component in this chain - the AnalyticsReporter. Its job is to create properties and report the event to the cloud. There's a base class for all the Reporters, see:

abstract base class AnalyticsReporter {
  /// Creates a new analytics reporter.
  const AnalyticsReporter({List<AnalyticsInterceptor> interceptors = const []})
      : _interceptors = interceptors;

  /// The interceptors that will be called before an event is reported.
  final List<AnalyticsInterceptor> _interceptors;

  /// Reports an event to the analytics service.
  @nonVirtual
  Future<void> reportEvent(AnalyticsEvent event) async {
    final builder = AnalyticsBuilder();

    event.buildProperties(builder);

    await _reportEvent(event.name, builder.toMap());

    for (final interceptor in _interceptors) {
      await interceptor.afterReport(event, builder.properties);
    }
  }

  /// Reports an event to the analytics service.
  ///
  /// This method should be implemented by the analytics service.
  ///
  /// For example, if you are using Firebase, you would implement this method
  /// to call `FirebaseAnalytics.logEvent`.
  Future<void> _reportEvent(String name, Map<String, Object?> properties);
}

With the structure above, it is sufficient to implement only the _reportEvent method, which actually delivers the event with properties.

You may also have noticed a list of Interceptors. The interceptor is a unit that gets an event and properties before the actual report. I personally use it for logging and debugging purposes. This is what it looks like:

abstract base class AnalyticsInterceptor {
  const AnalyticsInterceptor();

  /// Intercepts an event before it is reported to the analytics service
  FutureOr<void> afterReport(
    AnalyticsEvent event,
    List<AnalyticsProperty> properties,
  ) {}
}

final class LoggingAnalyticsInterceptor extends AnalyticsInterceptor {
  const LoggingAnalyticsInterceptor();

  @override
  FutureOr<void> afterReport(
    AnalyticsEvent event,
    List<AnalyticsProperty> properties,
  ) {
    if (kDebugMode) {
      print('Event: ${event.name}');
      for (final property in properties) {
        print('  ${property.name}: ${property.value}');
      }
    }
  }
}

Dependency Injection

person holding yellow and white pen
Photo by Diana Polekhina / Unsplash

Finally, I want to emphasise the value of arguably one of the most useful techniques - dependency injection.

The key problem is that developers often violate this principle. For example, Firebase Analytics provides a singleton that can be accessed from any part of the application: be it business logic, widgets or the data layer, it doesn't matter.

This leads to spaghetti code and complexity later on (even if it looks so simple in the beginning). It is quite common to change analytics services or introduce new ones. This process becomes incredibly easy when using the DI.

See, how I do it in my Flutter template: https://sizzle.lazebny.io/essentials/dependencies.

In the ending

For those interested in code - https://gist.github.com/hawkkiller/515aca8c55e29d343375281fd58cd153

Thank you for reading this article! Subscribe to my free newsletter to never miss articles like this. If you have any questions, feel free to join the discussion! See you soon.

Read more