A modular approach for integrating analytics platforms into your iOS app - Mixpanel
A modular approach for integrating analytics platforms into your iOS app
Data Stack

A modular approach for integrating analytics platforms into your iOS app

Last edited: Nov 14, 2022 Published: Sep 15, 2021
Joseph Pacheco App Developer and Product Creator

Mixpanel is the gold standard for iOS app analytics. And the best way to see how is to try it in your app.

Now, I know what you’re thinking. Integrating new frameworks can be work, not to mention risky for introducing bugs (especially if you’re already invested in some other analytics tool like Google Analytics).

The good news is, through the power of modular integrations in Swift, it’s not difficult to optimize your app’s architecture to virtually eliminate the disruptions that can come with this kind of experimentation. And that makes it possible to get into all the benefits of a sophisticated platform like Mixpanel with little technical risk—even if for only a trial run and/or to use it alongside other tools like GA.

Here’s how you (or your app developer team) can do it.

As a matter of principle, abstract-away your dependencies

Whether you’re talking about analytics platforms or persistence frameworks, it’s generally a good idea to keep the tentacles of third-party dependencies out of your core product code.

But this is especially true when it comes to tracking user events for analytics purposes. If you’re not careful, your view controllers (and SwiftUI views) will be littered with calls to code you have no direct control over. And if you ever have to remove that code, or swap it with some other dependency, you’ve got yourself a risk-introducing extravaganza!

But what if it was easy to swap analytics platforms or use multiple at the same time—without so much as touching any of the code that drives your features?

Use services to hide analytics frameworks

A service layer is a way of defining app-specific interfaces for logically related chunks of functionality that are often dependent on external APIs or third-party frameworks.

For example, “analytics” is a chunk of functionality relevant to all apps interested in growth, so it makes sense that we encapsulate our analytics-related functionality in a service called AnalyticsService.

/// A service that tracks events and properties
protocol AnalyticsService {

		func track(_ event: AnalyticsEvent)
    ...
}

The accompanying AnalyticsEvent protocol neatly represents any event you or your product team might desire to track, and in a way that takes full advantage of Swift language features.

/// An event to be tracked for analytics purposes
protocol AnalyticsEvent {
    ...
}

For example, let’s say you wanted to track when a user shared something from your app to another app. We could have a struct called ShareEvent that natively represents the act of sharing, along with an associated item being shared (as represented by a ShareEventItem enum):

/// An event wherein the user shared something
struct ShareEvent: AnalyticsEvent {
        
    /// The items being shared
    let items: [ShareEventItem]
}

/// An item that can be shared
enum ShareEventItem: String {
    case photo
    case video
		case app
}

Then when we want to track this event from our view controller, all we need is an instance of this strongly typed event and the analytics service.

So we create a protocol that allows an object to track analytics events:

/// A protocol that defines an object that tracks analytics events
protocol AnalyticsTracking {
    
		/// The analytics service used to track events
    var analyticsService: AnalyticsService { get }    
}

And then we have our view controller conform to this protocol and track the ShareEvent:

/// A view controller that displays a list of photos
class PhotoListViewController: UIViewController, AnalyticsTracking {

		/// The service that tracks analytics
		let analyticsService: AnalyticsService
		
		/// The callback triggered when a photo is shared
		func didSharePhoto() {
				
				/// Track a share event with the analytics service
				analyticsService.track(ShareEvent(items: [.photo]))
		}
		...
}

And viola! You’ve tracked an event in a way that is as natural as possible using Swift language conventions.

Now to funnel this event back to Mixpanel, we simply implement a custom struct that conforms to AnalyticsService which we then inject into our view controllers abstractly:

/// A service that tracks analytics with Mixpanel
struct MixpanelAnalyticsService: AnalyticsService {
		
		/// Track the given event with Mixpanel
		func track(_ event: AnalyticsEvent) {
				...
		}
}

/// Create the service
let service = MixpanelAnalyticsService()

/// Inject into the view controller
let viewController = PhotoListViewController(analyticsService: service)

