FieldNotesFORRESTBLADE.COM ↗

2019-08-307 MIN[react][analytics][tutorial]

telemetry first

I have spent this year rewriting a server rendered site into a React single page app, and the old site had Adobe Analytics wired through every page. I treated the tracking as something to deal with after the screens worked, the same way everyone does, and I paid for it with interest. Reports that people depend on went quiet, nobody noticed for a while, and untangling it after the fact took longer than building it right would have.

So this is the write-up of the better way, the way I now start every screen. We build a small app with the telemetry designed in from the first commit, the same as routing or error handling. Not sprinkled on at the end.

What we're building

A store finder. A search screen, a results screen, and a store detail screen. It is deliberately boring, because the app is not the point. The point is that when we finish, every screen and every meaningful action reports itself, and we can prove it in the network tab.

The stack is what an enterprise React project looks like right now: create-react-app, react-router-dom 5, the history package, hooks for everything, and Adobe Launch delivering Adobe Analytics.

Step 0, write the tracking plan before any code

This is the entire trick, and it costs ten minutes. Before the first component exists, write down every screen, every action worth counting, what each should be called, and what each should carry. Then, and this matters, show it to whoever reads the reports and get a yes. Page name conventions are already established in those reports, and inventing your own is how a rewrite orphans years of history.

screen    route         pageName               beacon
------    -----         --------               ------
search    /             finder:search          s.t()
results   /results      finder:results         s.t()
detail    /store/:id    finder:store-detail    s.t()

event              trigger                identifier      variables
-----              -------                ----------      ---------
search submitted   form submit            search          eVar1 term, event1,
                                                          event2 result count
store selected     click on a result      select-store    eVar2 store id, event3

Everything below is just implementing this table. When someone asks in six months what the app tracks, this table is the answer, and it lives in the repo next to the code.

Step 1, scaffold

npx create-react-app store-finder
cd store-finder
npm install react-router-dom history

Step 2, the data layer exists before the app does

Tags should never reach into your components or scrape the DOM for data. The contract is a plain object on window. Your app writes facts into it, the tag manager reads facts out of it, and neither side knows anything else about the other. It goes in index.html above the Launch embed so it exists before any tag can possibly ask for it.

<!-- public/index.html, inside <head> -->
<script>
  window.digitalData = {
    page: { pageName: '', language: 'en-US' },
    search: { term: '', resultCount: 0 }
  };
</script>
<script src="https://assets.adobedtm.com/launch-EN0000.min.js" async></script>

We follow the digitalData convention because that is what the analytics team here already speaks. The exact shape matters less than the rule: one object, defined first, and the app is the only writer.

Step 3, one telemetry module

Every word the app ever says to analytics goes through one file. Components never touch _satellite directly, the same way they never write raw SQL. This file is the tracking plan translated into code, and when the plan changes, this is the only file that changes.

// src/telemetry.js
const canTrack = () => typeof window._satellite !== 'undefined';

export function trackPage(pageName) {
  window.digitalData.page.pageName = pageName;
  if (canTrack()) window._satellite.track('page-view');
}

export function trackSearch(term, resultCount) {
  window.digitalData.search.term = term.toLowerCase().trim();
  window.digitalData.search.resultCount = resultCount;
  if (canTrack()) window._satellite.track('search');
}

export function trackStoreSelect(storeId) {
  if (canTrack()) window._satellite.track('select-store', { storeId });
}

The canTrack guard is not optional. Launch is a third party script your bundle knows nothing about. It will be absent on your machine, blocked by some browsers, and slow on bad connections. An app that throws because a tag manager did not load is an embarrassing bug, and I am telling you that as someone who shipped it.

Notice the order inside each function, data layer first, then the track call. The rule in Launch reads the data layer at the moment the direct call fires, so writing the data after tracking sends yesterday's values. That bug produces reports that are almost right, which is far worse than wrong.

Step 4, page views on every route change

This is the part that silently breaks in every SPA migration. The old site fired a page view per page load because it had page loads. A React app has exactly one. After that, the router swaps components and updates the URL and Adobe hears nothing, so the report shows a site where everyone arrives and nobody goes anywhere.

React Router runs on a history object. Create it yourself, hand it to the Router, and you get one choke point every navigation passes through, including the back button. As a custom hook:

// src/usePageView.js
import { useEffect } from 'react';
import { trackPage } from './telemetry';

const PAGE_NAMES = [
  { match: /^\/$/, name: 'finder:search' },
  { match: /^\/results/, name: 'finder:results' },
  { match: /^\/store\/[^/]+/, name: 'finder:store-detail' }
];

