www.fgks.org   »   [go: up one dir, main page]

Open Source: Declarative Tracking for React Apps

Jeremy Gayed
NYT Open
Published in
7 min readJul 13, 2017

--

Illustration by Andrei Kallaur/The New York Times

The core product teams of The New York Times are investing heavily in React.js and we’re building out a completely new web platform for all of our products. As part of this, we had the rare opportunity to rethink how we do many things, including data fetching, application bundling, ads, tracking and analytics.

Traditionally, the tracking and analytics concerns of our app were “bolted on” after the fact. That is, we’d often use the DOM as the source of truth for the existence and interaction of various elements on the page. This was not only brittle, but it was very difficult to do without exposing non-semantic data necessary for the analytic modules to see and attach. In some cases, it was impossible and we had to put analytic-specific code at seemingly random places within our app.

We saw this re-platform effort as a great opportunity to “bake in” analytics and tracking within the framework we were building. We wanted to move away from the DOM as the source of truth and instead use the components themselves to signal interactions of interest. This also allowed us to use the app hierarchy itself to establish the contextual awareness tracking events often needed.

TL;DR

Our solution ended up with the development of react-tracking, which provides a rich, declarative API to embed tracking information within your app. We’ve now open sourced this library.

react-tracking has allowed us to move away from CSS-selecting items of interest and attaching handlers. Instead, we can decorate the handlers themselves with the pertinent tracking data.

How it works

In a nutshell, this is how react-tracking works:

  1. You define a top level dispatch() function that determines where your tracking objects go. If you don’t define one, by default, they get pushed to window.dataLayer[] — a good default if you’re using Google Analytics — but easily overridable if you need it to go somewhere else, or you need to enhance with some API call before sending it to GA.
  2. You decorate various components (pages, forms, buttons, links, etc) and any handlers or lifecycle methods within those components (onClick, componentDidMount, etc) with just the data that you have, when you have it. This can either be used as a static object literal or a function that returns an object literal, and is useful in cases where data that you need is a function of props.
  3. That’s it! Whenever an event gets triggered (e.g. you’ve decorated a click handler), the tracking object will be deeply merged starting from that component all the way up the component hierarchy until the top level and then the merged object will be passed to dispatch().

This means the tracking concerns of each component is properly compartmentalized to that component. You do not need to “leak” information up (or down) the component hierarchy. This frees you up from having to worry about coupling components, or updating tracking information as components move around or are refactored.

An example

Here’s what this looks like in practice (using a theoretical sign-in page example with simplified bits relevant to react-tracking):

/* SignInPage.js */
import React, { Component } from 'react';
import track from 'react-tracking';
import SignInForm from './SignInForm';
import { Header, Footer } from './PageFurniture';
@track({
page: 'sign-in',
})
export default class SignInPage extends Component {
render() {
return (
<div>
<Header />
<SignInForm />
<Footer />
</div>
);
}
}

And the sign-in form:

/* SignInForm.js */
import React, { Component } from 'react';
import track from 'react-tracking';
@track({
module: 'sign-in',
})
export default class SignIn extends Component {

@track({
event: 'sign-in-attempt',
})
handleSignIn = () => {
// API call ...
};
@track({
event: 'register-attempt'
})
handleRegister = () => {
// register form ...
}
render() {
return (
<form>
<input type="text" name="username" placeholder="username" />
<input type="password" name="password" placeholder="password" />
<button in</button>
<button > </form>
);
}
}

Here’s what the tracking object looks like if the user clicked “Sign In”, for example (the register action would look similar):

{
page: 'sign-in',
module: 'sign-in',
event: 'sign-in-attempt'
}

Simple! This is of course assuming there’s no other contextual tracking data in the app. In practice, one common pattern is to define some top level tracking data in the root <App /> wrapper, to define global things like the app name, build number, etc. In which case, this App data would be part of the resultant object that’s dispatched.

What this buys us