It’s in this service where we import and make calls to the Mixpanel SDK. We’ll get to the nuances of implementing the track method later. At this point, notice just how simple this overall structure makes our lives. We could swap MixpanelAnalyticsService with any service that conforms to our protocol, leaving our view controller completely unchanged in the process because it never has to be aware of the details of the concrete implementation.

Leverage composition for multiple analytics platforms

But let’s say we wanted to use two analytics platforms simultaneously, say Mixpanel and GA. We’d simply create another service that composes our two platform implementations.

/// An analytics service that comprises multiple child analytics services
struct CompoundAnalyticsService: AnalyticsService {
    
    /// The analytics services comprising this compound service
    private let services: [AnalyticsService]
    
    /// Track the given event with each child service
    func track(_ event: AnalyticsEvent) {
        services.forEach { service in
            service.track(event)
        }
    }
		...
}

Then, when injecting into our view controllers, we pass both platform-specific implementations in the form of a single compound service:

let mixpanel = MixpanelAnalyticsService()
let google = GoogleAnalyticsService()
let compound = CompoundAnalyticsService(services: [mixpanel, google])
let viewController = PhotoListViewController(analyticsService: compound)

In this case, every event that get’s tracked will get sent to both Mixpanel and Google Analytics—again, without ever touching the internals of your view controller (or SwiftUI) code!

Map events to each platform

So far, you’ve seen how simple and elegant it is to support multiple analytics platforms with a few basic protocols and structs. But we still need to dive into the details of translating our native Swift events into something Mixpanel and other frameworks can understand.

In other words, the Mixpanel SDK (and others) accepts events and properties as simple strings and dictionaries. So to send an event to Mixpanel, we’d need to write code like this:

let mixpanel = Mixpanel.mainInstance()
mixpanel.track(event: "Share", properties: ["Item": "Photo"])

But recall that the track method of our MixpanelAnalyticsService takes native Swift events:

/// A service that tracks analytics with Mixpanel
struct MixpanelAnalyticsService: AnalyticsService {
    
		/// Track the given event with Mixpanel
    func track(_ event: AnalyticsEvent) {
				// ?!?!?!
    }
		...
}

So let’s start by using a struct to define exactly what Mixpanel needs from one of our events in order to make tracking easy:

/// An event that can be tracked by Mixpanel
struct MixpanelEvent {
        
    /// The name of the event used for tracking on Mixpanel
    let name: String
    
    /// The properties dictionary associated with the event
    let properties: [String: MixpanelType]
}

Then we define a protocol that allows our native events to be converted into one of these platform-specific events:

Then for each of our native events (at least those we’d like to track with Mixpanel), we conform to this protocol in an extension. For example, the extension for our share event from above would look like this:

/// Something that can be mapped to a Mixpanel event
protocol MixpanelEventConvertible {
    
    /// The Mixpanel events to which this
    var mixpanelEvent: MixpanelEvent { get }
}

After that, the track method of our MixpanelAnalyticsService is as simple as a few lines of code:

struct MixpanelAnalyticsService: AnalyticsService {
    
    /// Track a native analytics event
    func track(_ event: AnalyticsEvent) {
        guard let convertibleEvent = event as? MixpanelEventConvertible else {
            return
        }
        let mixpanelEvent = convertibleEvent.mixpanelEvent
        Mixpanel.mainInstance().track(event: mixpanelEvent.name, properties: mixpanelEvent.properties)
    }
}

And we could replicate this pattern for an arbitrary number of analytics platforms, say GA for example:


struct GoogleAnalyticsService: AnalyticsService {
    
    func track(_ event: AnalyticsEvent) {
        guard let convertibleEvent = event as? GoogleAnalyticsEventConvertible else {
            return
        }
        let googleEvent = convertibleEvent.googleEvent
        Analytics.logEvent(googleEvent.name, parameters: googleEvent.parameters)
    }
}

What’s great about this approach is that each platform is entirely self-contained, both from each other and from the rest of your code. Adding support for a new platform is simply a matter of creating a new group in Xcode and adding files for your service and extensions. Your native events remain untouched and so do your views and view controllers. You simply add another child analytics service to your compound analytics service and you’re done.

Accomodate variations in event architecture

But it’s time to address an inconvenient truth: There’s no one way to define your events.

