@aperture.io/analytics v0.1.8-rc.1
Analytics library
Declarative and compartmentalized library for React analytics, heavily inspired by the New York Times' blog post on declarative React tracking.
TL;DR
- Components define only the event data that's relevant to them.
- When an event is triggered, event data is deeply-merged all the way up the component tree.
- Finally, the combined event data is pushed to analytics provider.
Introduction
Setting up example components
Let's start with a top-level <App />
component:
export const App = () => {
const { Provider } = useAnalytics(
// Event data shared by all components within our app
{ app: 'MyApp' },
// Function used to handle triggered events
// This is where you would integrate with Amplitude, Segment, etc.
{ dispatch: (eventData) => console.log('dispatch', eventData) },
);
return (
// <Provider /> passes App event data down to its children
<Provider>
<HomePage />
</Provider>
);
};
Here, we are doing three things:
- Defining a set of event data to be shared by
<App />
and all of it children - Adding a
dispatch
function that will called whenever an tracking event is triggered by one of the components - Using a
<Provider />
to pass event data down to<App />
's children
Next, let's add a <HomePage />
component:
export const HomePage = () => {
const { Provider } = useAnalytics(
// Event data shared by all components within HomePage
{ page: 'HomePage' },
// Trigger an event when the component is mounted
{ dispatchOnMount: true },
);
return (
// <Provider /> passes HomePage event data down to its children
<Provider>
<NewsletterForm />
</Provider>
);
};
In this component, we are:
- Defining a set of event data specific to
<HomePage />
, to be shared with all of its children - Telling
useAnalytics
to dispatch an event as soon as the component is mounted. This is handy for tracking things like page views - Again, using a
<Provider />
to pass the event data down to<HomePage />
's children.
š” Note that the
<Provider />
is returned from the localuseAnalytics
hook call. This allows<Provider />
to merge any parent event data with the local<HomePage />
event data, before passing the combined data down to<HomePage />
's children.
Finally, let's add the <NewsletterForm />
component, where our user interaction tracking will take place:
export const NewsletterForm = ({ handleSubmit }) => {
const { trackEvent } = useAnalytics(
// Event data shared by all components within NewsletterForm
{ module: 'NewsletterForm' },
);
return (
<Form
onSubmit={(event) => {
handleSubmit(event);
// Event data specific to form submission
trackEvent({ action: 'submit-form' });
}}
>
<FormLabel>Sign up for our newsletter!</FormLabel>
<Input
placeholder="Email address"
onFocus={() => {
// Event data specific to focusing the email input
trackEvent({ action: 'email-input-focused' });
}}
/>
<Button type="submit">Sign up!</Button>
</Form>
);
};
In this component we:
- Define some local event data for use within
<NewsletterForm />
- Dispatch a tracking event when a user focuses the "Email address" field with some event-specific props
- Dispatch a tracking event when a user submits the form with some event-specific props
- We do not use
<Provider />
in this component, since it has no children that implement their own tracking
We already saw how you can define shared event data when you call useAnalytics
before. Here we are also passing event-specific data to trackEvent
when handling specific user interactions. This allows you to minimize how much data you have to pass to each trackEvent
call, without sacrificing flexibility.
How data is merged
Now that we have all of our components set up, let's walk through exactly what happens when a user interacts with our app. There are a total of three events that could be dispatched, so let's walk through them, one by one.
First, when <HomePage />
is mounted, an event is dispatched immediately because we set dispatchOnMount: true
. <HomePage />
is a child of <App />
, so its event data will be merged with its parent. This is what the combined data will look like:
{
// <App /> event data
app: 'MyApp',
// <HomePage /> event data
page: 'HomePage',
}
The next event is dispatched when a user focuses an input in the <NewsletterForm />
component. <NewsletterForm />
has two parents, <HomePage />
and <App />
, and we also passed some event-specific data when we called trackEvent
. Here is how this data would be merged:
{
// <App /> event data
app: 'MyApp',
// <HomePage /> event data
page: 'HomePage',
// <NewsletterForm /> shared data
module: 'NewsletterForm',
// Event-specific data passed to `trackEvent` directly
action: 'email-input-focused',
}
The last event is dispatched when a user submits the <NewsletterForm />
. It's very similar to the input focus event above, but has different event-specific data. Here it is merged:
{
// <App /> event data
app: 'MyApp',
// <HomePage /> event data
page: 'HomePage',
// <NewsletterForm /> shared data
module: 'NewsletterForm',
// Event-specific data passed to `trackEvent` directly
action: 'submit-form',
}
Overriding the dispatch function locally
If you are writing a component within a larger application, it is sometimes useful to override the parent dispatch
function in your local component. Here are some possible cases where this can be useful:
- You want to disable all tracking for a specific component or its children
- You want to send tracking event data to a different analytics provider (e.g. Segement instead of Amplitude)
- You want to do something custom first, before passing the data on to the parent
dispatch
function (think "event data middleware") - You want to validate the format of the data using an event data schema
To see how we can do this, let's go back to our example app above, and override the dispatch
for the <HomePage />
component:
export const HomePage = () => {
const { Provider } = useAnalytics(
{ page: 'HomePage' },
{
dispatch: (eventData, parentDispatch) => {
// Disable tracking in development
if (process.env.NODE_ENV === 'development') return;
// Remove some event data
const filteredData = omit(eventData, ['debug']);
// Add some event data
const newData = { ...filteredEventData, url: window.location.href };
// Validate the event data format
if (newData.fullName) throw Error("Don't track personal user data!");
// Send data to a different analytics service
Segment.track(eventData);
// Finally, pass the modified data to the parent dispatch function
parentDispatch(newData);
},
dispatchOnMount: true,
},
);
return (
<Provider>
<NewsletterForm />
</Provider>
);
};
We are doing a lot of things: piping data to a different provider, removing, adding, and validating data on the fly, and eventually passing the modified data up to the parent dispatch
function. And the best part, since the dispatch
function override happens locally in <HomePage />
, the rest of the application is unaffected. As you can see, this approach can be very powerful!
Middleware
Our dispatch
function in the last example got rather complicated. It's not exactly "doing one thing, and doing it well". We also don't have a way to easily re-use our code in a different component
The first thing we could do is break up the dispatch
function into separate helper functions:
// Remove some event data
const filterFields = (data, fieldsToOmit) => {
const nextData = omit(data, fieldsToOmit);
return nextData;
};
// Add some event data
const trackUrl = (data) => {
const nextData = { ...data, url: window.location.href };
return nextData;
};
// Validate the event data format
const validateEventData = (data) => {
if (data.fullName) throw Error("Don't track personal user data!");
return data;
};
// Send data to a different analytics service
const pushToSegment = (data) => {
Segment.track(data);
return data;
};
const dispatch = (eventData, parentDispatch) => {
// Disable tracking in development
if (process.env.NODE_ENV === 'development') return;
let data = eventData;
data = filterFields(data, ['debug']);
data = trackUrl(data);
data = validateEventData(data);
data = pushToSegment(data);
// Finally, pass the modified data to the parent dispatch function
parentDispatch(data);
};
That's certainly looking better, and we most of our code is not reusable! There are, however, still two issues remaining:
- It's a little repetitive to keep having to pass data into, and out of, our helpers. We could have the helpers mutate their
data
argument, but that's hardly a good way to write modern JavaScript. It would be nice if we had a way to get rid of all of this boilerplate š¤ - You might have noticed that we weren't able to refactor out the code that disables tracking in
development
. We could have added anisDisabled
function, but we'd still need anif
statement in ourdispatch
function. To put it another way, our helpers can't easily "short-circuit" thedispatch
function. This seems like something we might need, especially if we have a lot of reusable analytics helpers in a large codebase! š
This is where middleware comes in! A middleware function typically takes some data (or state), performs some action, and then, if everything looks good, calls the next middleware function. You've probably encountered middleware in libraries like express
or redux
before. When used effectively, middleware allows developers to implement complex behaviors using simple, reusable, and composable functions, and helps cut down on boilerplate code.
So, what does middleware look like in this library? Well, you've already seen it. Take another look at a dispatch
function:
const dispatch = (eventData, parentDispatch) => {
// Possibly short-circuit...
if (process.env.NODE_ENV === 'development') return;
// Do something...
const nextData = filterFields(eventData, ['debug']);
// Everything looks good, pass the data along to the next dispatch function!
parentDispatch(nextData);
};
That... sounds a lot like the middleware function we described earlier! It's even more obvious if you rename a few fields:
const middleware = (data, next) => {
// Possibly short-circuit...
if (process.env.NODE_ENV === 'development') return;
// Do something...
const nextData = filterFields(data, ['debug']);
// Everything looks good, pass the data along to the next middleware function!
next(nextData);
};
Now, let's rewrite our helper functions from earlier:
// Remove some event data
const filterFields = (fieldsToOmit) => (data, next) => {
const nextData = omit(data, fieldsToOmit);
next(nextData);
};
// Add some event data
const trackUrl = (data, next) => {
const nextData = { ...data, url: window.location.href };
next(nextData);
};
// Validate the event data format
const validateEventData = (data, next) => {
if (data.fullName) throw Error("Don't track personal user data!");
next(data);
};
// Send data to a different analytics service
const pushToSegment = (data, next) => {
Segment.track(data);
next(data);
};
Very similar so far! This just leaves the logic that disables tracking in a development
environment. What we are really trying to do here is to not call any middleware after the environment check. We know that calling next
from one middleware function is how we tell the next middleware function to run, so all we have to do is not call next
, and our middleware execution chain stops:
const disableInDevelopment = (data, next) => {
// In development, return without calling `next` to prevent additional
// middleware from running
if (process.env.NODE_ENV === 'development') return;
// For other environments, call `next` to keep the middleware execution going
next();
};
The last thing we need is to somehow combine all our middleware functions into a single dispatch
function that can be passed to the useAnalytics
good. You can do this using the applyMiddleware
helper function provided by this library:
import { applyMiddleware } from '@aperture.io/analytics';
// Merge our middleware functions into a single dispatch function
const dispatch = applyMiddleware([
disableInDevelopment,
filterFields(['debug']),
trackUrl,
validateEventData,
pushToSegment,
]);
That's much cleaner! āØ
The applyMiddleware
function takes an array of middleware functions, Middleware[]
, and returns a single, merged Middleware
function. The dispatch
function is also of type Middleware
. This means that you can nest calls to applyMiddleware
, and any previously-defined dispatch
function can be passed as middleware to applyMiddleware
:
const someDispatchFunction = // ...
const dispatch = applyMiddleware([
someDispatchFunction,
applyMiddleware([someMiddleware, anotherMiddleware]),
oneMoreMiddleware,
]);
Error handling
Despite our best efforts, our code will inevitably throw unexpected runtime errors. In this library's case, the most common errors we might run into are:
- Logic errors in our
dispatch
functions - Validation errors that we might throw on purpose in our validation middleware
When an error is thrown, the useAnalytics
hook returns an additional error
object:
const { error } = useAnalytics(
{ some: 'data' },
{
dispatch: () => {
throw Error('Uh oh');
},
},
);
Given the highly-nested nature of this library, it is important to note that errors are handled in the local call to useAnalytics
, and do not bubble up to parents.
In our grocery list example from earlier, events were triggered from the <GroceryCheckbox />
component, so that is where the error
object will be available:
const GroceryCheckbox = () => {
// š Call to trackEvent happens in this component, so the error is caught here
const { trackEvent, error } = useAnalytics();
const [checked, setChecked] = useState(false);
return (
<input
type="checkbox"
checked={checked}
onChange={() => {
trackEvent({
action: 'done-button-toggle',
bought: !checked,
});
setChecked(!checked);
}}
/>
);
};
Overriding default merge behavior
To avoid making assumption about the desired merging behavior, this library merges event data shallowly by default. Deeply merging event data is a large application with many levels of tracked parent and child components can get complicated quickly, so it is generally discouraged.
If you do require more complex data shapes, you have the option to override the default merge using the mergeData
config option:
import { merge } from 'lodash';
const { error } = useAnalytics(
{ nested: { some: 'data' } },
{ mergeData: (parentData, localData) => merge({}, parentData, localData) },
);
š” Note that you can pass different mergeData
functions to each call to useAnalytics
, so each nested tracked component has full control over how its local event data merging is handled.
And that's the library! Happy tracking! ššš
Public API
// Types
type Dispatch = (data: Object) => void;
type Middleware = (data: Object, next: Dispatch) => void;
type UseAnalytics = (
// Shared analytics data
eventData: Object,
// Configuration object
options: {
// Dispatch event handler
dispatch: Middleware;
// Whether to dispatch an event on component mount
dispatchOnMount: true;
},
) => {
// Localized analytics provider
Provider: React.Provider<Object>;
// trackEvent function
trackEvent: (data: Object) => void;
};
// Exports
export const applyMiddleware: (fn: Middleware[]) => Middleware;
export const useAnalytics: UseAnalytics;
Scripts
pnpm run build # Build library bundle
pnpm run start # Start dev server
pnpm run tsc # Start Typescript compiler
pnpm run tsc:watch # Start Typescript compiler (watch mode)
pnpm run test # Run Jest tests
pnpm run test:watch # Run Jest tests (watch mode)
pnpm run lint # Check for ESLint issues
pnpm run lint:watch # Check for ESLint issues (watch mode)
Code organization
/dist # Build artifacts destination folder (compiles TS output)
/source # Library source files
/index.ts # Library entry point
/.eslint.json # Local ESLint configuration
/babel.config.js # Local Babel configuration
/jest.config.json # Local Jest configuration
/jsconfig.json # JS config, primarily used by code editors to resolve path aliases
/tsconfig.json # TS configuration
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago