[Design] Solving for multiple events in mobile Applications

Kanav Arora
Urban Company – Engineering
5 min readNov 19, 2018

As mobile applications scale, there are third party assets which might want to track client behaviour. These requirements usually come from product managers/marketeers who are business heavy in their specifications but are implemented by developers who have a technology bias supported by existing engineering stack and infrastructure choices. To bridge this gap is a difficult and complex problem. This article aims to provide a better design to address it.

Initial Approach:

It's easier to work with examples, so let's say we have a minimal checkout application providing only two capabilities.

  1. Loading the page.
  2. Checking out with a simple click.

Let’s further assume that few functionaries within the company are interested in tracking the above events:

  1. Data Team: via in house analytics server for building data based systems.
  2. Product: via a tool like Mixpanel for funnel analysis.
  3. Marketing: via Appsflyer for user attributions.

A rudimentary approach would be to define the events for each destination in the main controller code itself:

function onCheckoutPageViewed () {   // api call to backend to fetch checkout info
ApiCall.getCheckoutInfo();
// send event to your in-house data server
DataServer.sendEvent({"event_name": "page_viewed",
"page_name": "checkout"});
// send event to MixPanel for funnel analysis
Mixpanel.sendEvent("checkoutPageViewed");
}function onCheckoutButtonClicked (orderId) { // api call to backend to place order
ApiCall.placeOrder(orderId);
// send event to your in-house data server
DataServer.sendEvent({"event_name": "order_placed",
"id": orderId});
// send event to MixPanel for funnel analysis
Mixpanel.sendEvent("orderPlaced");
// send this event to AppsFlyer for attribution.
AppsFlyer.trackEvent("checkout",
{"orderId": orderId});
}

The immediate observations / problems with the above approach:

  1. In main controller, you have all the logic for sending events: who to send events to, what properties/fields each destination requires. This distracts a developer from the real stuff happening i.e. actual api call to backend to place an order or get checkout information.
  2. Events and respective schemas for each destination normally come from PM/Marketing, but implemented by developers, who sometimes have little to no context on how they are going to be consumed. This often leads to mis-communication and wrong events/properties being sent, which leads to complications downstream.
  3. If you as developer want to debug an event to a certain destination, you would have to find the logic of where that event is being triggered from and check the logic there which is time consuming.
  4. There are no checks and balances in place, and correctness of events can only be tested manually.

Needless to say, the existing way is broken at a design level at multiple points.

Proposed Solution:

A few concepts:

Trigger: Any event that happens in the app. Eg button click, page viewed, any user action. A Trigger is associated with a unique identifier and a key-value pair of all the properties that further define the event.

Channel: A destination (third party or even in-house) which wants to consume any set number of triggers. Eg: Mixpanel, AppsFlyer, your own analytics server, GoogleAnalytics.

Event: An event is described as cross product of [Trigger X Channel]. i.e. an event fired to a specific channel for a specific trigger/action on the app.

So a proposed solution could be like this:

  1. The code for firing triggers should be as simple as this:
function onCheckoutPageViewed () {
ApiCall.getCheckoutInfo();
TriggersManager.trigger("checkoutPageViewed");
}function onCheckoutButtonClicked (orderId) {
ApiCall.placeOrder(orderId);
TriggersManager.trigger("checkoutButtonClicked",
{order_id: orderId});
}

`TriggersManager` is a library (whose implementation we will talk later) that abstracts out the actual logic of sending a defined event to required channels.

2. PM/Marketing/Other stakeholders should define in a non code format (like a csv file) the triggers they are interested in and the various properties they are interested in receiving. For example:

Data Server Config
+-----------------------+-------------+-----------+--------------+
| trigger | event_name | page_name | id |
+-----------------------+-------------+-----------+--------------+
| checkoutPageViewed | page_viewed | checkout | |
| checkoutButtonClicked | order_placed| | devToProvide |
+-----------------------+-------------+-----------+--------------+
MixPanel Config
+-----------------------+--------------------+
| trigger | event |
+-----------------------+--------------------+
| checkoutPageViewed | checkoutPageViewed |
| checkoutButtonClicked | orderPlaced |
+-----------------------+--------------------+
AppsFlyer Config (only needs to consume one trigger)
+-----------------------+------------+--------------+
| trigger | eventName | orderId |
+-----------------------+------------+--------------+
| checkoutButtonClicked | checkout | devToProvide |
+-----------------------+------------+--------------+

Note: Here, `devToProvide` tag indicates that this field is dynamic and needs to be provided by the developer.

3. Now the developer creates a file for each Channel, and writes the transformations for each Trigger which will correctly send the event to that channel. We only need the transformations for those properties that are dynamic and need to be provided by the developer. For example, for the above case:

// Data Server transformation
{
"checkoutPageViewed" : {},
"checkoutButtonClicked" : {"order_id": "id"}
}
// Mixpanel transformation
{
"checkoutPageViewed" : {},
"checkoutButtonClicked" : {}
}
// AppsFlyer transformation
{
"checkoutButtonClicked" : {"order_id": "orderId"}
}

4. Finally, you need to wrap implementation of all channels in a defined way. Easiest way is to have each channel extend an interface to send the event in their own way.

Interface ChannelInterface
function sendEvent(eventDict);
DataServerChannel<ChannelInterface>
function sendEvent(eventDict) {
DataServer.sendEvent(eventDict);
}
MixpanelChannel<ChannelInterface>
function sendEvent(eventDict) {
Mixpanel.sendEvent(eventDict["event"]);
}
AppsFlyerChannel<ChannelInterface>
function sendEvent(eventDict) {
let eventName = eventDict.remove("eventName");
AppsFlyer.trackEvent(eventName, eventDict);
}

Note that the `eventDict` passed to each Channel is as per its own schema as defined in the csv and `TriggerManager` does the appropriate transformation.

Advantages:

  1. Non dev stakeholders are able to define the events they want to send for their specific channel. They are able to specify the properties which they want to be sent with each event, and even define some of the values themselves. Developers only need to expose the list of triggers.
  2. The code during the logic flow becomes very clear. During coding a certain flow, developers only care about firing the right trigger at the right place with all the properties that could possibly be used by downstream Channel clients.
  3. You get some base level validation of events in code itself. When an event is fired for a specific channel, we can check there itself whether all the required properties are being filled or not. This type of work otherwise has to be done manually.
  4. Different clients (ios, android, web) could share the same csv. This further provides consistency among multiple clients.
  5. Adding a new Channel becomes pretty convenient. You have to get a new csv created which lists the interested triggers, create a transformation file for the same, and extend the ChannelInterface. This is much easier as opposed to searching the whole code for figuring out the right places to plug in the event firing for the new channel.

Shortcomings/Further Improvements:

  1. Whether a trigger is fired at the right place by the developer, or even fired at all is something which we can't validate. Automation/BlackBox Testing can prevent against this.
  2. You can't guarantee the values of the properties being sent with each event are correct. Once again automation and adding more features to the csv schema can help. For example, if you want a certain boolean property to have just true and false as values, we should be able to specify it in the csv itself as a predefined schema extension of `devToProvide` notion.

Implementation

The Engineering team at Urbanclap have implemented and open sourced these libraries. The iOS and Android counterparts are also provided, following the same design patterns earlier proposed.

We have been using this system in our production apps for a little over 2 years now. Currently, this system is generating 100 triggers across 5 different channel clients and 4 mobile apps. Needless to say, this has streamlined our events process, provided reliability into events and eased the communication between developers and stakeholders.

Finally, we at Urbanclap would love to hear about your own solution to event handling problem. Please feel free to reach out to us or comment directly in reply to this post.

--

--

Kanav Arora
Urban Company – Engineering

VP Engineering at UrbanClap(urbanclap.com). Traveller, Pun-ctual, Philosopher in Life.