Skip to main content

A modular analytics layer in Swift

Updated:

There is a saying, "if a tree falls in a forest and no one is around to hear it, does it make a sound?" Monitoring user behaviour is a bit like that; if people are using your app in a particular way, but you don't know about it, then how can you work out how to improve it.

There are many different options available to us in order to monitor user behaviour, such as user testing, collecting surveys and analytics. Here we are going to be discussing the topic of analytics, specifically using it within a Swift app.

When building and improving your apps, knowing how it is being used by real users in the wild can be very valuable. It allows you to make data-informed decisions rather than just estimating what you think is a good idea. You can also use it to verify and test out ideas once they have been released to your users.

Whilst adding analytics to an app can be quick and easy, ensuring the implementation is easy to use, alter and extend can be more complex. The implementation should be able to stand the test of time and give us the flexibility to make changes to the analytics data we are reporting in the future. We are going to look through one way of building an analytics layer in Swift, that is both modular and extensible.

Backend

When analytics data is captured within our apps, we would normally want to submit this to our preferred backend. This allows data to be collected over time and analysis to be ran over the data.

It is very common for this backend to be a third-party SDK such as: Firebase Analytics or MixPanel. You may also want to use a tool such as Segment, to control where the data is sent and have the flexibility to change the destination whenever you want.

Your backend may be an in-house analytics system, reporting data to your own backend API or pumping data into a data warehouse of some kind. This can be beneficial if your data is specialised or you need to do something with it that the third-party destinations don't support.

Whichever backend solution has been settled on, it is good to wrap its API in one of your own, to avoid the codebase being littered with a specific analytics API. Doing this will give you the flexibility to change the solution more easily and help make your code more maintainable.

Features

We are going to build a layer that avoids using a static API, puts any backend APIs behind a protocol, uses the power of Swift enums and pairs each event with the pieces of data it needs to contain.

More specifically it should:

  1. Be easy to report new and existing events.
  2. Allow the data be sent to whichever analytics backend we wish to use, even multiple.
  3. Be testable and mockable, allowing it to be an injected dependency.
  4. Allow events to be reported from anywhere in the app, but encourage best practices.
  5. Have compile-time safety of events and their properties.

There are a few different components that make up our analytics layer, each covering part of the process.

The main event

The events themselves can be modelled through our AnalyticsEvent enum.

enum AnalyticsEvent: Equatable {
    case addContactTapped
    case contactSelected(index: Int)
    case messageSent(threadType: ThreadType, length: Int)
    case userSignedIn
}

The enum contains a case for each event we want to fire, with associated values for any pieces of data that need to be attached to the event. When we report an event, we are required to provide any data it needs. By writing events in this way we can reduce the amount of code required to report an event, whilst checking for correctness at compile time.

When the event is passed further it provides the set of parameters that are attached to it. These parameters use an enum for their names, to allow them to be mapped differently later on, similar to the events themselves.

extension AnalyticsEvent {
    var parameters: [Parameter: String] {
      switch self {
      case .addContactTapped,
           .userSignedIn:
          return [:]
      case let .case contactSelected(index):
          return [
              .contactIndex: String(describing: index)
          ]
      case let .messageSent(threadType, length):
          return [
              .threadType: threadType.rawValue,
              .messageLength: String(describing: length)
          ]
      }
    }

    enum Parameter: String, Equatable {
        case contactIndex
        case messageLength
        case threadType
    }
}

For writing the switch statement over the AnalyticsEvent enum it is best to avoid using a default case, as this enforces that each new event is handled.

Providers

Each backend you wish to report data to will conform to our AnalyticsProvider protocol. This will document the different analytics operations we can perform.

protocol AnalyticsProvider {
    func report(event: AnalyticsEvent)
}

The report(event:) function is taking our event enum as an argument. This allows each provider to map the event to whichever format they need it in. For example, Firebase Analytics requires events to contain underscores (_) instead of hyphens (-). Once the event has been mapped, it would then be reported to the destination for that particular provider, often calling through to the provider's SDK.

A final solution may also report screens, user properties, preferences and more. These would be added to the AnalyticsProvider protocol.

Let's implement a provider

Making our backend conform to the provider protocol is relatively simple, let's start by building one for Firebase Analytics. The event name and parameters are mapped to strings, before being passed through to the Firebase Analytics SDK logEvent function.

class FirebaseAnalyticsProvider: AnalyticsProvider {
    private let eventMapper = AnalyticsEventMapper()

    func report(event: AnalyticsEvent) {
        let name = eventMapper.eventName(for: event)
        let parameters = eventMapper.parameters(for: event)
        Analytics.logEvent(name, parameters: parameters)
    }
}

The flexibility of the solution means we can easily build another provider to log events to the console.

class LoggingAnalyticsProvider: AnalyticsProvider {
    private let eventMapper = AnalyticsEventMapper()