function pageNameFor(pathname) {
  const hit = PAGE_NAMES.find((entry) => entry.match.test(pathname));
  return hit ? hit.name : 'finder:not-found';
}

export function usePageView(history) {
  useEffect(() => {
    trackPage(pageNameFor(history.location.pathname));
    const unlisten = history.listen((location) => {
      trackPage(pageNameFor(location.pathname));
    });
    return unlisten;
  }, [history]);
}
// src/App.js
import React from 'react';
import { Router, Route, Switch } from 'react-router-dom';
import { createBrowserHistory } from 'history';
import { usePageView } from './usePageView';
import Search from './Search';
import Results from './Results';
import StoreDetail from './StoreDetail';

const history = createBrowserHistory();

export default function App() {
  usePageView(history);
  return (
    <Router history={history}>
      <Switch>
        <Route exact path="/" component={Search} />
        <Route path="/results" component={Results} />
        <Route path="/store/:id" component={StoreDetail} />
      </Switch

Page names come from a route map, not from the raw URL, because raw URLs carry ids and querystrings that shatter one screen into a thousand report rows. And the map has a fallback, so even a bad route reports itself as finder:not-found instead of vanishing.

Step 5, the search screen carries its event

Here is the search screen, and the thing to notice is that the tracking call is not decoration added later, it is part of what submitting the form means.

// src/Search.js
import React, { useState } from 'react';
import { findStores } from './api';
import { trackSearch } from './telemetry';

export default function Search({ history }) {
  const [term, setTerm] = useState('');

  const onSubmit = async (event) => {
    event.preventDefault();
    const stores = await findStores(term);
    trackSearch(term, stores.length);
    history.push('/results', { term, stores });
  };

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="term">City or zip</label>
      <input
        id="term"
        value={term}
        onChange={(event) => setTerm(event.target.value)}
      />
      <button type="submit">Search</button>
    </form>
  );
}

The event carries the result count on purpose, and this is where telemetry-first stops being hygiene and starts being product work. Searches with zero results are the list of things people want from you that you do not have, sorted by demand.

One warning while we are here. Be deliberate about what goes into variables. A search term is fine once it is trimmed and lowercased. Names, emails, addresses, anything a person typed about themselves, do not send it. Adobe's terms prohibit it, your privacy team prohibits it, and cleaning a variable that has been collecting PII is miserable work.

Step 6, the rules in Launch

Now the other half of the contract. In Launch, create three data elements of the JavaScript Variable type, pointing at digitalData.page.pageName, digitalData.search.term, and digitalData.search.resultCount.

Then three rules, one per row of the plan. The page view rule listens for a Direct Call event with identifier page-view, sets pageName from the data element, and sends a beacon of type s.t(), which is the page view type. The search rule listens for search, sets eVar1 from the term, event1 as the counter and event2 carrying the result count, and sends s.tl(), the link tracking type. The select-store rule listens for select-store, reads the id from the call's detail payload at event.detail.storeId, sets eVar2 and event3, and sends s.tl().

The type distinction is the classic trap. s.t() counts as a page view, s.tl() counts as an interaction, and mixing them up produces page view numbers that quietly stop meaning anything.

Last thing in Launch, find the default rule that fires a page view when the library loads, the one every property gets set up with, and disable it. Our hook already owns the landing view. Leave both on and every landing page counts twice, and inflated traffic is the kind of wrong that takes months to notice because nobody complains when numbers go up.

Step 7, prove it!

Install the Adobe Experience Cloud Debugger extension, or skip it and open the network tab and filter for b/ss, which is the beacon path. Then walk the app like a visitor and count:

load /                 1 beacon    s.t()   finder:search
search "moline"        1 beacon    s.tl()  term + result count
land on /results       1 beacon    s.t()   finder:results
click a store          1 beacon    s.tl()  store id
land on /store/123     1 beacon    s.t()   finder:store-detail
reload /store/123      1 beacon    not two
back button            1 beacon    finder:results

The back button row is there because history.listen hears popstate too, so back and forward navigation report like any other view. The reload row is there because that is the double-count test. This walk takes two minutes and it is now part of my definition of done for any screen. I do it before every deploy, because I have deployed the version that failed it.

The actual lesson

Measured against building the same app untracked, the telemetry cost maybe an hour, and most of that was clicking around the Launch interface. Retrofitting it into an app that shipped without it cost me the better part of a sprint. The rewrite is not done when the screens render. It is done when the reports that people already depend on say the same true things they said before. Build like that from the first commit and the analytics stops being a chore, it is just another thing your app does well.