Using Babel Transforms to Inject Analytics Code in React Native Apps

In late 2018, we decided to add first-class support for React Native in Heap. This meant bringing Heap’s autocapture philosophy to the React Native platform: installing Heap on a React Native app should mean that all user interactions with the app are captured. This includes taps, changes to text fields, and more.

This post will look at how we did this: adding custom code as part of the app build process. We’ll talk about abstract syntax trees, how we built a Babel plugin to inject code into React Native, and some of the tools we used along the way.

Why code injection?

On the web, Heap captures all user behavior on a siteclicks, etc. by adding an onclick event listener to the entire DOM. On iOS, Heap installs custom implementations of a few key UIKit APIs via method swizzling. But React Native doesn’t provide any sort of global hook we can use to autocapture user interactions.

An easy way to make autocapture work would be to create our own fork of the React Native repo, and release a package with the custom code changes. This would be a large maintenance burden, however, since we’d need to release a new React Native Heap SDK version every time Facebook releases a new React Native version (not just when that particular piece of React Native code changes). More generally, this would be a bad developer experience.

After brainstorming, we came up with a better idea: inject code into React Native at build time. The React Native Metro Bundler bundles Javascript code for almost all React Native apps, since it is the default Javascript bundler for the framework. For syntax features like JSX, the Metro Bundler uses Babel, a source-to-source compiler that transforms things like JSX and experimental language features into plain Javascript.

Abstract Syntax Trees and ASTExplorer

Babel operates on Abstract Syntax Trees internally to perform its compilation. An Abstract Syntax Tree (AST) is a representation of the syntactic structure of source code in the form of a tree. Lots of tools that work with code use ASTs: compilers, interpreters, linters, and formatters. For example, the code formatter Prettier auto-formats source code by parsing the code into an AST, then re-printing the AST in a predefined style.

When looking at and exploring ASTs, we found ASTExplorer.net to be especially useful. It allows you to paste in source code and select the config used for the source code (language, parser, transforms) and it will generate the AST for that code, and, for transforms, show the resultant code.

We’ll be using ASTExplorer often in this post as we show how we built the solution for Heap.

Using Babel to modify code

On its own, Babel doesn’t do anything;  it’s effectively same code in -> same code out. To make it do anything, we need plugins, which configure Babel to perform operations against the AST in a specific way. Babel exposes a number of APIs that allow the user to traverse and modify AST nodes.

You can either use existing plugins, or write your own. For example, the existing exponentiation-operator plugin takes code that looks like this:

and makes it look like this:

Babel transforms use a visitor pattern. When Babel visits a node of a specific type, Babel calls the corresponding function provided for that node type. Babel passes a path object (the representation of the path to the visited node) to this function to allow for accessing the visited node.

For the exponentiation operator example, we can transform binary expressions that look like x ** y into code that looks like Math.pow(x, y) by implementing a function for BinaryExpression nodes:

Now that we have the basic tools we need to modify source code’s AST using Babel, let’s inject some instrumentation code.

So we want to inject some code – but where?

The most basic interaction we can capture is a touch on a Touchable component. These are components that users can interact with by touching on them. Examples include TouchableOpacity, TouchableNativeFeedback, and TouchableHighlight. For the purposes of this post, we’ll be focusing on TouchableOpacitys.

If you were manually tagging a Touchable component, you’d probably add some code that looks like analytics.track(‘touched button’) to the onPress handler for that Touchable. For example:

We don’t want to inject instrumentation into app code (like the onPress handler we added tracking code to above), since code structure can vary widely between apps, and we don’t have visibility into what the code would actually look like.

Instead, we want to find a spot in the React Native library that will always fire when a TouchableOpacity is touched. A good spot is probably where onPress is called within the TouchableOpacity component:

Check out the React Native source code here.

Now that we know where we want to inject our instrumentation code, let’s write some code to do that.

Writing the Babel Plugin

So we want to inject some code into this particular method, but how do we programmatically identify this method as the right spot?  Sure, it calls onPress, but we can’t just instrument all functions that call another function called onPress. Let’s look at a bit more of the surrounding code:

Using the context, we can pull out a few landmarks that tell us this is where we want to instrument:

  • It’s a function inside an object assigned to a var called TouchableOpacity.
  • That object is passed to createReactClass
  • There’s a Touchable object inside the mixins array
  • The method is touchableHandlePress, which is passed in as the onClick prop for the rendered Animated.View.

Let’s transfer these landmarks over to the AST for this component.

First, we’ll copy the source file contents into AST Explorer:

 

