Setup SpeedCurve's "Real User Monitoring" using RxJS


Introduction

We recently started using SpeedCurve to track Unsplash’s performance. To get its “Real User Monitoring” (a.k.a. “LUX”) working with our app, we needed to do some extra work to handle client-side navigations. Currently, the documentation on the matter is fairly sparse, so here’s an (elaborate) guide on how to do it using Redux.


NOTE: except for a minor React-related step, this guide is largely framework-agnostic so long as you have Redux.

Problem

Here’s the sequence of events we’re working with (as per SpeedCurve’s docs):

  • user performs an in-app navigation, causing the URL to change
  • we call LUX.init()
  • data is fetched for the new route (optional)
  • new route is rendered
  • we call LUX.send()

We’re gonna opt to use redux-observable, as that will make it easy to listen to streams of actions and perform actions accordingly.

redux-observable

Most of our work will be inside a redux-observable “epic”. Epics have the following type signature:

type Epic = (
  action$: Observable<Action>,
  state$: Observable<State>
) => Observable<Action>;

This means that they receive a stream of redux actions and a stream of redux state, and output a stream of redux actions. For more on that, read their docs.


NOTE: throughout this guide, I’ll briefly explain what each Observable operator does. However, if you are new to Observables, you’ll likely benefit from having these resources open for reference:

URL changes

We must start by identifying when the URL changes, indicating that the user navigated to a new route.

In our specific case, we use react-router with connected-react-router, which lets us read the react-router state from Redux, making it easy to identify URL changes. If you don’t use these libraries, you’ll still be fine as long as you store your pathname in Redux, or have some sort of Observable emitting pathnames.

const isString = (str) => typeof str === "string";

const pathname$ = state$.pipe(
  map((state) => (state.router ? state.router.location.pathname : undefined)),
  filter(isString)
);

const newPathname$ = pathname$.pipe(distinctUntilChanged());
  • By using state$, we are reading the stream of redux state. Every time the state changes, a new value is emitted which will be manipulated by all the operators inside the .pipe() call.
  • map and filter behave on observables the way their array counterparts behave on arrays.

We then end up with pathname$: an observable that emits all valid pathname values. Finally, distinctUntilChanged() will only emit a new value when it is different from the previous one. This is necessary because location$ might emit new values even if location.pathname hasn’t changed.


Now we have newPathname$, a stream of differing pathnames, each one representing a new client-side navigation. We can then call LUX.init after each one:

newPathname$.pipe(
  tap(() => {
    LUX.init();
  });
)

(tap is an operator you use when you want to perform side-effects.)

Data-fetching & rendering

Now for the tricky part. Our routes can be grouped into two:

  • those that require data-fetching before rendering (“dynamic” routes)
  • those that don’t need data and can render immediately (“static” routes)

We need to model these scenarios in Redux. We will do so using three actions: ROUTE_DATA_FETCHED, DYNAMIC_ROUTE_COMPONENT_UPDATED and STATIC_ROUTE_COMPONENT_UPDATED. (This is the only React-specific bit)

class DynamicRoute extends Component {
  componentDidMount() {
    const { dispatch } = this.props;

    // fetch data here...

    dispatch(routeDataFetched());
  }

  componentDidUpdate() {
    this.props.dispatch(dynamicRouteComponentUpdated());
  }
}

class StaticRoute extends Component {
  componentDidMount() {
    // no data to fetch here.
  }

  componentDidUpdate() {
    this.props.dispatch(staticRouteComponentUpdated());
  }
}

PS: For convenience, we wrote a trackRouteUpdates HOC that dispatches the componentDidUpdate actions, and wrapped all of our route components with it. We highly suggest you do the same to avoid repeating this logic in every component.

Now that we have our actions, let’s go back to our epic and use them:

const takeOneAction = (actionType) =>
  action$.pipe(
    filter((action) => action.type === actionType),
    take(1)
  );

const firstRouteDataFetched$ = takeOneAction("ROUTE_DATA_FETCHED");
const firstDynamicRouteComponentUpdated$ = takeOneAction(
  "DYNAMIC_ROUTE_COMPONENT_UPDATED"
);
const firstStaticRouteComponentUpdated$ = takeOneAction(
  "STATIC_ROUTE_COMPONENT_UPDATED"
);

The actions inside componentDidUpdate are bound to be dispatched multiple times, but we are only concerned with the first one. takeOneAction will listen for a specific action, and complete the observable after the action is found once in the action$ stream (when an observable is “completed”, it stops emitting values).

Let’s put these three action observables together:

// For static routes, we simply listen for one `StaticRouteComponentUpdated` action.
const staticRouteIsReady$ = firstStaticRouteComponentUpdated$;

