Building an enum-based analytics system in Swift

Gathering some form of analytics from your users is super important when continuously building, iterating on and improving a product. Learning how your users use your app in real life situations can sometimes be really surprising and take its development in new directions or act as inspiration for new features.

While there are definitely ways to take it too far and be very creepy with analytics, there are also many ways to implement systems that both inform you of how your product is actually used, while still respecting your users' privacy, data usage and overall experience.

However, implementing a solid analytics system that is also easy to use in code can be really difficult. This week, let's take a look at how such a system can be architected and implemented, based on one of my favorite Swift features - enums!

The requirements

Before starting to build any form of system it's always a good idea to write down a list of requirements in terms of how you want it to work and what you need it to do.

For our analytics system, we're going to have 4 goals:

  • It needs to be easy to log events from any view controller. You should only need one line of code to log something.
  • The system should support any underlying system for actually sending events to some form of backend.
  • The system should be highly testable and easy to verify.
  • It should be easy to add, remove & modify events and get compile time errors whenever a call site needs to be updated.

Common approaches

One more thing before we dive into Xcode and start coding - let's have a look at some common approaches for implementing analytics in an app, to see what we can learn from them.

Singletons

The by far most common way to implement an analytics system (that I've seen in the apps I've worked on) is to use a singleton based approach. Just like we took a look at in "Avoiding singletons in Swift", using singletons for analytics can be a totally valid approach (and very convenient), but it can also quickly make our app harder to test & maintain.

Logging analytics events should in most cases be considered part of the controller layer (or an equivalent logic layer if you're not using MVC), and when using a singleton it's very easy for analytics code to start leaking out into your model or view layers.

Strings

Another common way of setting up analytics in an app is to use strings for identifiers. This is super flexible and enables you to change what identifiers you use very quickly. However, it's also a source of common mistakes and bugs when it comes to analytics code. It's far too easy to change a string in one place but forget to update it in another. This again usually makes these kind of systems harder to maintain over time, and can usually lead to lots of noise & invalid events - which makes data analysis a lot harder.

Third party SDKs

Finally, a very common solution used to implement analytics is to use a third party service or SDK. This can in many ways be a great solution in order to make it a lot faster and easier to implement analytics, just be careful with what kind of SDKs you put in your app - and make sure to research what kind of data they gather from your users in order to respect their privacy.

So I'm not recommending against using a third party SDK, quite the opposite. However, when adding such an SDK to your app, I recommend always putting a layer between that code and your own code. Doing that will make testing a lot easier, and make it much more flexible to switch out any such solution in the future.

Setting up the architecture

OK, we have our requirements down and we have done our research - let's get to work! ⚒

The way we're going to setup our analytics system is that we're going to start with an AnalyticsManager. This class will act as the top level API for logging events, and an instance of this class will be dependency injected into any view controller that wants to use our system.

But our AnalyticsManager won't actually do any logging. Instead it will use an AnalyticsEngine to send events to a backend. AnalyticsEngine will be a protocol that we can have multiple implementations of (for example one for testing, one for staging and one for production). It will also make it easier to switch out any third party SDK we might be using in the future.

Finally, we'll have an enum called AnalyticsEvent, which will contain all the events that our analytics system supports. We will use this setup instead of plain strings, both in order to have a compile time guarantee that our events are correct, and also to make refactors and other changes a lot easier in the future.

Let's get coding

Let's start from the ground up, and implement AnalyticsEvent first. We'll use an enum without a raw value, and implement a few events that we initially want to support:

enum AnalyticsEvent {
    case loginScreenViewed
    case loginAttempted
    case loginFailed(reason: LoginFailureReason)
    case loginSucceeded
    case messageListViewed
    case messageSelected(index: Int)
    case messageDeleted(index: Int, read: Bool)
}

Two notes about the code above:

  • For certain events we add additional metadata, such as the LoginFailureReason for the loginFailed event. This will let us more easily analyze our analytics data, to answer questions such as "Why are so many users failing to login to our app?".
  • When including metadata, we use anonymous information, such as the index of a message in the UI, or a simple Bool to indicate whether it was read or not. Ideally we should never have to include IDs such as message ID or user ID, since logging such data can quickly lead to compromised user privacy.

Start your engine

Next, let's implement the AnalyticsEngine protocol:

protocol AnalyticsEngine: class {
    func sendAnalyticsEvent(named name: String, metadata: [String : String])
}

Quite simple, but you may be a bit surprised when looking at the above code. Didn't I just say that we shouldn't use free form strings as identifiers? What about our newly implemented AnalyticsEvent enum? 🤔

While we want all top level calls to use AnalyticsEvent in a type safe way, we don't want the underlying engine to have to know about that type. This will give us much more flexibility to refactor things in the future, and we can guarantee a uniform serialization process by not leaving it up to each engine.

Engine implementations

The beauty of this setup is that it enables multiple implementations of the AnalyticsEngine protocol. For example, we can get started with a simple CloudKit based one:

class CloudKitAnalyticsEngine: AnalyticsEngine {
    private let database: CKDatabase

    init(database: CKDatabase = CKContainer.default().publicCloudDatabase) {
        self.database = database
    }

    func sendAnalyticsEvent(named name: String, metadata: [String : String]) {
        let record = CKRecord(recordType: "AnalyticsEvent.\(name)")

        for (key, value) in metadata {
            record[key] = value as NSString
        }

        database.save(record) { _, _ in
            // We treat this as a fire-and-forget type operation
        }
    }
}

Or we could use more advanced solutions like sending data to our own backend database, or using third party SDKs like Mixpanel or Logmatic. We can also easily implement a mocked engine for testing, but more on that next week 😉.

Serialization

Before we go ahead and finalize things by implementing AnalyticsManager, let's take a look at how we can serialize an AnalyticsEvent value to prepare it for consumption by an AnalyticsEngine.

There are two parts, the name of the event and its metadata. For the most part, the name is super easy to automatically generate, since we can use the standard library's String(describing:) API to have Swift generate a string representing all cases without associated values. For cases with associated values, we'll manually return a name.

extension AnalyticsEvent {
    var name: String {
        switch self {
        case .loginScreenViewed, .loginAttempted,
             .loginSucceeded, .messageListViewed:
            return String(describing: self)
        case .loginFailed:
            return "loginFailed"
        case .messageSelected:
            return "messageSelected"
        case .messageDeleted:
            return "messageDeleted"
        }
    }
}

For metadata, we're either going to have to manually convert a given enum value to a dictionary, or use an automatic encoder such as Wrap. Here's what a simple manual implementation could look like:

extension AnalyticsEvent {
    var metadata: [String : String] {
        switch self {
        case .loginScreenViewed, .loginAttempted,
             .loginSucceeded, .messageListViewed:
            return [:]
        case .loginFailed(let reason):
            return ["reason" : String(describing: reason)]
        case .messageSelected(let index):
            return ["index" : "\(index)"]
        case .messageDeleted(let index, let read):
            return ["index" : "\(index)", "read": "\(read)"]
        }
    }
}

The API

We're finally ready to put everything together and implement AnalyticsManager. The manager will take an object conforming to AnalyticsEngine in its initializer and provide an API that lets us log a given event, like this:

class AnalyticsManager {
    private let engine: AnalyticsEngine

    init(engine: AnalyticsEngine) {
        self.engine = engine
    }

    func log(_ event: AnalyticsEvent) {
        engine.sendAnalyticsEvent(named: event.name, metadata: event.metadata)
    }
}

Very simple, but that's all we really need! 🎉

Usage

The true test of any system is how its API is to use, and what the call site looks like. Let's give our analytics system a go by implementing it in a MessageListViewController:

class MessageListViewController: UIViewController {
    private let messages: MessageCollection
    private let analytics: AnalyticsManager

    init(messages: MessageCollection, analytics: AnalyticsManager) {
        self.messages = messages
        self.analytics = analytics
        super.init(nibName: nil, bundle: nil)
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        analytics.log(.messageListViewed)
    }

    private func deleteMessage(at index: Int) {
        let message = messages.delete(at: index)
        analytics.log(.messageDeleted(index: index, read: message.read))
    }
}

As you can see above, we use classic initializer-based dependency injection to pass our AnalyticsManager into our view controller as part of its setup process. Here you could also use property-based dependency injection (if you are using Storyboards for example), or the factory based approach from "Dependency injection using factories in Swift".

How did we do?

So how did our final implementation score against our 4 goals:

  • You can now easily log events from any view controller. Just inject an AnalyticsManager and use one line of code to log any event.
  • Using various implementations of AnalyticsEngine we can support multiple backends or third party SDKs.
  • Since AnalyticsEngine is a protocol it's very easy to mock it in tests.
  • Since AnalyticsEvent is a type safe enum, it adds an extra level of security for us and we can utilize the compiler to make sure that our setup is correct.

Conclusion

Using three distinct parts, a manager, an engine and an event enum, we are now able to easily write predictable and flexible analytics code that is heavily compile time checked.

This approach is not only nice for analytics, but the Manager + Engine combination can be a great way to abstract things like hardware sensors, location services, etc. as well. What's nice is that it lets you separate your logic and your code from interacting with your underlying dependencies. That heavily increases the chances of your code standing the test of time and not having to be completely rewritten if your underlying dependencies change.

Next week, we're going to build on top of this solution to see how to add unit tests and UI tests to verify our analytics code. So make sure to either subscribe to this blog or follow @swiftbysundell on Twitter to get notified when the next post comes out (spoiler: it'll be next Sunday 😜).

What do you think? Is this an approach of implementing analytics that you've been using before, or is it something you'll try out? Do you have any other tips on how to build an easy to use and predictable analytics system? Let me know, along with any questions, comments or feedback you have - either in the comments section below or on Twitter @johnsundell. Feel free to also share this post on Twitter if you liked it, I would really appreciate it 😊.

Thanks for reading! 🚀

UI testing analytics code in Swift

Using unit tests to identify & avoid memory leaks in Swift