Our Ever Expanding Appetite for Analytics
You've just shipped a new feature. A week later, Jeff asks whether anyone's actually using it. You check the analytics dashboard and... there's nothing. No events, no data, no insights. The feature is flying blind. You can't say whether that increase in daily active users is connected to it, whether it's worth iterating on, or whether you should quietly kill it. All you've got is a gut feeling.
Every feature brings with it an appetite for data. Analytics isn't an add-on - it's a vital part of the feature. But it's easy to miss that and instead see it as something to bolt on after you've finished the feature. The result is analytics scattered throughout the feature with little structure or thought about how analytics shapes the design. This approach can easily lead to analytic bugs that, in turn, will lead to decision-making based on faulty data.
Let's change that.

This post will explore how to build an analytics layer that is easy to write, extend and test by placing it separate but parallel to the functionality that it tracks. Each area of the app will get its own analytics registry responsible for constructing events.
This post will gradually build up to a working example using the
MixpanelSDK. But I get you're busy. Head over to the completed example and take a look atAnalyticsService,AnalyticsEventandFeedAnalyticalRegistryto see how things end up.
Looking at the Menu
Before jumping into building each component, let's take a moment to look at the overall architecture we're going to put in place and how each component fits into it.

AnalyticsEvent- represents a user event worth tracking.AnalyticsService- is the bridge between the wider app and the analytics provider(s). It acceptsAnalyticsEventinstances and delivers them toMixpanel,Firebase, etc. By fully wrapping the analytics provider(s), it insulates the rest of the app from any provider changes.AnalyticalRegistry- groups related events together and exposes each event as a method. It constructs and configuresAnalyticsEventinstances before passing them toAnalyticsService.
I've used generic naming in the class diagram, but in the example, we will be working in concrete types, so
AnalyticalRegistrywill becomeFeedAnalyticalRegistryandProfileAnalyticalRegistry.
Don't worry if that doesn't all make sense yet; we will look into each component in greater depth below.
Starting with the Event
Before we can track anything, we need to be able to build the concept of an event:
// 1
struct AnalyticsEvent {
// 2
let name: String
// 3
let properties: [String: Any]?
init(name: String,
properties: [String: Any]? = nil) {
self.name = name
self.properties = properties
}
}
AnalyticsEventis a struct used for tracking the details about a particular action that has just occurred.nameis the identifier for this event, e.g. "Post Liked".propertiesis a dictionary of additional context about the event, e.g.["Liked": true]. The values areAnybecause analytics properties can be a mix of types -Bool,String,Int, etc - and we need a single dictionary to hold them all.
The above
AnalyticsEventis deliberately simple for this post, but this type can be extended/altered to fit your exact needs.
With AnalyticsEvent in place, the next piece is the service that delivers it to our analytics provider(s).
Serving up the Service
As mentioned, we don't want our wider app to care about what analytic provider(s) we are using. AnalyticsService hides those details:
// 1
class AnalyticsService {
private let mixpanel: Mixpanel
// 2
init(mixpanel: Mixpanel) {
self.mixpanel = mixpanel
}
// 3
func send(_ event: AnalyticsEvent) {
mixpanel.track(event.name,
properties: event.properties)
}
}
AnalyticsServiceis a class because it's a long-lived shared resource passed to multiple registries rather than a value you'd copy around.Mixpanelis injected through the initialiser rather than created internally, keeping the dependency explicit.send(_:)takes anAnalyticsEventand passes its name and properties straight through to thetrack(_:properties:)method onmixpanel. This is the only place in the app that talks to theMixpanelSDK.
That last point is worth underlining. By funnelling every event through this single method, Mixpanel never leaks into the rest of the codebase. If Mixpanel changes its API, or we switch to Firebase tomorrow, this one method is the only thing that needs updating. Every registry, every call site, every test - all untouched.
Events exist and the service can deliver them, but nothing connects the two yet:
// 1
class FeedAnalyticalRegistry {
// 2
private let service: AnalyticsService
init(service: AnalyticsService) {
self.service = service
}
// 3
func sendPostOpenedEvent() {
let event = AnalyticsEvent(name: "Post Opened")
service.send(event)
}
func sendLikeEvent(liked: Bool) {
let event = AnalyticsEvent(name: "Post Liked",
properties: ["Liked": liked])
service.send(event)
}
func sendSharedEvent(shared: Bool) {
let event = AnalyticsEvent(name: "Post Shared",
properties: ["Shared": shared])
service.send(event)
}
}
FeedAnalyticalRegistrygroups all feed-related analytics events into one type. You'd create similar registries for other areas of your app -ProfileAnalyticalRegistry,SettingsAnalyticalRegistry, etc.- The registry depends on an
AnalyticsServiceinstance. - Each method represents a single event. The caller passes typed parameters -
liked: Bool,shared: Bool- and the registry handles constructing theAnalyticsEventwith the correct name and property keys. This keeps event names and property structures defined in one place rather than scattered across call sites.
FeedAnalyticalRegistry doesn't care how the value of liked or shared was gathered; it just takes that information and knows how to construct an AnalyticsEvent instance from it. This separation of concerns means that a registry can be used in multiple domains, such as paired with a view-model, controller, service, etc., without the internal implementation of that registry having to change. The environment each registry operates in never leaks into its implementation.
The registry is where the real value of this approach lives. Instead of analytics being scattered across the feature, every event definition lives here in one place.
FeedAnalyticalRegistry is really quite simple, but there's no reason that more complex logic can't be inside a registry:
class ProfileAnalyticalRegistry {
private let service: AnalyticsService
init(service: AnalyticsService) {
self.service = service
}
func sendFieldsChangedEvent(firstNameChanged: Bool,
lastNameChanged: Bool,
emailAddressChanged: Bool,
bioChanged: Bool) {
let totalFieldsChanged = [firstNameChanged, lastNameChanged, emailAddressChanged, bioChanged]
.filter { $0 }
.count
let properties: [String: Any] = ["First Name Changed": firstNameChanged,
"Last Name Changed": lastNameChanged,
"Email Address Changed": emailAddressChanged,
"Bio Changed": bioChanged,
"Total Fields Changed": totalFieldsChanged]
let event = AnalyticsEvent(name: "Profile Fields",
properties: properties)
service.send(event)
}
}
ProfileAnalyticalRegistry follows the same pattern as FeedAnalyticalRegistry - we inject in an AnalyticsService instance and expose very focused methods that hide the AnalyticsEvent building process.
The production code is in place - but analytics events that aren't tested are analytics events we can't trust yet.
Testing to Taste
To test AnalyticsService, we need a way to inject a test-double in the place of Mixpanel. That test-double needs to match the interface of Mixpanel to be acceptable to the compiler - we could subclass Mixpanel and override its method, but a better approach is to create a protocol that we can wrap both Mixpanel and the test-double in.
We don't own Mixpanel, so we can't directly change its implementation, but we can use an extension to conform Mixpanel to our new protocol:
// 1
protocol MixpanelTracking {
func track(_ event: String, properties: [AnyHashable : Any]?)
}
// 2
extension Mixpanel: MixpanelTracking { }
MixpanelTrackingis a protocol that mirrors thetrack(_:properties:)method onMixpanel.Mixpanelalready has atrack(_:properties:)method with this exact signature, so conforming it toMixpanelTrackingrequires no additional code - the extension is empty.
This pattern - defining a protocol that matches an existing method and conforming the type via an empty extension - works for any third-party dependency where you want to introduce a testability seam without modifying the original type. It's not specific to analytics or
Mixpanel; anywhere you depend on a concrete type you don't control, the same approach applies.
With Mixpanel now conforming to MixpanelTracking, we can update AnalyticsService to accept a type of MixpanelTracking rather than Mixpanel:
class AnalyticsService {
private let mixpanel: MixpanelTracking
init(mixpanel: MixpanelTracking) {
self.mixpanel = mixpanel
}
// Omitted unchanged functionality
}
This allows us to create a test-double conforming to MixpanelTracking to take the place of Mixpanel in our unit tests:
class StubMixpanelTracking: MixpanelTracking {
// 1
enum Event {
case track(String, [AnyHashable : Any]?)
}
// 2
private(set) var events = [Event]()
// 3
func track(_ event: String,
properties: [AnyHashable : Any]?) {
events.append(.track(event, properties))
}
}
Eventis an enum that records which method was called on the stub. With onlytrack(_:properties:)on the protocol, it has a single case -track. Astrack(_:properties:)has two parameters, thetrackcase has two associated values to allow any test to inspect the values passed into the method. Recording method calls as enum cases avoids maintaining individual properties for each method's state, and scales naturally as the API grows.eventsis an array that captures every call in order. Using an array rather than a single property means we can verify that the right number of events were sent, not just the last one.- As
StubMixpanelTrackingconforms toMixpanelTracking, it must implementtrack(_:properties:). Here, we append a newEventcase to the array to track that the method was called.
This isn't a post about how to write test-doubles, so there is no need to get in touch about how you consider
StubAnalyticsEventSendingto be more of aSpythan aStub.
We can now write our AnalyticsService unit tests:
final class AnalyticsServiceTests: XCTestCase {
// 1
var mixpanel: StubMixpanelTracking!
// 2
var sut: AnalyticsService!
// 3
override func setUp() {
super.setUp()
mixpanel = StubMixpanelTracking()
sut = AnalyticsService(mixpanel: mixpanel)
}
// 4
func test_givenAnEventWithNameOnly_whenSendIsCalled_thenEventDetailsPassedToMixpanel() {
let event = AnalyticsEvent(name: "test_name")
sut.send(event)
XCTAssertEqual(mixpanel.events.count, 1)
guard case let .track(name, properties) = mixpanel.events.first else {
XCTFail("Expected track event")
return
}
XCTAssertEqual(name, "test_name")
XCTAssertNil(properties)
}
func test_givenAnEventWithNameAndProperties_whenSendIsCalled_thenEventDetailsPassedToMixpanel() {
let event = AnalyticsEvent(name: "test_name", properties: ["test_key": "test_value"])
sut.send(event)
XCTAssertEqual(mixpanel.events.count, 1)
guard case let .track(name, properties) = mixpanel.events.first else {
XCTFail("Expected track event")
return
}
XCTAssertEqual(name, "test_name")
XCTAssertEqual(properties as? [String : String], ["test_key": "test_value"])
}
}
mixpanelis our test-double that captures events. The property is force unwrapped, as each time a test runssetUp(), this property will be set, so we can't set it here, but we know it will always be there by the time the test is running.sut- system under test - is the service we're testing.setUp()creates fresh instances of both properties before every test, ensuring each test is independent and unaffected by any previous test's state.- Each test follows the same pattern: trigger an event on the service, use
guard caseto extract the captured values from the stub, then assert that the event's name and properties match what we expect. This consistent pattern makes it straightforward to add tests for new events.
With AnalyticsService fully tested, we can move on to the registries.
Much like AnalyticsService, we need to write a test-double to allow the registry's dependencies to be swapped out during testing:
protocol AnalyticsEventSending {
func send(_ event: AnalyticsEvent)
}
AnalyticsEventSending is a protocol that has just one method send(_:) that matches the method in AnalyticsService. To conform AnalyticsService to AnalyticsEventSending, we need to make one change:
class AnalyticsService: AnalyticsEventSending {
// Omitted unchanged functionality
}
Both registries now need to accept AnalyticsEventSending rather than AnalyticsService:
class FeedAnalyticalRegistry {
private let service: AnalyticsEventSending
init(service: AnalyticsEventSending) {
self.service = service
}
// Omitted unchanged functionality
}
class ProfileAnalyticalRegistry {
private let service: AnalyticsEventSending
init(service: AnalyticsEventSending) {
self.service = service
}
// Omitted unchanged functionality
}
With that change, we can now write a test-double conforming to AnalyticsEventSending:
class StubAnalyticsEventSending: AnalyticsEventSending {
enum Event {
case send(AnalyticsEvent)
}
private(set) var events = [Event]()
func send(_ event: AnalyticsEvent) {
events.append(.send(event))
}
}
StubAnalyticsEventSending follows the same pattern as StubMixpanelTracking.
We can now write registry unit tests, starting with FeedAnalyticalRegistry:
class FeedAnalyticalRegistryTests: XCTestCase {
var analyticsService: StubAnalyticsEventSending!
var sut: FeedAnalyticalRegistry!
override func setUp() {
super.setUp()
analyticsService = StubAnalyticsEventSending()
sut = FeedAnalyticalRegistry(service: analyticsService)
}
func test_whenSendPostOpenedEventIsCalled_thenEventDetailsAreCorrect() {
sut.sendPostOpenedEvent()
XCTAssertEqual(analyticsService.events.count, 1)
guard case let .send(event) = analyticsService.events.first else {
XCTFail("Expected send event")
return
}
XCTAssertEqual(event.name, "Post Opened")
XCTAssertNil(event.properties)
}
func test_givenLikedIsTrue_whenSendLikeEventIsCalled_thenEventDetailsAreCorrect() {
let liked = true
sut.sendLikeEvent(liked: liked)
XCTAssertEqual(analyticsService.events.count, 1)
guard case let .send(event) = analyticsService.events.first else {
XCTFail("Expected send event")
return
}
XCTAssertEqual(event.name, "Post Liked")
XCTAssertEqual(event.properties?["Liked"] as? Bool, liked)
}
func test_givenLikedIsFalse_whenSendLikeEventIsCalled_thenEventDetailsAreCorrect() {
let liked = false
sut.sendLikeEvent(liked: liked)
XCTAssertEqual(analyticsService.events.count, 1)
guard case let .send(event) = analyticsService.events.first else {
XCTFail("Expected send event")
return
}
XCTAssertEqual(event.name, "Post Liked")
XCTAssertEqual(event.properties?["Liked"] as? Bool, liked)
}
func test_givenSharedIsTrue_whenSendSharedEventIsCalled_thenEventDetailsAreCorrect() {
let shared = true
sut.sendSharedEvent(shared: shared)
XCTAssertEqual(analyticsService.events.count, 1)
guard case let .send(event) = analyticsService.events.first else {
XCTFail("Expected send event")
return
}
XCTAssertEqual(event.name, "Post Shared")
XCTAssertEqual(event.properties?["Shared"] as? Bool, shared)
}
func test_givenSharedIsFalse_whenSendSharedEventIsCalled_thenEventDetailsAreCorrect() {
let shared = false
sut.sendSharedEvent(shared: shared)
XCTAssertEqual(analyticsService.events.count, 1)
guard case let .send(event) = analyticsService.events.first else {
XCTFail("Expected send event")
return
}
XCTAssertEqual(event.name, "Post Shared")
XCTAssertEqual(event.properties?["Shared"] as? Bool, shared)
}
}
ProfileAnalyticalRegistryTests will follow the same pattern.
See the accompanying example project for the full listing.
As AnalyticsEvent doesn't contain any functionality, we will not write unit tests for it.
And that is everything we need for our analytic event tracking. Well done on making it here 💃.
☕️ Coffees?
The appetite for analytics only ever grows. Features get tracked, questions get asked, and new events get requested. With this approach, satisfying that appetite is just a method on a registry, a configured event, and a test that proves it all works.
Nothing scattered, nothing flying blind. Just answers there whenever Jeff needs them.
To see the above code snippets together in a working example, head over to the repository and clone the project.