const dynamicRouteIsReady$ = concat(
  firstRouteDataFetched$,
  firstDynamicRouteComponentUpdated$
).pipe(takeLast(1));

/**
 * Since this epic doesn't know whether the route is static or dynamic, we have to
 * `race` between the two possible options.
 */
const waitForDynamicOrStaticRouteUpdated$ = race(
  dynamicRouteIsReady$,
  staticRouteIsReady$
);

For dynamic routes, we must wait for data to be fetched and then for the route to update, in that order. This is what concat does, while takeLast(1) grabs the last value emitted by the concatenated stream. It’s a handy way of knowing that both inner observables have completed.

Wrap-up (almost)

Now we can chain this with the newPathname$ observable we wrote earlier:

import { EMPTY } from "rxjs";

// 1- user performs an in-app navigation, causing the URL to change
newPathname$.pipe(
  tap(() => {
    // 2- we call LUX.init()
    LUX.init();
  }),

  // 3- data is fetched for the new route (optional)
  // 4- new route is rendered
  mergeMapTo(waitForDynamicOrStaticRouteUpdated$),
  tap(() => {
    // 5- we call LUX.send()
    LUX.send();
  }),
  // This epic only performs side-effects. No need to return any action.
  mergeMapTo(EMPTY)
);

Notice how the above code perfectly mirrors the sequence of events outlined at the start of the article.

mergeMap will map your newPathname$ observable to the inner action observable. The mergeMapTo variant is used when you don’t need the values of the previous observable, which is the case here (the newPathname values aren’t needed in waitForDynamicOrStaticRouteUpdated$).

Handling pending LUX tracking

We’re done!…well, not really. One caveat is the case where a user quickly navigates from page A to B to C before page B finishes rendering. We will end up calling LUX.init twice in a row: once for page B and again for page C. That cannot happen: each LUX.init() must be matched with LUX.send(). We decided to fix that by making sure LUX.send is always called before making a new LUX.init call.

let pendingLuxTracking = false;

const doLuxSend = () => {
  if (pendingLuxTracking) {
    LUX.send();
    pendingLuxTracking = false;
  }
};

return newPathname$.pipe(
  tap(() => {
    doLuxSend();
    LUX.init();
    pendingLuxTracking = true;
  }),
  mergeMapTo(waitForDynamicOrStaticRouteUpdated$),
  tap(doLuxSend),
  mergeMapTo(EMPTY)
);

We don’t particularly love this solution (this is one of only a couple of mutable variables in our entire codebase). However, solving this in an immutable “RxJS-way” came out to be so complicated that we were ultimately satisfied with the above. If you have a cleaner way to do it, please let us know!

Final result ✨

import { EMPTY, concat, race } from "rxjs";
import {
  filter,
  map,
  mergeMapTo,
  take,
  takeLast,
  tap,
  distinctUntilChanged,
} from "rxjs/operators";

const isString = (str) => typeof str === "string";

const luxEpic = (action$, state$) => {
  let pendingLuxTracking = false;
  const doLuxSend = () => {
    if (pendingLuxTracking) {
      LUX.send();
      pendingLuxTracking = false;
    }
  };

  const takeOneAction = (actionType) =>
    action$.pipe(
      filter((action) => action.type === actionType),
      take(1)
    );

  const firstRouteDataFetched$ = takeOneAction("ROUTE_DATA_FETCHED");
  const firstDynamicRouteComponentUpdated$ = takeOneAction(
    "DYNAMIC_ROUTE_COMPONENT_UPDATED"
  );
  const firstStaticRouteComponentUpdated$ = takeOneAction(
    "STATIC_ROUTE_COMPONENT_UPDATED"
  );

  const dynamicRouteIsReady$ = concat(
    firstRouteDataFetched$,
    firstDynamicRouteComponentUpdated$
  ).pipe(takeLast(1));
  const staticRouteIsReady$ = firstStaticRouteComponentUpdated$;
  const waitForDynamicOrStaticRouteUpdated$ = race(
    dynamicRouteIsReady$,
    staticRouteIsReady$
  );

  const pathname$ = state$.pipe(
    map((state) => (state.router ? state.router.location.pathname : undefined)),
    filter(isString)
  );

  const newPathname$ = pathname$.pipe(distinctUntilChanged());

  return newPathname$.pipe(
    tap(() => {
      doLuxSend();
      LUX.init();
      pendingLuxTracking = true;
    }),
    mergeMapTo(waitForDynamicOrStaticRouteUpdated$),
    tap(doLuxSend),
    mergeMapTo(EMPTY)
  );
};

We’re finally done. 🤯