For simplicity, let’s remove some of the irrelevant lines of code, like other methods, comments, and imports:

Now we have this AST:

Let’s identify the parts of the AST that correspond to each bullet point for our reasoning:

  • There’s an ObjectProperty  with a key name of touchableHandlePress
  • There’s a CallExpression where the callee name is createReactClass
  • There’s an ObjectProperty with a key name of mixins, and within that subtree, there’s an identifier of Touchable
  • There’s a VariableDeclarator with an id name of TouchableOpacity. However, since we want to eventually apply our solution to other Touchables, we’ll ignore this.

While each of these is relevant, the target node for instrumentation is the touchableHandlePress function. Let’s rephrase these AST features to relate to this node:

  • We’re looking for an ObjectProperty node where the key name is touchableHandlePress
  • That node has a parent that is a CallExpression with a callee name of createReactClass
  • The node has a sibling ObjectProperty node with the following properties:
    • Has a key name of mixins
    • Has a value that is of type ArrayExpression
    • Contains an identifier with name Touchable within that ArrayExpression

As you might be thinking, this approach is a bit of a heuristic. Code in the React Native library can and does change, and we do occasionally need to update our plugin to handle these code changes. Similarly, if non-React Native code matches the AST pattern our plugin is looking for, we would potentially instrument this code, too, though this is unlikely.

Now that we know what pattern to look for in the AST, let’s write some code to find our target node.

Let’s start by adding a method to a basic Babel transform visitor. In our case, we’re looking for an ObjectProperty node, so let’s start with a function that executes for all ObjectPropertys:

Next, we know that the node we’re looking for has a key name of touchableHandlePress, so let’s add a conditional that checks this:

Next, we want to see if the node has a parent that’s a CallExpression with a callee name of createReactClass. We can do this using the findParent method on the Babel path:

Finally, we want to check if this node has a sibling with the Touchable mixin. Let’s implement this logic in a helper.

We can access an array of node siblings in the paths container field. We can search this array for the mixins node by checking if the node is of type ObjectProperty, and has key name mixins and value type ArrayExpression.

Once we find the mixins node, we need to check if it contains a Touchable identifier. We can do this by traversing the node subtree by calling traverse with another babel visitor, and extract some state:

See the source code for the solution up to this point here.

Injecting the Code

We now know the current node is where we need to inject code. So let’s inject our instrumentation.

We want to create a new function that:

  • Calls the Heap library with event metadata
  • Calls the original function

We’ll be using the babel-types package to create new AST nodes for our instrumentation:

Let’s start by wrapping the original function, and calling it. If we were writing code normally, we could call a function object by calling the function’s call property:

Let’s do that for this function. We’ll start by building a member expression (i.e. accessing the call property), and then call that expression with this and the event  argument:

Next, let’s build out the code to inject. We could create this CallExpression by creating a number of AST nodes, but for simplicity and readability, let’s use Babel templating to do this:

Now let’s put it all together. We’ll use templating for this, too:

Lastly, let’s build a new function from the function body we’ve just created:

Now, we have a new function that wraps the original function, and contains some instrumentation code, but it’s not actually part of the AST yet – it’s just a new AST we’ve created. We need to use this new function to replace the old function:

And that’s it!  We’ve replaced the original function with an equivalent function with our instrumentation code. Check out the complete plugin here.

Testing it out

Now that we’ve written the plugin code, let’s test it out. We can start by running this transform against the TouchableOpacity.js file in the React Native library. This is what that file looks like with no transformation:

Let’s run this file through default plugins (i.e. the plugins included in the module:metro-react-native-babel-preset preset) and our plugin. We can do this with the Babel CLI:

This should output the following:

Looks like it works!  Let’s implement the instrumentation handler and run the app:

Check out the full solution here.

From here, we can extract metadata from this (which represents the component the user touched) and e (the event the interaction triggered) to create and send a raw event we can use later for analysis.

Conclusion / Wrapping it up

As we’ve seen, Babel plugins can be powerful. You could apply the approach we discussed today to things like application performance instrumentation, like automatically timing your onPress handlers. Or maybe you want to build a plugin to create some new Javascript syntax, like exponentiation. Or you could develop your own ESLint rule. Or, if you need to automate a large-scale code change, such as using a new API, automating fixes for breaking changes after upgrading a dependency, or a large refactor, you can use tools like jscodeshift and codemode-js to use babel transforms to update an entire codebase.

If you’re looking to build a plugin of your own, or want to get a little bit more in-depth about writing Babel plugins, be sure to check out the Babel Plugin Handbook. This resource was invaluable when I was learning Babel.