Combining multiple analytics trackers using enum case paths

FEBRUARY 7, 2020

To see the final project that this post builds check out: https://github.com/aj-bartocci/AnalyticsTrackingExample

The project breaks each tracker into it’s own module to demonstrate they can be isolated, in practice idk if this would be necessary but might not be a bad idea. The project also contains some tests that are cool to look at and see how trivial it is to test.

Analytics in a large project tends to be annoying

In my experience working with multiple Analytics trackers in one app has been a bit annoying. It’s tough to easily see what is going on without modifying code and potentially breaking things. For example in order to log events we need to find where the events are firing and add a print statement. It’s not hard to do when you know the signature of the analytics event. But sometimes it’s not so simple. For Example we might be looking for an event that tracks when the user logs out. What do we search for? .sendEvent(.loggedOutUser), .logAnalytics(“user_logged_out”), etc? Unless we have a deep knowledge of how we are doing analytics throughout the app it can be hard to find where to even put a print statement.

I wanted to explore creating an architecture for analytics tracking that would be:

#1 Scalable

#2 Testable

#3 Isolated (mostly just as an exercise to see if could be done)

As I mentioned in my last post, I’ve been checking out the work the guys at pointfree.co have been doing. So for this architecture I wanted to try out the “Current” concept of dependency injection, and also use their idea of enum case paths.

To start off I created a tracker for Google Analytics.

I created a new framework called GoogleAnalyticsTracker so this tracker would be completely isolated from the rest of the project. The tracker code itself is very basic, it’s a simple function:

// GoogleAnalyticsTracker.swift
import Foundation

public enum AppEvent {
    case appLaunched
    case appEnteredForeground
    case appEnteredBackground
}

struct ProdGoogleTracker {
    static func sendEvent(_ event: String) {
        print("PROD sending google event: \(event)")
        print("---")
    }
}

public let googleAnalyticsTracker: (AppEvent) -> Void = { (event) in
    switch event {
    case .appLaunched:
        Current.send("app_launch")
    case .appEnteredForeground:
        Current.send("app_entered_foreground")
    case .appEnteredBackground:
        Current.send("app_entered_background")
    }
}

I didn’t want to setup real tracking so I simply made a struct that represents hitting the real Google analytics, so if we see PROD get printed we know that would be when the real analytics is getting hit.

So you might be wondering ‘what is this Current thing?’ As I discussed in my last post Current is a dependency injection strategy that I am exploring. Since the analytics trackers that I have encountered are all singletons I thought this was a good idea to try Current out. This is my first experiment using current to manage global dependencies.

Here is the file where Current is defined

// Environment.swift in GoogleAnalyticsTracker framework 
import Foundation

public struct Environment {
    public var send: (String) -> Void
}

extension Environment {
    public static let live = Environment { (event) in
        ProdGoogleTracker.sendEvent(event)
    }
}

#if DEBUG
extension Environment {
    public static let mock = Environment { (_) in
        // do nothing
    }
}
public var Current = Environment.mock
#else
public let Current = Environment.live
#endif

The important stuff going on here is that if we are running in debug mode, we do nothing with the incoming event. If we are running a release build then we will be hitting the production Google analytics tracker.

Cool this seems to be good, lets add some test to make sure it is working as we expect.

// GoogleAnalyticsTrackerTests.swift
import XCTest
@testable import GoogleAnalyticsTracker

class GoogleAnalyticsTrackerTests: XCTestCase {
    
    let sut = googleAnalyticsTracker

    func test_appLaunchEvent() {
        var receivedEvent: String?
        Current.send = { (event) in
            receivedEvent = event
        }
        sut(.appLaunched)
        XCTAssertEqual(receivedEvent, "app_launch")
    }
    
    func test_appEnteredForeground() {
        var receivedEvent: String?
        Current.send = { (event) in
            receivedEvent = event
        }
        sut(.appEnteredForeground)
        XCTAssertEqual(receivedEvent, "app_entered_foreground")
    }
    
    func test_appEnteredBackground() {
        var receivedEvent: String?
        Current.send = { (event) in
            receivedEvent = event
        }
        sut(.appEnteredBackground)
        XCTAssertEqual(receivedEvent, "app_entered_background")
    }
}

