Declarative analytics: gAnalytics

Michael Spitsin
8 min readJun 20, 2017

--

Yet another way to handle analytics in app

What distinguishes the assembly line and programmer. Yes, many things, including the main one: they work for different purposes. The each mechanism in assembly line created for one single task and it does that task over and over again. Every day, every month. The assembly line, in general, performs set of such tasks and repeats over and over again, producing an incredible amount of final product (or intermediate one).

As opposed to that, programmer behaves differently. Starting to write a code, that he wrote already before, he begins to feel a tiny discomfort deep inside. And every time he repeats itself, his inner disturbance grows, until at some point of time !BOOM! and he starts to write the code, that does machine job for him. It can be appeared differently and requires more or less time to decide to do that. Most simple way is to move common blocks to another functions, common behavior to another classes and etc. More hard ways include some kind of metaprogramming and code generation. But the main goal is one: reduce the time of development. And in that case the DRY principle is one of the primary allies for every programmer.

About Analytics in Apps

This kind of area is not such “boilerplated”, as other fields in android apps development, but by the certain coincidence, I decided to spend my (and your) time to talk about that.

During my short period of application development I’ve faced with that type of code over and over again:

Here is an example with 4 functions, but imagine 20 classes with 4–14 methods in each. And in every method you will have the same line over and over again:

ServiceLocator.getInstance().getAnalytics().track(CATEGORY, "some_string");

Okey, you can declare a private static final Analytics analytics and put ServiceLocator.getInstance().getAnalytics() here. Then you can declare a new method track:

public static void track(String action) {
ServiceLocator.getInstance().getAnalytics().track(CATEGORY, action);
}

Then in above example’s case, you will have next result:

Yes, we have (in average) fewer symbols in each line, but now we have one additional method and one additional field. Now imagine that some of our beginning methods have to pass a label in analytics method, so you will have to create at least one additional method:

public static void track(String action, String label) {
analytics.track(CATEGORY, action, label);
}

And now, let’s imagine that we have 20 Analytic classes. And in each, we need to create 1–2 additional methods and one field. Isn’t that a boilerplate? Moreover, we have repeatings in name of class, Category, names of methods and actions. So we need to resolve it somehow.

The last thing is that we, of course, can have one method in whole application (example with Google Analytics):

public static void track(String category, String action, String label) {
tracker.send(HitBuilders.EventBuilder()
.setCategory(category)
.setAction(action)
.setLabel(label)
.build());
}

And then just use it everywhere with passing hardcoded strings. But this approach has many disadvantages, that why it is better to have all analytics in one explicit place and get access to it throw special restricted methods.

So, let’s recap above text: we need to find a way to eliminate aforementioned boilerplate and provide a more convenient way to work with analytics.

Declarative solution (Proxy approach)

What if I told you, that you could have above example described in that way (written in Kotlin):

@HasPrefix(splitter = "_")
interface AnalyticsCandyShop {
fun near()
fun opened()
fun closed()
fun sell()
}

Looks nice, isn’t it? You will have only interfaces with such methods and then, the system will create somehow their implementations and will give them to you. From my point of view less ‘boilerplatable’ will be only if you declare all your analytics in some text file and then the system will generate code for you.

So, how can we achieve that solution? The answer is simple. We need to instantiate a proxy, that will implement our interface and then apply all our annotations or conversions to it. Here is a simple snippet of code:

interface AnalyticsWrapper {
fun <T : Any> create(kClass: KClass<T>) = create(kClass.java)
fun <T : Any> create(clazz: Class<T>): T
}

class AnalyticsSingleWrapper : AnalyticsWrapper {
@Suppress("unchecked_cast")
override fun <T : Any> create(clazz: Class<T>): T {
return Proxy.newProxyInstance(clazz.classLoader, arrayOf<Class<*>>(clazz)) { proxy, method, args ->
//do processing annotations, making conversions
//or
//some other work !HERE!
} as T
}
}

That’s all you need. It is a core thing for our solution. Same approach use, for example, Retrofit (here is a link to the same snippet).

Next, we want to be able to receive an event and then pass our actions to any analytics library. So to do that we need to pass an special EventProvider to our AnalyticsWrapper and then construct an Event inside it:

interface EventProvider {
fun provide(event: Event)
}
class AnalyticsSingleWrapper(private val eventProvider: EventProvider) : AnalyticsWrapper {
@Suppress("unchecked_cast")
override fun <T : Any> create(clazz: Class<T>): T {
return Proxy.newProxyInstance(clazz.classLoader, arrayOf<Class<*>>(clazz)) { proxy, method, args ->
//build here category, action, label and value
val event = Event(category, action, label, value)
eventProvider.provide(event)
} as T
}

Here freedom is started. You can create any annotations that you want; group your analytics in an interface, that will return interfaces; add caching mechanism; provide a settings or configuration for default behavior. Some of that done in my library, but I’ve not published it since it is not finished. But I’ve done some work and will describe to you, what annotations I’ve added and how to use them.

If you will like it, please, like this post, or star my project, and I will know, that my work is interesting not only just for me :)

