Tomasz Gebarowski

Analytics done right

Tracking user behaviour and gathering analytics is very important not only for marketing purposes but also to improve the user experience. Especially when having limited resources it allows to concentrate on most important user paths. I’ve worked on several mobile applications, some of them I’ve written from scratch, others were maintained by me, for some I did code reviews only. Most of those apps had one thing in common, they implemented some kind of analytics (Firebase, Gemius, Mixpanel or proprietary) which was hard to maintain and seemed like it was there only to satisfy some business requirement. Added without broader perspective, just to satisfy current needs.

Based on my experience, typical code smells and bad practices when introducing analytics to mobile app included:

Let us imagine that we have a Tracking class and several helper methods that are following more or less this pattern:


enum EventType: String {
   case offerClick
   case offerShow
   case offerBid
   case offerDismiss
...
}

class Tracking {

   static let shared = Tracking()

   func trackOfferClick(id: String) {
       Analytics.logEvent(AnalyticsEventSelectContent, parameters: [
                          AnalyticsParameterItemID: "id-\(id)",
                          AnalyticsParameterItemName: offerClick.rawValue
                          ])
   }
   ...

   func trackOfferShow(id: String) {
       Analytics.logEvent(AnalyticsEventSelectContent, parameters: [
       AnalyticsParameterItemID: "id-\(id)",
               AnalyticsParameterItemName: offerShow.rawValue
                          ])
    }
}

So what could we tell about this tiny class that adds analytics functionality to our app? Such a pattern should be familiar to every developer who has spent a reasonable time with mobile apps. Usually, this is the most straightforward abstraction that is added on top of some analytics engine. What’s wrong with this code?

First of all, it is tightly coupled with Firebase analytics, switching the library to other vendor will require repetitive and time-consuming changes (a code smell that is frequently referred as copy and paste, with some aspects of shotgun surgery smell). Secondly, the wrapper promotes singleton pattern and probably will be used in lost of places like that:

Tracking.shared.trackOfferClick(id: "offerId1")

resulting in code that is extremely hard to test.

What is more, having a shared EventType enum including all possible analytics events makes it hard to split our application into modules. It would require that EventType is globally accessible by all dependent modules. Assuming that we split our features into modules, adding tracking to such a new feature would require changes in a shared framework. Hence, the shared framework will have knowledge of all analytics events from features that are not belonging to it. Something that is also a cause of potential source code conflicts (when several people are adding/modifying analytics events).

Finally, wrapping analytics with “descriptive” method names is not a good idea. It will provide only a fake abstraction, a middle man like code smell - won’t add any extra functionality to the wrapper class. Imagine that we have 1000 events, this will result in 1000 functions just wrapping analytics logs with some descriptive names.

How this situation could be improved?

Let’s first abstract the EventType enum into protocol allowing to split the conforming data structures into modules.

protocol EventType {
    static var category: String { get }
    associatedtype N
    associatedtype A
}

The protocol will use two associatedTypes, one for event name (N) and other for event action (A). It will define a category of event, which will be returned from the default protocol extension as a name of conforming class:

extension EventType {
    static var category: String {
        return String(describing: self)
    }
}

New events can be defined by conforming to EventType protocol:

struct Messages: EventType {
    typealias N = Name
    typealias A = Action
    
    enum Name: String {
        case title
    }
    enum Action: String {
        case click
    }
}

Now let’s build a generic wrapper class for reporting events:

protocol EventReporterPlugin {
    func send(category: String, name: String, action: String, userData: [String: String])
}

final class EventReporter<T: EventType> {
    private let plugins: [EventReporterPlugin]
    
    init(plugins: [EventReporterPlugin] = []) {
        self.plugins = plugins
    }
    
    func send(name: T.N, action: T.A, userData: [String: String] = [:]) {
        plugins.forEach { $0.send(category: T.category, name: "\(name)", action: "\(action)", userData: userData) }
    }
}

What’s interesting about this class? It introduced an EventReporterPlugin protocol that all 3rd party plugins should conform to. With this design, it should be possible to hook up new analytics SDK easily, or even use two analytics engines simultaneously. Secondly, it allows to use a generic EventType and has no knowledge of specific events. Together with EventReporter, EventType protocol can be a part of generic Tracking.framework having no knowledge about tracked events. We can specialize EventReporter by providing concrete EventType implementations and support only required events in our feature specific frameworks.

By conforming to EventReporterPlugin we can support Firebase analytics or add a dummy reporter plugin just for testing purposes:


class ConsoleReporter: EventReporterPlugin {
    func send(category: String, name: String, action: String, userData: [String: String]) {
        print("[Tracking] Category: '\(category)' name: '\(name)' action: '\(action)' userData: \(userData)")
    }
}

we could add a helper extension to simplify object creation or use dependency injection to promote testability:

extension EventReporter {
    static var console: EventReporter<T> {
        return EventReporter<T>(plugins: [ConsoleReporter()])
    }
}

Practical use case

Taking into account what was shown above, we can implement tracking that promotes modularization and is easy to maintain:

Basket.framework:

struct Basket: EventType {
    typealias N = Name
    typealias A = Action
    
    enum Name: String {
        case payment
        case add
        case checkout
    }
    enum Action: String {
        case select
        case pay
        case other
    }
    
    // Override default category
    static var category: String = "Cart"
}


let reporter: EventReporter<Basket> = EventReporter.console
reporter.send(name: .payment, action: .select,
              userData: ["cardType": "Mastercard"])

Note that if we are not satisfied with what is returned automatically from a category using default protocol extension, the value can be overridden.

Onboarding.framework:

struct Onboarding: EventType {
    typealias N = Name
    typealias A = Action
    
    enum Name: String {
        case registration
        case consents
        case login
    }
    enum Action: String {
        case tapped
        case entered
        case clicked
        case submited
    }
}


let reporter: EventReporter<Onboarding> = EventReporter.console
reporter.send(name: .consents, action: .submited,
              userData: ["legal": "YES", "marketing" : "NO"])

Summary

Proposed solution solves most of the problems covered in the first part:

I know that this pattern is not perfect. The EventType protocol does not support nesting so that Names and Actions are not directly related. It could happen that from analytics perspective given a combination of Name and Action does not exist, but the compiler will not complain. Secondly, EventReporter send method’s userData parameter accepts weakly typed values in form of a dictionary of strings. This allows flexibility but is also a bit risky. Anyway, I think that there are more pros than cons and this pattern will bring more benefits to your app than potential problems.

If you enjoyed this post and would like to be informed about new articles, please follow me on twitter: @tgebarowski