How to implement app analytics the right way

Picture this: You’re finishing up an iOS app, and there’s only one feature left to implement – the analytics. The client’s marketing team has chosen a reputable framework that should be straightforward to integrate. They’ve even supplied a document detailing which user actions require tracking. It could take a day or two, but you’d be ready to release the app.

And then the client requests adding another analytics framework or tool.

You can see what’s ahead: a hectic rush to meet the release deadline, duplicated code, events tracked with one framework but not the other, and so on. And then multiply this by the number of analytical tools you need to incorporate over the app’s lifespan.

When adding another analytics client to your app - App analytics done the right (memical) way

But what if there was another method? What if you could add or remove analytical frameworks as needed without modifying the code responsible for tracking events? Let’s take a look!

Prefer listening over reading?

It all begins with the right abstraction

If you have experience with multiple analytics trackers (e.g., MixPanel, Flurry, Firebase, etc.), you might think these trackers share no commonalities. But is that really the case?

Let’s bypass the API and examine what a typical analytics client does:

  • Tracks events – These are one-time or prolonged actions, such as a user tapping a button or initiating an onboarding flow.
  • Manages a session – This is a combined ‘state’ of the user and the app, such as which accessibility features are enabled.
  • Collects context data – This includes the device model, iOS version, etc. Naturally, businesses want to know as much about the user as legally permissible.

That’s essentially all an analytics tracker does when you cut through the noise.
The questions you should now be asking are:

  • Can I abstract these actions?
  • Can I design a universal tracking API using that abstraction?

Let’s start with tracking events. To introduce a modicum of sophistication let’s call our abstraction… AnalyticsTracker 🤣

protocol AnalyticsTracker {
    func trace(event: AnalyticsEvent)
    func start(timedEvent: AnalyticsEvent)
    func stop(timedEvent: AnalyticsEvent)
}

and:

struct AnalyticsEvent {
    let name: String
    let collection: AnalyticsEventCollection
    let context: [String: AnyHashable]?
}

This way we can describe virtually every measurable outcome of every user action. Just to give you a concrete example:

/// Tracks screen entry.
func trackScreenEntered() {
    let event = AnalyticsEvent(
        name: ScreenTrackingEventName.enter.rawValue,
        collection: .screenTracking,
        context: [ScreenTracking.Parameters.screenName.rawValue: screenName]
    )
    
    tracker.track(event: event)
}

So, what happens if a tracker doesn’t support the concept of a Timed Event? This type of event represents a non-atomic action with a clear beginning and end.

In such a case, we try to implement the closest alternative. For instance, in the Firebase tracker, we could add suffixes to an event name, such as ONBOARDING_START and ONBOARDING_STOP.

But what if a tracker uses a proprietary concept that can’t be implemented by other trackers? In this case, focus on implementing this concept only for a tracker that supports it. The business won’t miss out on anything, as other trackers simply don’t provide this specific functionality.

So, what about managing a session? I thought you would never ask 🤣

Firstly, we need to understand what a session truly is. Simply put, a session is the combined state of the user and an application at a specific moment in time. It gives invaluable background information that provides context for all user actions.

For example, the fact that 3% of users took 5+ minutes to complete the onboarding might seem concerning. However, when you consider that most of these users had some form of accessibility features enabled on their devices, it makes sense. It might be useful to create a separate funnel for these users to improve their experience with the app. All this crucial information is stored in the analytics session.

So, what tasks should a SessionManager handle:

  • Create an initial session, for instance, when the application is launched.
  • Start a new session and end the current one, like when a user signs in.
  • Update session properties, such as the accessibility settings we just mentioned.

Let’s try defining those in a protocol:

protocol AnalyticsSessionManager {
    func start(session: AnalyticsSession)
}

Wait a minute… That’s really it !?!

Well, it’ll make more sense when we take a closer look into the AnalyticsSession:

enum AnalyticsSession {
    case authenticated(AnalyticsAccessibilitySettings, AnalyticsUser)
    case unauthenticated(AnalyticsAccessibilitySettings)
}

What data might AnalyticsAccessibilitySettings and AnalyticsUser contain? The only correct answer is: whatever is important from the business perspective:

struct AnalyticsUser {
    let pushNotificationsEnabled: Bool
    let isBiometricsEnabled: Bool
    
}

A SessionManager maintains a reference to the active session. When a new session starts, such as when a user signs into the app, the manager forwards this data to the analytics provider framework. If the active session parameters need updating, like when a user changes accessibility settings, these changes are simply forwarded. It’s a straightforward process.

But what happens if a particular analytics provider doesn’t implement an analytics session concept? Similar to the AnalyticsTracker, we have two options: attempt to implement the next best thing (for instance, including all session data as additional event context), or simply not implement an analytics session at all.

Looks nice, but does it work in real-life projects?

So far, we focused only on trying to determine what a typical analytics client does. A quick reminder: it tracks events and manages analytics sessions. Let’s make it official:

protocol AnalyticsClient: AnalyticsTracker, AnalyticsSessionManager {}

Every analytics provider framework we want to incorporate into the application must conform to the AnalyticsClient protocol. But how do we interact with it? Let’s consider the initial view of the app’s onboarding flow as an example. This view needs to monitor the beginning of the onboarding process. From its perspective, does it matter how a specific analytics tracker executes sending that event? Of course not! These are just the implementation details that we can conceal behind an abstraction, which we’ve just done.

