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.
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
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
TouchableHighlight. For the purposes of this post, we’ll be focusing on
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
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
- That object is passed to
- There’s a
Touchableobject inside the
- The method is
touchableHandlePress, which is passed in as the
onClickprop for the rendered
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
ObjectPropertywith a key name of
- There’s a
CallExpressionwhere the callee name is
- There’s an
ObjectPropertywith a key name of
mixins, and within that subtree, there’s an identifier of
- There’s a
VariableDeclaratorwith 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
ObjectPropertynode where the key name is
- That node has a parent that is a
CallExpressionwith a callee name of
- The node has a sibling
ObjectPropertynode with the following properties:
- Has a key name of
- Has a value that is of type
- Contains an identifier with name
- Has a key name of
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
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
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
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
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
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
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
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.