This is where Current really shines. When doing dependency injection I need to make mocks and stubs a lot of times which can be annoying as things change over time since all of the mocks and stubs will also need to be updated just to make tests work. This makes tests look like a time sink. However, modifying Current is trivial which makes these tests very easy to understand.

To actually send our tracking events we might do:

struct Analytics {
    static func send(event: AnalyticsEvent) {
        switch event {
        case .app(let event):
            googleAnalyticsTracker(event)
        }
    }
}

We’ll create another Current variable, but for our app now:

// Environment.swift in the main app 
import Foundation

struct Environment {
    struct AnalyticsTracker {
        var send: (AnalyticsEvent) -> Void
    }
    var analytics: AnalyticsTracker
}

extension Environment {
    static let live = Environment(
        analytics: AnalyticsTracker(send: Analytics.send)
    )
}

#if DEBUG
var Current = Environment.live
#else
let Current = Environment.live
#endif

Now in our app delegate we can add calls to Current.analytics.send. If we build an run now it should fire events for app launch and entering the foreground. We can easily verify this with tests but lets quickly add a print statement so we can see that this is true.

struct Analytics {
    static func send(event: AnalyticsEvent) {
        print("Logged event: \(event)")
        switch event {
        case .app(let event):
            googleAnalyticsTracker(event)
        }
    }
}

We’ll just need to remember to take this print statement out before we build for release (hopefully we won’t forget!).

Now when we run the app we should see some logged events coming through.

Now down the road we want add another tracker for firebase, we want to migrate away from Google Analytics eventually, but for now we want to run both trackers in parallel. The firebase tracker will track the same events as the Google Analytics tracker and some additional new events.

Let’s pretend we go through all of the same steps as before, we create a new framework called FirebaseAnalyticsTracker and add two new trackers for firebase one for app events and one for api error events.

// FirebaseAnalyticsTracker.swift
import GoogleAnalyticsTracker

public enum APIErrorEvent {
    case unexpectedResponse(endpoint: String)
    case timeout(endpoint: String)
}

public let firebaseAppEventTracker: (AppEvent) -> Void = { event in
    switch event {
    case .appLaunched:
        Current.sendEvent("app_launch", [:])
    case .appEnteredForeground:
        Current.sendEvent("app_entered_foreground", [:])
    case .appEnteredBackground:
        Current.sendEvent("app_entered_background", [:])
    }
}

public let firebaseAPIErrorTracker: (APIErrorEvent) -> Void = { (event) in
    switch event {
    case .unexpectedResponse(endpoint: let endpoint):
        Current.sendEvent("unexpected_response", ["endpoint": endpoint])
    case .timeout(endpoint: let endpoint):
        Current.sendEvent("response_timeout", ["endpoint": endpoint])
    }
}

So now in order to track these events we need to update our Analytics.swift file:

// Analytics.swift

enum AnalyticsEvent {
    case app(AppEvent)
    case api(APIErrorEvent)
}

struct Analytics {
    static func send(event: AnalyticsEvent) {
        print("Logged event: \(event)")
        switch event {
        case .app(let event):
            googleAnalyticsTracker(event)
            firebaseAppEventTracker(event)
        case .api(let event):
            firebaseAPIErrorTracker(event)
        }
    }
}

This will work but it feels a little messy. For every new case we add we will need to update this switch statement. And as we add more trackers it will make the Analytics struct keep growing, so this isn’t ideal.

How cleaner would it be if we did something like this:

struct Analytics {
    static let send: (AnalyticsEvent) -> Void = combine(
        pullback(googleAnalyticsTracker, action: \.app),
        pullback(firebaseAppEventTracker, action: \.app),
        pullback(firebaseAPIErrorTracker, action: \.api)
    )
}

Enum case paths

I didn’t come up with the concept of enum case paths, but I think it is a really cool idea. You can check out where I learned about it here.

So what in the world is going on here?

pullback(googleAnalyticsTracker, action: \.app)

The pullback function can be thought of as way to pull out a specific case of an enum.

In this case we are pulling the .app case out of the AnalyticsEvent enum and applying it to googleAnalyticsTracker since it operates on the AppEvent enum. Instead of having to do a switch to get the AppEvent we use this pullback function. So how is this possible?

First we need to add a var to our AnalyticsEvent enum:

extension AnalyticsEvent {
    // boilerplate to get keypaths for our AnalyticsEvent enum
    // this is the one downside to this approach but I think
    // it is worth it in the end
    var app: AppEvent? {
      get {
        guard case let .app(value) = self else { return nil }
        return value
      }
    }
    var api: APIErrorEvent? {
      get {
        guard case let .api(value) = self else { return nil }
        return value
      }
    }
}

Since structs get key paths for variables we add some vars that match our enum cases. These variables basically just checking if the current self matches the expected case, and if it does we can get the underlying value.

We also need to create these combine and pullback functions:

public func combine<Action>(
  _ coordinators: (Action) -> Void...
) -> (Action) -> Void {
  return { action in
    for coordinator in coordinators {
        coordinator(action)
    }
  }
}

public func pullback<LocalAction, GlobalAction>(
  _ coordinator: @escaping (LocalAction) -> Void,
  action: KeyPath<GlobalAction, LocalAction?>
) -> (GlobalAction) -> Void {
    return { globalAction in
        // if the global action can be pulled out of the coordinator
        // we send it on, otherwise we just ignore it
        guard let localAction = globalAction[keyPath: action] else { return }
        coordinator(localAction)
    }
}

Since our trackers are simple functions we can easily combine them with another simple function. The pullback function is a little more complex but all it is doing is trying to see if the value for the specified keyPath is nil or not. If it is nil we ignore it and if it exists we send it on to our tracker.

So now we can update the Analytics.swift file:

struct Analytics {
    static let send: (AnalyticsEvent) -> Void = combine(
        pullback(googleAnalyticsTracker, action: \.app),
        pullback(firebaseAppEventTracker, action: \.app),
        pullback(firebaseAPIErrorTracker, action: \.api)
    )
}

Everything still works but now we’ve lost our ability to log events. In order to fix this we can create a new function called logging:

public func logging<Action>(
  _ coordinator: @escaping (Action) -> Void
) -> (Action) -> Void {
    return { (action) in
        coordinator(action)
        print("Logged Analytics Event: \(action)")
        print("---")
    }
}

Since all of our trackers are really just functions we can easily wrap them in another function that just logs after each event.

To restore logging to our app we can update AppDelegate.swift:

// AppDelegate.swift 

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        Current.analytics.send = logging(Current.analytics.send)
        Current.analytics.send(.app(.appLaunched))
        return true
    }

Since Current is mutable in debug mode we can easily wrap our send function in logging and now every event will be logged.

The huge benefit here is that we don’t need to remember to take logging out when it comes time to make a release build. If we try to archive now we will get an error:

Now the compiler catches when we forget to remove debug logic.

Another cool benefit of using Current is now we can test our app analytics tracking as well. There should already be tests in each isolated framework so we know that each tracker is working as it should. We should also test that the app is tracking as it should.

For example lets make sure google and firebase are both tracking app launch events:

// AnalyticsTests.swift in the app target 

import XCTest
@testable import Analytics
import FirebaseTracker
import MixpanelTracker
import GoogleAnalyticsTracker

class AnalyticsTests: XCTestCase {

    override func setUp() {
        // Put setup code here. This method is called before the invocation of each test method in the class.
        FirebaseTracker.Current = .mock
        MixpanelTracker.Current = .mock
        GoogleAnalyticsTracker.Current = .mock
    }

    override func tearDown() {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }
    
    func test_appLaunchEvent() {
        // don't really care about the event details
        // since that is covered by each module's test
        // just want to see that the events we are
        // expecting to fire actually do 
        var didFireFirebaseEvent = false
        FirebaseTracker.Current.sendEvent = { (_, _) in
            didFireFirebaseEvent = true
        }
        var didFireGoogleEvent = false
        GoogleAnalyticsTracker.Current.send = { _ in
            didFireGoogleEvent = true
        }
        Analytics.send(.app(.appLaunched))
        XCTAssert(didFireFirebaseEvent)
        XCTAssert(didFireGoogleEvent)
    }
}

Now we can verify that when the app launch event is sent both of our trackers are receiving that event.

TLDR;

I think enum case paths made analytics tracking a lot cleaner and composable. It helped keep things isolated by letting us remove a switch statement that would continuously grow.

You can check out my full example project here: https://github.com/aj-bartocci/AnalyticsTrackingExample

References:

https://github.com/pointfreeco/swift-case-paths