Annotations

Here I’ve added a description for some annotations of whole list. Currently, I have five annotations. So let’s go through all of them:

@Category (Scope: Class)

When you need to explicitly define a category, that will be used for the interface you need to annotate it with @Category annotation. For example, interface and category have completely different names, or they use different naming conventions (handling conventions will be added later). Then that annotation will help you.

@Action (Scope: Method)

Same thing as @Category, but used for methods. Methods in our approach are actions for analytics, so their names may be different or, as I told before, use different naming conventions.Then you need to annotate a method with @Action annotation.

@Label (Scope: Parameter)

When you need to add label into Analytics, you can define a parameter in requested method and mark it with @Label annotation. Also, you can just pass a parameter without an annotation. The system will know, that it is a label. Also, you may specify a LabelConverter, that is needed when you pass a complex object and you want to explicitly define how to transform it to label string. By default, this annotation uses simple converter that calls toString method for every object (in future I can add a more complex behavior, for example, for enums call name instead of toString).

I have no @Value annotation because for now, it is pointless. We know, that it have to be Number, so you just need to pass a Number parameter in the requested method and the system will understand that this is a value. You can check test cases for that to know more.

@HasPrefix (Scope: Class/Method)

In some cases, as my first example, actions for analytics use a name of category as their prefix. For example, you have category candyshop and action candyshopopened. For such cases, it isn’t necessary to write fun candyshopopened() for your analytic’s interface. You can write @HasPrefix fun opened(). And if the whole interface has such action, then you can write:

@HasPrefix
interface CandyShop() {
fun opened()
}

And that’s all. Also @HasPrefix annotation support defining a special splitter and explicit prefix name. For example:

@HasPrefix(name = "cs", splitter = "_-_")
interface CandyShop() {
fun opened()
}

Will give you an action cs_-_opened instead of candyshopopened.

@NoPrefix (Scope: Class/Method)

In case you using @HasPrefix annotation for your interface, but actually, some of the methods don’t need it, you can annotate them with @NoPrefix annotation. For example:

@HasPrefix
interface SomeCategory {
fun action1()
@NoPrefix fun action2()
}

Will produce next two events:

  1. Category: somecategory, Action: somecategoryaction1
  2. Category: somecategory, Action: action2

Currently using this annotation for an interface is pretty useless, since it is the default behavior. But in future a will add setting up a defaults, so this annotation may be beneficial in next stages.

Default behaviour

I’ve made some effort to create a smart enough system.

  1. When you name your interface with analytics prefix, it will no be counted for the output category. For example, interface AnalyticsCandyShop will produce a category named candyshop. If (for some unknown reason) you want to use that prefix in output category, you need to specify name explicitly through @Category annotation. Explicit annotation for exactly that case would be clumsy.
  2. When a method has only one parameter, it is automatically be a Label for your event. So defining a @Label annotation would be redundant in that case. Still, you can do it, if, for example, you have a custom converter that you need to use for the label.
  3. When a method has two parameters and one of them is Number, then it is automatically a Value and another parameter will be a Label. If both parameters are Number then first will be a Label and second a Value.
  4. If method will have two parameters and only one will be a Number and it will be annotated with @Label. Then you will have an exception, since, in the case of two method parameters, one of them have to be a Number without @Label annotation. If you will have no numbers in two parameters, then you will have the similar exception. And if you will have more than two parameters in the method, then you will have an exception, because you allowed to have up to two parameters, no more.

Future

That’s all I’ve got for now. In future a want to add a few major things to that kind of library:

  1. Naming Conventions. Currently, by default interfaces have a lowercase convention and methods lowerCamelCase convention. Of course, it is not cover the most cases. Often you need a snake_case, often CamelCase. And we need to add a special annotation for that (not just for the interface but for global library settings). Or in most cases you methods and interfaces will be boilerplated by redundant @Action and @Category annotations.
  2. Global settings. For example, all your analytics uses prefixes for action, so instead of adding to each interface @HasPrefix annotation, it will be better to define somewhere a default behavior and that’s all.

I thing that will reduce writing a code more.

Conclusion

Currently, I have no version of this library, since it is in alpha, and I think it is not needed so strong. I just wanted to show you guys, how you can organize sending analytics in your application. If you want a detailed example of my approach and some version of the library available through JCenter, then, please, let me know about that. And I will add it as fast as I can.

Thank you all for reading. Like, subscribe and also I will really appreciate if you share your thoughts about that kind of approach.

P.S.: Link to the my approach is here.

--

--

Michael Spitsin
Michael Spitsin

Written by Michael Spitsin

Love being creative to solve some problems with an simple and elegant ways

No responses yet