0.1.8-rc.1 ā€¢ Published 3 years ago

@aperture.io/analytics v0.1.8-rc.1

Weekly downloads
-
License
MIT
Repository
github
Last release
3 years ago

Analytics library

Declarative and compartmentalized library for React analytics, heavily inspired by the New York Times' blog post on declarative React tracking.

TL;DR

  1. Components define only the event data that's relevant to them.
  2. When an event is triggered, event data is deeply-merged all the way up the component tree.
  3. 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:

  1. Defining a set of event data to be shared by <App /> and all of it children
  2. Adding a dispatch function that will called whenever an tracking event is triggered by one of the components
  3. 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:

  1. Defining a set of event data specific to <HomePage />, to be shared with all of its children
  2. Telling useAnalytics to dispatch an event as soon as the component is mounted. This is handy for tracking things like page views
  3. Again, using a <Provider /> to pass the event data down to <HomePage />'s children.

šŸ’” Note that the <Provider /> is returned from the local useAnalytics 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:

  1. Define some local event data for use within <NewsletterForm />
  2. Dispatch a tracking event when a user focuses the "Email address" field with some event-specific props
  3. Dispatch a tracking event when a user submits the form with some event-specific props
  4. 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:

  1. You want to disable all tracking for a specific component or its children
  2. You want to send tracking event data to a different analytics provider (e.g. Segement instead of Amplitude)
  3. You want to do something custom first, before passing the data on to the parent dispatch function (think "event data middleware")
  4. 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:

  1. 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 šŸ¤”
  2. You might have noticed that we weren't able to refactor out the code that disables tracking in development. We could have added an isDisabled function, but we'd still need an if statement in our dispatch function. To put it another way, our helpers can't easily "short-circuit" the dispatch 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:

  1. Logic errors in our dispatch functions
  2. 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
0.1.8-rc.1

3 years ago

0.1.8-rc.0

3 years ago

0.1.7

3 years ago

0.1.6

3 years ago

0.1.5

3 years ago

0.1.4

3 years ago

0.1.3

3 years ago

0.1.2

3 years ago

0.1.1

3 years ago

0.1.0

3 years ago