In fact, a view doesn’t “care” about the number of trackers it interacts with. We can even pass an array of trackers to the view to iterate through when sending an event.

But it gets even better! What if we could ensure every tracker is notified whenever there’s an action they need to perform, without addressing them individually across the app? This is where one of the pillars of OOP – composition – comes into play:

protocol AnalyticsAggregator: AnalyticsTracker, AnalyticsSessionManager {
    private (set) var clients: [AnalyticsClient] { get }
}

And for the sake of clarity, let’s implement the following extension as well:

extension AnalyticsAggregator {
    
    func track(event: AnalyticsEvent) {
        clients.forEach { $0.log(event: event) }
    }
    func start(timedEvent: AnalyticsEvent) {
        clients.forEach { $0.start(timedEvent: timedEvent) }
    }
    func stop(timedEvent: AnalyticsEvent) {
        clients.forEach { $0.stop(timedEvent: timedEvent) }
    }
    
}

So, when we need to track an event or adjust analytics session parameters, we simply call the relevant method on the Aggregator. This tool relays commands to all active analytics clients. We can also modify implementation details of each analytics client without affecting the rest of the application.

Alright, we’ve only covered theoretical examples so far, and you may be eager to see a real-life implementation of an AnalyticsClient.

Well, your wish is granted! Let’s examine what is arguably the most popular analytics framework currently – Firebase Analytics:

final class FirebaseAnalyticsClient: AnalyticsClient {
    
    
    
    private let analyticsWrapper: FirebaseAnalyticsWrapper.Type    // a Firebase Analytics API wrapper
    
    

    init(
        analyticsWrapper: FirebaseAnalyticsWrapper.Type = Analytics.self,
    ) {
        self.analyticsWrapper = analyticsWrapper
    }
    
    

    func trace(event: AnalyticsEvent) {
        analyticsWrapper.logEvent(event.analyticsName, parameters: event.context)
    }

    func start(timedEvent: AnalyticsEvent) {
        let name = composeTimedEventName(event: timedEvent, isStarting: true)
        analyticsWrapper.logEvent(name, parameters: timedEvent.context)
    }

    func stop(timedEvent: AnalyticsEvent) {
        let name = composeTimedEventName(event: timedEvent, isStarting: false)
        analyticsWrapper.logEvent(name, parameters: timedEvent.context)
    }

    func start(session: AnalyticsSession) {
        switch session {
        case let .unauthenticated(analyticsSettings):
            setSessionParameters(analyticsSettings: analyticsSettings, analyticsUser: nil)
        case let .authenticated(analyticsSettings, analyticsUser):
            setSessionParameters(analyticsSettings: analyticsSettings, analyticsUser: analyticsUser)
        }
    }
    
    
    
}

Let’s talk about the FirebaseAnalyticsWrapper. It’s a protocol that mirrors the Firebase Analytics API. By using it, we make sure that the analytics client relies on an abstraction instead of the actual Firebase analytics framework.

This approach allows us to introduce a mock version of the API wrapper during Unit Tests. We can then check that the interaction between the wrapper and the Analytics Client is functioning correctly.

This is how the wrapper looks like:

protocol FirebaseAnalyticsWrapper: AnyObject {
    static func logEvent(_ name: String, parameters: [String: Any]?)
    static func setUserProperty(_ value: String?, forName name: String)
    static func setUserID(_ userID: String?)
    
}

extension Analytics: FirebaseAnalyticsWrapper {}

In summary, by taking a moment to define a typical analytics client’s responsibilities, we achieved the following:

  • We shielded app components from the intricate implementation details of each integrated analytics framework.
  • We provided a single entry point into the app analytics for all the app components.
  • We allowed the addition, removal, and configuration of analytics clients as business needs dictate.
  • We ensured a clear division of responsibilities, loose coupling, and testability throughout the app analytics stack.

Are there any alternatives?

You might ask: “Why should I implement a complex abstraction layer when I can simply use a third-party solution? After all, there’s a solution out there for every engineering problem, right?”.

Certainly, you can use a convenient analytics abstraction library, such as Umbrella. It comes integrated with leading analytics providers (like Firebase, Flurry, etc.) and offers the ability to integrate unsupported analytics tools. However, it’s worth considering how future-proof such a solution is. Can we expect to integrate every potential analytics tool (as required by the business) into the app when we don’t control the code of the analytics abstraction we use? Just food for thought.

Alternatively, you might consider using a backend service like Segment to gather and process events before sending them to the appropriate providers. This approach is significantly lighter compared to incorporating multiple independent analytics frameworks into the app. However, remember that using additional aggregator services can become costly over time, particularly as app traffic increases. Also, not every analytics client might be supported by such a service.

Final thoughts

Generally, hiding implementation details behind abstractions is beneficial in software development, and application analytics is no exception.

Carefully crafted abstractions that cover key app analytics concepts (such as an event, a tracker, a session, etc.) can save significant amount of time in the long run. The only trade-off is the initial time required to thoroughly think through everything.

Alas, it is a sacrifice I’m more than willing to make!

Don’t miss any new blogposts!

    By subscribing, you agree with our privacy policy and our terms and conditions.

    Disclaimer: All memes used in the post were made for fun and educational purposes only 😎
    They were not meant to offend anyone 🙌
    All copyrights belong to their respective owners🧐
    The thumbnail was generated with text2image ❤️ 
    Audio version generated with TTS Maker