For example, a Share event with a property for an items type could just as easily be split into a separate event for each item type (e.g. Share Photo and Share Video).

This is relevant, for one, because you might use different approaches at different stages of your app’s development. That is, you might start with a Share Photo event because your app only allowed photo sharing at launch. But now that you can share all types of items, a generic Share event with an items property makes more sense.

On the other hand, different platforms encourage different architectural approaches. Mixpanel prefers you keep events concise and let the event properties do the talking. That’s because it has advanced tools that allow you to slice and dice your data however you like after you track. However, other platforms might have limitations that make separate events a better approach.

That means there’s not necessarily a one-to-one mapping between your native Swift events and the events tracked by a given analytics platform.

In other words, your native ShareEvent(items: [.photo, .video]) could translate to a Share event with an items property in Mixpanel and at least two events in some other platform.

To accommodate this, we simply update our MixpanelEventConvertible to require each native event map to one or more platform-specific events:

/// Something that can be mapped to one or more of Mixpanel events
protocol MixpanelEventMappable {
    
    /// The mixpanel events to which this
    var mixpanelEvents: [MixpanelEvent] { get }
}

Tweak based on your needs

The benefit of this approach is that it’s highly flexible and can be adjusted to accommodate a variety of use cases and app-specific needs.

Accommodate lots of events and properties

Most apps have a manageable number of events and properties such that our approach to mapping events to each platform is maintainable while adding clarity. But if you have hundreds of events and properties, that could amount to many engineering hours and thousands of lines of code. As such, you might want to swap it for something that can be defined outside of engineering and automated. And with this overall structure you can swap as desired!

Handle divergent naming conventions

Different analytics platforms have different conventions for naming events. For example, Mixpanel uses a simple (and IMO attractive) uppercased string with spaces (e.g. Share or Sign Up) while GA uses lowercased strings with underscores (e.g. share and sign_up).

Sure, we could kind of force consistency across analytics platforms by picking one style and rolling with it, but we’d still have special cases to deal with. For one, GA encourages you to use predefined names (that can’t be changed) for certain events to get additional, special data about the event.

As such, you could easily update the AnalyticsEvent protocol with a universal tracking identifier property (perhaps as an enum type) for all platforms:

/// An event to be tracked for analytics purposes
protocol AnalyticsEvent {
    
    /// The unique id used in the tracking of this event
    var trackingId: AnalyticsEventTrackingId { get }
}

Then you could provide a mapping from this identifier to the platform-specific event name from which the name property of each of your platform-specific event types could be derived automatically.

Simplify as desired

Maybe you think some aspects of the above approach are overkill for your setup. Perhaps you really are only using GA and Mixpanel and you don’t really need to maintain separate event types since they both reduce to a string and properties dictionary. You could just as easily combine the MixpanelEvent and GoogleAnalyticsEvent into a single AnalyticsPlatformEvent struct and call it a day.

/// An event that can be tracked an analytics platform
struct PlatformEvent {
        
    /// The name of the event used for tracking
    let name: String
    
    /// The properties dictionary associated with the event
    let properties: [String: Any]
}

/// Something that can be mapped to a platform event
protocol PlatformEventConvertible {
    
    /// The platform event
    var platformEvent: PlatformEvent { get }
}

This way, each of your native Swift events would conform only to this protocol once, funneling the same data to both services.

On the other hand, you may want to adopt this shared protocol while coming up with some other abstraction that solely addresses the differences in naming conventions.

Either way, this structure still gives you the option of adding support for feature sets supported exclusively by one platform and not the other, which is particularly useful as new features are introduced.

It’s just a matter of how sharply separated you’d like to keep each silo, or whether you want to track additional points of overlap in your code.

About Joseph Pacheco

Joseph is the founder of App Boss, a knowledge source for idea people (with little or no tech background) to turn their apps into viable businesses. He’s been developing apps for almost as long as the App Store has existed—wearing every hat from full-time engineer to product manager, UX designer, founder, content creator, and technical co-founder. He’s also given technical interviews to 1,400 software engineers who have gone on to accept roles at Apple, Dropbox, Yelp, and other major Bay Area firms.

Get the latest from Mixpanel
This field is required.