Our ever expanding appetite for analytics

In our industry, data-driven decision making has really taken root. We are increasingly interested in the actions that our users are performing inside our apps. The aim being to get users to buy more products, share more photos, open one more post, etc - generally just stay in our apps for longer and help us achieve our business objectives. We achieve this by sending events when the user performs a task, these events can be as simple as a single value - event name or much more detailed by including multiple values that we believe provide details into why the user took that action. All of this work needs to be undertaken by us, the developer. A few years ago you may have been that type of developer who thought:

"Gee, I need to add even more events....pssst destroying my beautiful abstractions.....Jeff always coming over here, asking me to track this and track that.....I've got actual features to build, I'll do that at the end of the sprint"

(What? Are you asking if I used to have this thought?........no, never - I don't even know someone called Jeff 😏)

There was a tendency to think of analytics not as part of the feature but rather as an add on, as something that we could ship without. We may even have lied to ourselves that shipping without waiting for analytics to be implemented meant that we were being agile or lean. However doing so actually meant that our new feature was flying blind -

  1. Is that increase/drop in daily active users connected to the new feature?
  2. Are of users using it?
  3. Should we continue developing it?

We can't empirically answer any questions without data so instead we need to go with gut reactions 😷.

I feel as if I have taken you on a long journey to say:

Data is the new king 👑

(Or at least really, really, important)

And like anything important in our projects we need to properly (unit) test it to make sure it's doing what we expect. Below is an example of how we can structure our analytics layer to allow it to be independent and unit tested.

Satisfy our analytics appetite 🍝

There are a number of really useful analytical tracking solutions out there and in the below examples I will be using Mixpanel - however this approach to handling analytical events can really be used with any solution. It all boils to the idea that we will be programming to an interface rather than an implementation. This way we can hide our solution (Mixpanel) behind an interface, have our separate analytical classes construct the events and then pass them via a delegate to our solution (or in the case of our unit tests a spy).

Let's look at some code:

protocol AnalyticsDelegate: class {
    func sendEvent(name: String)
    func sendEvent(name: String, properties: [String: AnyObject]?)
    func startTimedEvent(name: String)
}

class AnalyticsManager: NSObject, AnalyticsDelegate {

    // MARK: - Properties

    let mixpanel = Mixpanel.sharedInstanceWithToken("32011ca4a547c7bba1278c903409d0e1")

    lazy var feedAnalyticalRegistry: FeedAnalyticalRegistry = {
        let feedAnalyticalRegistry = FeedAnalyticalRegistry(delegate: self)

        return feedAnalyticalRegistry
    }()

    lazy var profileAnalyticalRegistry: ProfileAnalyticalRegistry = {
        let profileAnalyticalRegistry = ProfileAnalyticalRegistry(delegate: self)

        return profileAnalyticalRegistry
    }()

    lazy var settingsAnalyticalRegistry: SettingsAnalyticalRegistry = {
        let settingsAnalyticalRegistry = SettingsAnalyticalRegistry(delegate: self)

        return settingsAnalyticalRegistry
    }()

    // MARK: - Singleton

    static let sharedInstance = AnalyticsManager()

    // MARK: - AnalyticsDelegate

    func sendEvent(name: String) {
        sendEvent(name, properties: nil)
    }

    func sendEvent(name: String, properties: [String: AnyObject]?) {
        mixpanel.track(name, properties: properties)
    }

    func startTimedEvent(name: String) {
        mixpanel.timeEvent(name)
    }
}

Let's explore the highlights package below:

protocol AnalyticsDelegate: class {
    func sendEvent(name: String)
    func sendEvent(name: String, properties: [String: AnyObject]?)
    func startTimedEvent(name: String)
}

This is the interface that we are going to hide behind. It's important to note that this protocol shouldn't be treated as an interface that will cover all solutions/frameworks but rather as one that is focused on providing an abstraction above the available Mixpanel methods - it's simply to allow us to more easily unit test our events.

class AnalyticsManager: NSObject, AnalyticsDelegate {

Here we declare that AnalyticsManager will implement AnalyticsDelegate methods.

lazy var feedAnalyticalRegistry: FeedAnalyticalRegistry = {
    let feedAnalyticalRegistry = FeedAnalyticalRegistry(delegate: self)

    return feedAnalyticalRegistry
}()

lazy var profileAnalyticalRegistry: ProfileAnalyticalRegistry = {
    let profileAnalyticalRegistry = ProfileAnalyticalRegistry(delegate: self)

    return profileAnalyticalRegistry
}()

lazy var settingsAnalyticalRegistry: SettingsAnalyticalRegistry = {
    let settingsAnalyticalRegistry = SettingsAnalyticalRegistry(delegate: self)

    return settingsAnalyticalRegistry
}()

In the above code snippet we are creating 3 lazy instances of analytical registries. A registry is a class that groups together related events. Rather than implementing all events in the one class I have decided to split them over a number of different classes so that we can avoid having one god class. With each registry we set AnalyticsManager as the delegate. I could have made each registry a singleton but instead I choose to have them as properties on the AnalyticsManager (which is itself a singleton) with the idea being to group all possible analytical options into the one location and so (hopefully) improve readability of the project. The downside of this choice is that when triggering an event, the syntax is slightly longer:

AnalyticsManager.sharedInstance.feedAnalyticalRegistry.sendLikeEvent(true)

rather than:

FeedAnalyticalRegistry.sharedInstance.sendLikeEvent(true)

One of my general development rules is to try and avoid singletons unless doing so will overly complicate the project.

Let's look at our delegate method's implementation.

func sendEvent(name: String) {
    sendEvent(name, properties: nil)
}

func sendEvent(name: String, properties: [String: AnyObject]?) {
    mixpanel.track(name, properties: properties)
}

func startTimedEvent(name: String) {
    mixpanel.timeEvent(name)
}

The above methods pass parameters to the instance of Mixpanel that we have as a property without making any changes.

Ok, so that's the manager/delegate, lets look at these mysterious registries we keep talking about. To save on each registry having to implement the same init'er we will use a base/parent class that each registry will extend.

class AnalyticalRegistry: NSObject {

    // MARK: - Properties

    let delegate: AnalyticsDelegate

    // MARK: - Init

    init(delegate: AnalyticsDelegate) {
        self.delegate = delegate
        super.init()
    }
}

Time for an actual registry.

class SettingsAnalyticalRegistry: AnalyticalRegistry {

    // MARK: - Events

    let notificationsEventName = "Notifications"

    // MARK: - Parameters

    let notificationsEnabledPropertyName = "Notification Enabled"

    // MARK: - Notifications

    func sendNotificationEnabled(enabled: Bool) {
        let properties = [notificationsEnabledPropertyName: enabled]

        delegate.sendEvent(notificationsEventName, properties: properties)
    }
}

This is the simplest example of a registry that we will look at but the more complex registries follow the same pattern.

let notificationsEventName = "Notifications"

The event name itself as a string property.

let notificationsEnabledPropertyName = "Notification Enabled"

The property/parameter associated with an event. This is used as the key for a dictionary that will more details on the event that has been triggered.

func sendNotificationEnabled(enabled: Bool) {
    let properties = [notificationsEnabledPropertyName: enabled]

    delegate.sendEvent(notificationsEventName, properties: properties)
}

Finally, an actual event!

It's important to note that only the registry actually constructs the event and any associated dictionary meaning that any class that uses a registry should only be interested in passing the required data and not building the structure that the event will use - this is a similar approach as taken when building requests for an API call, described in this post.

class ProfileAnalyticalRegistry: AnalyticalRegistry {

    // MARK: - Events

    let avatarChangedEventName = "Avatar Changed"
    let profileFieldsEventName = "Profile Fields"
    let friendRequestEventName = "Friend Request"

    // MARK: - Parameters

    let fieldsFirstNameChangedPropertyName = "First Name Changed"
    let fieldsLastNameChangedPropertyName = "Last Name Changed"
    let fieldsEmailAddressChangedPropertyName = "Email Address Changed"
    let fieldsBioChangedPropertyName = "Bio Changed"
    let fieldsTotalChangedPropertyName = "Total Fields Changed"
    let friendRequestedPropertyName = "Friend Requested"

    // MARK: - Avatar

    func sendAvatarChangedEvent() {
        delegate.sendEvent(avatarChangedEventName)
    }

    // MARK: - Fields

    func sendFieldsChangedEvent(firstNameChanged: Bool, lastNameChanged: Bool, emailAddressChanged: Bool, bioChanged: Bool) {

        var properties: [String: AnyObject] = [fieldsFirstNameChangedPropertyName: firstNameChanged,
                                               fieldsLastNameChangedPropertyName: lastNameChanged,
                                               fieldsEmailAddressChangedPropertyName: emailAddressChanged,
                                               fieldsBioChangedPropertyName: bioChanged]

        var totalFieldsChanged = 0

        if firstNameChanged {
            totalFieldsChanged += 1
        }

        if lastNameChanged {
            totalFieldsChanged += 1
        }

        if emailAddressChanged {
            totalFieldsChanged += 1
        }

        if bioChanged {
            totalFieldsChanged += 1
        }

        properties[fieldsTotalChangedPropertyName] = totalFieldsChanged

        delegate.sendEvent(profileFieldsEventName, properties: properties)
    }

    // MARK: - FriendRequest

    func sendFriendRequest(requested: Bool) {
        let properties = [friendRequestedPropertyName: requested]

        delegate.sendEvent(friendRequestEventName, properties: properties)
    }
}

I've shared the above class to show a more complex registry that contains some event specific logic.

So, we have our AnalyticsDelegate protocol and AnalyticalRegistry subclasses lets look at how to test them.

Avoid filling up on junk 🍏

If we want to examine the events that our registries are producing we need to spy on it.

class AnalyticsDelegateSpy: NSObject, AnalyticsDelegate {

    // MARK: Properties

    var passedInEventName: String?
    var passedInProperties: [String: AnyObject]?

    // MARK: - AnalyticsDelegate

    func sendEvent(name: String) {
        passedInEventName = name
    }

    func sendEvent(name: String, properties: [String: AnyObject]?) {
        passedInEventName = name
        passedInProperties = properties
    }

    func startTimedEvent(name: String) {
        passedInEventName = name
    }
}

A spy is fairly common pattern in unit testing and works by exposing parameters passed into it's method as properties. In this case our AnalyticsDelegateSpy conforms to the AnalyticsDelegate protocol and exposes two properties passedInEventName and passedInProperties - these properties will allow us to know what the output of an event method is. AnalyticsDelegateSpy will be substituted in place of AnalyticsManager when we create instances of our registries.

class SettingsAnalyticalRegistryTests: XCTestCase {

    // MARK: - Properties

    var analyticsDelegateSpy: AnalyticsDelegateSpy!
    var settingsAnalyticalRegistry: SettingsAnalyticalRegistry!

    // MARK: - Lifecycle

    override func setUp() {
        super.setUp()
        analyticsDelegateSpy = AnalyticsDelegateSpy()
        settingsAnalyticalRegistry = SettingsAnalyticalRegistry(delegate: analyticsDelegateSpy)
    }

    // MARK: - Tests

    // MARK: sendNotificationEnabled

    func test_sendNotificationEnabled_eventName() {
        settingsAnalyticalRegistry.sendNotificationEnabled(true)

        XCTAssertEqual(settingsAnalyticalRegistry.notificationsEventName, analyticsDelegateSpy.passedInEventName)
    }

    func test_sendNotificationEnabled_properties() {
        let enabled = true

        settingsAnalyticalRegistry.sendNotificationEnabled(enabled)

        XCTAssertEqual(enabled, analyticsDelegateSpy.passedInProperties![settingsAnalyticalRegistry.notificationsEnabledPropertyName] as? Bool)
    }    
}

Let's break the above down into smaller code snippets that we can examine in more depth.

var analyticsDelegateSpy: AnalyticsDelegateSpy!
var settingsAnalyticalRegistry: SettingsAnalyticalRegistry!

// MARK: - Lifecycle

override func setUp() {
    super.setUp()
    analyticsDelegateSpy = AnalyticsDelegateSpy()
    settingsAnalyticalRegistry = SettingsAnalyticalRegistry(delegate: analyticsDelegateSpy)
}

One of the most important ideas underpinning unit tests is that each unit test should be independent. So in the above code snippet we are creating a new instance of the spy and register before ever test. This ensures that any changes made in one unit test are discarded before the next unit test is executed.

func test_sendNotificationEnabled_eventName() {
    settingsAnalyticalRegistry.sendNotificationEnabled(true)

    XCTAssertEqual(settingsAnalyticalRegistry.notificationsEventName, analyticsDelegateSpy.passedInEventName)
}

In the above test, we are checking that the event name being used for this event actually matches the one we expect it. This is achieved by using the passedInEventName property declared in the spy and comparing that against the event name declared in the registry.

func test_sendNotificationEnabled_properties() {
    let enabled = true

    settingsAnalyticalRegistry.sendNotificationEnabled(enabled)

    XCTAssertEqual(enabled, analyticsDelegateSpy.passedInProperties![settingsAnalyticalRegistry.notificationsEnabledPropertyName] as? Bool)
}

The above test is a little bit more involved than the previous as we need to first set up the data we are going check. I could have used XCTAssertTrue instead of XCTAssertEqual but I decided on the current implementation as it allows me to change the enabled variable without changing the assert.

And that's it, the sendNotificationEnabled is fully tested with just two unit tests. This pattern of two tests (Event name and Properties) will be used in all of the other tests that we see. This predictable and consistent pattern is one of the key attractions to this registry approach.

func test_sendNotificationEnabled_properties() {
    let enabled = true

    settingsAnalyticalRegistry.sendNotificationEnabled(enabled)

    XCTAssertEqual(enabled, analyticsDelegateSpy.passedInProperties![settingsAnalyticalRegistry.notificationsEnabledPropertyName] as? Bool)
}

Before continuing let's examine the structure being used for the unit test name.

On a side note, when writing unit tests I use the following pattern for naming them:

func test_sendNotificationEnabled_properties()
  1. test
    • Because you need this to indicate that this is a test method and not a helper method.
  2. sendNotificationEnabled
    • what's under test
      • The exact method/unit name is being tested.
  3. properties
    • expected result
      • The expected result for a passing unit test

With this approach you should have all the information you need to understand why a unit test is failing without having to look at it's body 😊.

Ok, let's look at a slightly more complex example.

class ProfileAnalyticalRegistryTests: XCTestCase {

    // MARK: - Properties

    var analyticsDelegateSpy: AnalyticsDelegateSpy!
    var profileAnalyticalRegistry: ProfileAnalyticalRegistry!

    // MARK: - Lifecycle

    override func setUp() {
        super.setUp()
        analyticsDelegateSpy = AnalyticsDelegateSpy()
        profileAnalyticalRegistry = ProfileAnalyticalRegistry(delegate: analyticsDelegateSpy)
    }


    // MARK: - Tests

    // MARK: sendAvatarChangedEvent

    func test_sendAvatarChangedEvent_eventName() {
        profileAnalyticalRegistry.sendAvatarChangedEvent()

        XCTAssertEqual(profileAnalyticalRegistry.avatarChangedEventName, analyticsDelegateSpy.passedInEventName)
    }

    // MARK: sendFieldsChangedEvent

    func test_sendFieldsChangedEvent_eventName() {
        profileAnalyticalRegistry.sendFieldsChangedEvent(true, lastNameChanged: true, emailAddressChanged: true, bioChanged: true)

        XCTAssertEqual(profileAnalyticalRegistry.profileFieldsEventName, analyticsDelegateSpy.passedInEventName)
    }

    func test_sendFieldsChangedEvent_properties() {
        let firstnameChanged = true
        let lastnameChanged = true
        let emailAddressChanged = false
        let bioChanged = true

        profileAnalyticalRegistry.sendFieldsChangedEvent(firstnameChanged, lastNameChanged: lastnameChanged, emailAddressChanged: emailAddressChanged, bioChanged: bioChanged)

        XCTAssertEqual(firstnameChanged, analyticsDelegateSpy.passedInProperties![profileAnalyticalRegistry.fieldsFirstNameChangedPropertyName] as? Bool)
        XCTAssertEqual(lastnameChanged, analyticsDelegateSpy.passedInProperties![profileAnalyticalRegistry.fieldsLastNameChangedPropertyName] as? Bool)
        XCTAssertEqual(emailAddressChanged, analyticsDelegateSpy.passedInProperties![profileAnalyticalRegistry.fieldsEmailAddressChangedPropertyName] as? Bool)
        XCTAssertEqual(bioChanged, analyticsDelegateSpy.passedInProperties![profileAnalyticalRegistry.fieldsBioChangedPropertyName] as? Bool)
        XCTAssertEqual(3, analyticsDelegateSpy.passedInProperties![profileAnalyticalRegistry.fieldsTotalChangedPropertyName] as? Int)
    }

    // MARK: sendFriendRequest

    func test_sendFriendRequest_eventName() {
        profileAnalyticalRegistry.sendFriendRequest(true)

        XCTAssertEqual(profileAnalyticalRegistry.friendRequestEventName, analyticsDelegateSpy.passedInEventName)
    }

    func test_sendFriendRequest_properties() {
        let requested = true

        profileAnalyticalRegistry.sendFriendRequest(requested)

        XCTAssertEqual(requested, analyticsDelegateSpy.passedInProperties![profileAnalyticalRegistry.friendRequestedPropertyName] as? Bool)
    }

}

I'll leave going through this example as an exercise you, if you get lost look back at the simpler example - it's same pattern.

Coffee time ☕️

With this approach, we can create very simple to understand analytical registries that can then be 100% unit tested and encapsulate any event specific knowledge from the other parts of the our project. By splitting our analytics into smaller classes, each one with a tight focus we can also avoid the dreaded god class/object that contains all of our events. If we need to use a different analytical solution it should just be case of updating the delegate's methods with the overall pattern staying the same.

You can find the completed project by heading over to https://github.com/wibosco/AnalyticalAppetite-Example

All of this just to keep Jeff happy, eh.