    func report(event: AnalyticsEvent) {
        let name = eventMapper.eventName(for: event)
        print("Event reported: \(name)")
    }
}

We can create a provider for whichever destination we wish to use.

Get mapping

Each provider needs to convert the event enum into a reporting format. Most analytics SDKs require events in a string format and so it an be a good idea to provide a default mapper that does this.

If a particular app doesn't need the flexibility for each provider to control the mapping, we can instead return data in the correct format directly within the AnalyticsEvent.

For now we will give ourselves full flexibility, by using a separate mapper.

class AnalyticsEventMapper {
    func eventName(for event: AnalyticsEvent) -> String {
        ...
    }

    func parameters(for event: AnalyticsEvent) -> [String: String] {
        ...
    }
}

The first part to the mapping process is to get a name for the event. This is as simple as using a switch statement to map each event case to a String.

func eventName(for event: AnalyticsEvent) -> String {
    switch event {
    case .addContactTapped:
        return "addContact_tapped"
    case .contactSelected:
        return "contact_selected"
    case .messageSent:
        return "message_sent"
    case .userSignedIn:
        return "user_signedIn"
    }
}

The second part is getting a dictionary of event parameters.

func parameters(for event: AnalyticsEvent) -> [String: String] {
    var newParameters = [String: String]()
    event.parameters.forEach { key, value in
        let newKey = parameterName(for: key)
        newParameters[newKey] = value
    }
    return newParameters
}

func parameterName(for parameter: AnalyticsEvent.Parameter) -> String {
    switch parameter {
    case .contactIndex:
        return "contact_index"
    case .messageLength:
        return "message_length"
    case .threadType:
        return "thread_type"
    }
}

The parameters(for:) function is simply converting the dictionary keys from an enum to a String. If used in multiple places, it can be easily extracted to a generic extension on Dictionary.

Putting it all together

We now need to bring together all of the components we have built and create the API for our analytics layer, the AnalyticsReporter. The reporter is the dependency you will be injecting and using throughout your app to report events.

class AnalyticsReporter {
    private let providers: [AnalyticsProvider]

    init(providers: [AnalyticsProvider]) {
        self.providers = providers
    }

    func report(event: AnalyticsEvent) {
        providers.forEach {
            $0.report(event: event)
        }
    }
}

This reporter allows multiple analytics providers to be used, obviously if an app only required a single provider we could decide whether this was worth supporting or not. Due to each analytics backend being wrapped with our provider, the reporter is pretty simple.

As with the provider, a final solution may also include reporting of other entities such as screens and user properties.

Time to use it

We can now put our implementation into practice to see how it is to use.

class ContactsViewModel {
    private let coordinator: ContactsCoordinator
    private let analyticsReporter: AnalyticsReporter

    init(coordinator: ContactsCoordinator, analyticsReporter: AnalyticsReporter) {
        self.coordinator = coordinator
        self.analyticsReporter = analyticsReporter
    }

    func contactSelected(at index: Int) {
        analyticsReporter.report(event: .contactSelected(index: index))
        coordinator.startContactThreads(at: index)
    }

    func addContactTapped() {
        analyticsReporter.report(event: .addContactTapped)
        coordinator.startContactCreation()
    }
}

The AnalyticsReporter can be injected wherever we wish to use it, in the case above in a ContactsViewModel. Using the API is as simple as selecting the correct event and providing any values it requires.

If we need to report a new event, we just need to add it to the AnalyticsEvent enum, provide its parameters and update any mapping code. ⚒

Testing

It is very important that our analytics code be testable and that it doesn't make any other code more difficult to test. To verify the correct events have been reported, we can create a TestingAnalyticsProvider within our unit test sources. The test provider stores reported events so that they can be asserted on afterwards.

class TestingAnalyticsProvider: AnalyticsProvider {
    var eventsReported = [AnalyticsEvent]()

    func report(event: AnalyticsEvent) {
        eventsReported.append(event)
    }
}

Wrap up

That wraps up implementing, using and testing our new analytics layer. In general the approach we have put forward is flexible and can be altered where necessary to suit a particular app's needs.

We have seen that:

  • Our implementation uses a modular approach that should minimise the impact analytics has on the rest of the app.
  • It is easy to configure and report both new and existing events.
  • Each destination is wrapped in a provider protocol, giving the flexibility to support any backend we wish to use, including a test one.
  • By avoiding a static API, we encourage the reporter dependency to be instantiated or provided via dependency injection, whilst it still being easy to do so.
  • The use of an enum for the event type enforces compile-time safety for events and the properties they each require.

I hope the article was useful. If you have any feedback or questions please feel free to reach out.

Thanks for reading!

Like what you read? Please share the article.

Avatar of Andrew Lord

WRITTEN BY

Andrew Lord

A software developer and tech leader from the UK. Writing articles that focus on all aspects of Android and iOS development using Kotlin and Swift.