A story of our lives…
Imagine wrapping up an iOS app with only one feature left to implement: the analytics. The client’s marketing team chose a well-known framework that should be easy enough to integrate. They even provided you with a document describing which user actions to track! Nice. It might take you a day or two, but you would be finally ready to release the app.
And then the client asks you to add another analytics framework or tool
You know what’s coming, right? Frantic crunch to meet the release deadline, duplicated code, events tracked with one framework, but not the other, etc. Then multiply it by the number of tools you need to add throughout the application lifetime.
But what if I told you there was another way? What if you could add or remove analytical frameworks at will – without having to modify the code responsible for tracking the events?
Strap in and let’s find out
It all begins with the right abstraction
If you have experience with multiple analytics trackers (e.g. MixPanel, Flurry, Firebase, etc.), you might be forgiven for thinking these trackers have absolutely nothing in common. But don’t they really?
Let’s ignore the API and take a look at what a typical analytics client really does:
- tracks events – one-time or prolonged actions (e.g. the user tapping a button or starting an onboarding flow).
- manages a session – a combined “state” of the user and the app (e.g. what accessibility features were enabled).
- collects context data – device model, iOS version, etc. Naturally, the business wants to know as much about the user, as (legally) possible…
And that’s really it! If you filter out the noise, the responsibilities above amount to all an analytics tracker does.
The questions you should be asking yourselves now are:
- Can I abstract these actions?
- Can I design a tracking API universal enough to use 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 if a tracker does not support eg. the Timed Event concept? Such an event represents a non-atomic action that has a distinct beginning and end.
That’s easy! We’ll try to implement the “next best thing”. For Firebase tracker, we could add a proper suffix to an event name: e.g. ONBOARDING_START and ONBOARDING_STOP.
But what if a tracker uses some proprietary concept that just cannot be implemented by other trackers? You just focus on implementing this concept only for the tracker that supports it. The business wouldn’t miss out on anything, since the other trackers simply don’t offer that particular functionality either way
What about managing a session then? Thought you’d never ask
First, let’s clarify what a session really is. To put it simply: it’s a combined state of the user and an application, in a distinct moment in time. An invaluable background information, putting all user actions in a much needed context.
E.g. the fact that it took 3% of users 5+ minutes to complete the onboarding might seem worrisome… if not for the fact that most of these users had some form of accessibility features enabled on their devices. It seems reasonable to create a separate funnel for such users to figure out how to improve their experience with the app. All that invaluable information is stored in the analytics session.
So, what should a SessionManager do:
- Create an initial session – e.g. when the application is launched.
- Start a new session and stop the current one – e.g. when the user signs in.
- Update session properties – e.g. the accessibility settings we’ve just discussed.
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 keeps a reference to the active session. When a new session is started (e.g. when the user signs into the app), the manager forwards the data to the analytics provider framework. If we just want to update the active session parameters (e.g. when the user changed accessibility settings), we just forward these. Seems simple enough, right ?
But what if a given analytics provider does not implement a concept of an analytics session? Similarly to the AnalyticsTracker, we either try to implement a “next best thing” (e.g. include all session data as an additional event context), or don’t 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 {}
As expected, every analytics provider framework we wish to include into the application would have to conform to the AnalyticsClient protocol.
So, how do we interact with it? Let’s take an initial view of the app onboarding flow as an example. That view needs to track the start of the onboarding process. From its perspective, does it matter how a given analytics tracker implements sending that event? Of course not!
These are just the implementation details we can hide behind an abstraction (which we just did).
As a matter of fact, does the view even care how many trackers it is interacting with? No! We can pass an array of them to the view to iterate through when sending an event.
But wait! It gets even better! What if there was a way to make sure all the trackers get notified every time there is an action they need to perform, without the need to address them individually across the app? Say hello to one of the OOP pillars – composition:
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, whenever we want to track an event or change analytics session parameters, all we need to do is to call the appropriate method on the Aggregator. It is responsible for relaying that command to every active analytics client. Additionally, we can add, remove and change implementation details of each analytics client, and the rest of the application would not even notice .
All right, it’s all been a bunch of theoretical examples so far. I need to see a real-life implementation of an AnalyticsClient!
Your wish is my command! Let’s take a look at (arguably) the most popular analytics framework these days – 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)
}
}
…
}
A word of explanation about the FirebaseAnalyticsWrapper. It’s simply a protocol mirroring the Firebase Analytics API. By introducing it, we ensure the analytics client depends on an abstraction rather than a physical representation of Firebase analytics framework.
This way, when writing Unit Tests, we can inject a fake implementation of the API wrapper, and verify proper interaction between it and the Analytics Client.
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 {}
To sum up: by simply stopping for a minute to define the responsibilities a typical analytics client, we managed to:
- Spare app components from being exposed to all the meaty-gritty implementation details of every analytics framework.
- Deliver a single point of entry into the app analytics for all the components mentioned above.
- Allow ourselves to add, remove and configure analytics clients if the business requires it.
- Ensure clean division of responsibilities, loose coupling and testability of the entire app analytics stack.
Are there any alternatives?
Hold on for a minute… Why do I need to implement all that complicated abstraction layer when I can just use a 3rd party solution? There is at least one pod, SPM package, etc. out there for every engineering problem, right?
Naturally, you can use a handy analytics abstraction library like Umbrella. It is shipped with out-of-the-box integration with top analytics providers (e.g. Firebase, Flurry, etc.) and provides an ability to integrate unsupported analytics tools. The question worth asking is: how future-proof is such a solution? Can we hope to integrate every potential analytics tool (as required by the business) into the app when we don’t control the code of analytics abstraction we use? A food for thought…
You might also consider a different approach: using a backend service (like Segment) to aggregate and process events before passing them on to the appropriate providers. This is definitely a much lighter solution compared to integrating multiple independent analytics frameworks into the app. Bear in mind however: using additional aggregator services might be expensive in the long run, especially when the app traffic picks up. In addition, not every analytics client might be supported by such a service.
Final thoughts
In general: hiding implementation details behind abstractions will never do you wrong in software development Application analytics is no different.
Well-thought abstractions covering key app analytics concepts (an event, a tracker, a session, etc.), can save you a lot of sorrow in the long run. The only downside is an additional time you’d need to spend on thinking everything through properly.
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
The post App analytics – done the right (memical) way first appeared on Swift and Memes.