Here’s a few things to note about this pattern.

  1. As with any clean component architecture, the <SignInForm /> actually has no idea where it is being rendered, however, the page context information came along for the ride automatically.
  2. Even further, the Sign In <button /> doesn’t know what module (or page) it is being rendered in (especially useful if the button was defined in a different file), it just knows about the event it needs to dispatch when it’s clicked, which is the only place this information should be known.
  3. We decorated the <SignInPage /> and the <SignInForm /> with tracking information, but we only received a tracking object when an event happened (which was also decorated!), as we would expect.

TIP: There is a common use case of firing a “page-ready” event when a page renders. This is also supported via defining a process() function on some top level component; an example of this is covered in the next section.

Simple and extensible pattern

The previous example hopefully showcases how simple, yet powerful and flexible this pattern is. However, there is robust support for some fairly complex use cases.

Analytics platform agnostic

A top level dispatch() function allows you to do anything you would need to the tracking data that’s dispatched throughout the app. For example, you may want to enhance the data with some backend API call before sending it to window.someOtherDataLayer[].

Flexible control

A top level process() function allows you to “hook-in” to any decorated component and return an object to send to dispatch() (returning a falsy value will no-op). A common use-case is to automatically dispatch a “page-ready” event on any Page components, for example:

process: (data) => (data.page ? { event: 'page-ready' } : null)

What this is doing is checking for a page property on the tracking object, and if it has it, assumes it’s a page and returns a “page-ready” event that will then get merged with the rest of the app’s tracking data and passed to dispatch().

Advanced use cases

The @track() decorator can also accept a function. This is useful if your tracking data relies on some props data.

It will also pass along any args that would be sent to handler functions (starting from the second parameter, since props is always first). This is useful for form inputs, for example:

import React, { Component } from 'react';
import track from 'react-tracking';
// In this case, the "status" tracking data
// is a function of one of its props (isNew)
@track(props => {
return { status: props.isNew ? 'new' : 'existing' };
})
export class SomeButton extends Component {
// In this case the tracking data depends on
// some unknown (until runtime) value (event).
@track((props, [event]) => ({
action: 'click',
label: event.currentTarget.title || event.currentTarget.textContent,
}))
handleClick = event => {
if (this.props.onClick) {
this.props.onClick(event);
}
};
render() {
return (
<button > {this.props.children}
</button>
);
}
}

Validation

At The New York Times, we’ve taken this a step further by defining a tracking schema to validate against. Traditionally, the tracking object contracts were coordinated manually, usually via an internal Google Doc, but we’re moving to stricter and a more structured programmatically enforced schema. We’re using the excellent ajv library to define a JSON schema for our tracking data layer.

In our development environment, we’ve defined dispatch() to pass through the tracking schema’s validate() function to validate all tracking objects and throw errors whenever there are any issues (in order to reduce bundle size, we turn this off in the production build).

/** 
* Our internal tracking-schema.
* The `validator()` function has a process.env.NODE_ENV check
* to no-op in production, but validate against JSON schema in dev.
*/
import { validator } from 'tracking-schema';
import enhanceWithUserData from './userDataApi';
const PAGE_DATA_READY = 'pageDataReady';// checks dataLayer[] to be available and pushes data to it
const pushtoDataLayer = data => {
validator(data); // logs errors in dev; no-op in prod
(window.dataLayer = window.dataLayer || []).push(data);
};
// dispatch() will decide whether to push directly into the DL
// or enhance with API-provided data first
export const dispatch = data => {
if (data.event === PAGE_DATA_READY) {
enhanceWithUserData(data).then(pushtoDataLayer);
} else {
pushtoDataLayer(data);
}
};

Try it!

We’ve found that react-tracking has been easy for various teams to pick up, yet sophisticated enough to handle all of our use cases thus far. We’d love for you to try it out and let us know what you think. Feel free to submit issues on our Github if you run into any problems or have questions about how to do certain things. And, as always, pull requests are more than welcome!

Thanks

Many thanks to the cross-functioning team at The Times who helped build react-tracking thus far: Jeremy Gayed, Oleh Ziniak, Aneudy Abreu, Max Baldwin, Ivan Kravchenko, and Nicole Baram.

--

--

Coptic Orthodox Christian. Lead Software Engineer @nytimes. Lover of all things JavaScript 🤓