It is common for SaaS apps to have a lot of analytics code distributed across multiple layers. A couple of years back, at WeTransfer, we tried out different providers to see what was best suited for understanding our user behaviour. This led to the swapping of varying analytics providers in the code, GDPR complaints, etc. At a point, we settled with two providers, one used by the data team and another by the product team. Both had different types of data payload and needs. For example, the data team wanted to understand the business more, and the product team wanted to understand features more. Now, on top of this, we, developers had our own realtime logging and tracking system to measure the crashes, ANRs, performance tracing of the core features to measure objectives and SLOs. So if you think about it, we have three different types of tracking/tracing system in the codebase that contributed to significant amount of lines of code in the codebase that was quite equivalent to the feature logic.
I’ve always wondered where the right place to add tracking is. Someone asked this question at one conference, and the subject matter expert’s answer was quite interesting—”all layers.” Tracking code can sit at any layer (business, UI, or data), depending on what we track, and it is true if you think about it.
So imagine a codebase where three different tracking systems propagate different events sitting at all layers. We, developers faced some challenges in managing the codebase
- When someone from the data or product team asks where this particular event is triggered or if we need to make changes to an existing event, identifying and debugging was quite hard
- The tracking logic did not have a boundary to define as it was sitting at the Compose (UI) level, ViewModel, UseCase, Repository, etc.
- Since it lacked the boundary, we couldn’t agree on the one way or the right way to add this logic. Some were writing separate use cases, and some advocated at the ViewModel level, and the rabbit hole continued.
- Over a while, the analytics logic influenced how we built features – technically, we would build one component and reuse it as much as possible. Still, from the analytics perspective, every component reuse is a separate entity, as each measures the performance of a different state or feature. For example, let us consider an email app where we show a list of emails in the Inbox list. We would use the same component for the Sent list, but the analytics would see it as two different features to measure. So, we need to accommodate this component to accept different payloads based on the state it represents. This added more lines of code than it was supposed to.
Going back to the drawing board
My colleague Irena and I returned to the drawing board to conquer this chaos. First, we wondered if we could create a way of working for the team to address this, but soon, we realised that we might need separate APIs to declutter the codebase effectively. To resolve our problem, we drafted the objective of the API
- Developers should be able to visually see the number of events emitted in a particular screen or feature
- Developers should be able to swap out different providers without touching all the layers
- A common API to initiate tracking and the manager should be smart enough to route to different providers
Here’s the high-level API design we came up with
// base interface
interface AnalyticsEvent
// analytics provider 1
// can create different variants by extending Provider1
interface Provider1Event: AnalyticsEvent {
val category: String
val action: String
val property: String?
}
// analytics provider 2
// can create different variants by extending Provider2
interface Provider2Event: AnalyticsEvent {
val eventName: String
}
// event definition
class SomeClickedEvent(): Provider2Event {
override val eventName = "some_clicked_event"
}
// call site, how we want all layers to invoke
analyticsManager.track(SomeClickedEvent())
KotlinSome key highlights
- By creating different small classes for each events, we can identify the number of events available in a module or screen or feature (based on how you package them)
- Having a unified one call method at the invocation decluttered quite some code but if both analytics provider have the same call site, we need to invoke this method twice by passing different event. We were okay for now with this solution and decided to optimise later if needed (fast track now, we did not need this optimization as each provider was quite unique)
Tailoring Events to specific types
While the high level design looks promising, we had a couple of challenges to solve. One, for simple events like the click events there were no additional payloads or properties. But for more custom events that needs more state and properties, we converted the classes to data classes to host additional properties
data class someCustomEvent(
val action: String,
val context: String
): Provider2Event {
override val eventName = "some_clicked_event"
}
// analytics tracker
interface AnalyticsTracker<EventType> {
fun <T : EventType> trackEvent(event: T)
}
// provider 1 implementation
class Provider2Tracker: AnalyticsEvent<Provider2Event> {
override fun <T : Provider2Event> trackEvent(event: T) {
// map to provider event and track
}
}
KotlinAvoiding object mappers
To avoid writing a lot of object mappers from the event objects to the tracker payload, we leveraged the concept of Serialization. By using Moshi serializer, we mapped the data class or class properties to the respective map structure or json – depending upon the needs of the analytics provider
@JsonClass(generateAdapter = true)
data class someCustomEvent(
@Json(name = "Action") val action: String,
@Json(name = "Context") val context: String
): Provider2Event {
override val eventName = "some_clicked_event"
}
KotlinFinal thoughts
Recognizing the need for a more structured approach, we returned to the drawing board with clear objectives: to create an API that simplifies the tracking process, allows for easy swapping of providers, and provides a visual representation of events in the code. By designing small, modular classes for each event and unifying the call method, we significantly decluttered our codebase. Although this approach required some trade-offs, such as invoking the method twice for different providers, it proved to be an effective solution that met our immediate needs.
To handle more complex events requiring additional state and properties, we leveraged Moshi serialization, we avoided the need for cumbersome object mappers, further reducing the complexity of our code.
In conclusion, our approach not only brought order to the chaos but also made the codebase more maintainable and scalable. While the journey had its challenges, the solutions we implemented allowed us to manage our tracking systems more efficiently, ultimately contributing to a more robust and adaptable application.
Thank you for taking the time to read about our experience. We hope it provides valuable insights into managing analytics in complex codebases.
Special thanks to Irena for collaborating on designing the API