Don't Overcomplicate Analytics

Our user tracking setup at work uses a web worker that routes tracking events into multiple destinations. The tracking events themselves must be explicitly sent at individual points in a user flow. When we add tracking for new features and A/B experiments it usually entails a lengthy discussion with the data science team to make sure we send specific values at specific points in time.

Analytics really doesn't need to be this complicated. Let me show you how simple analytics can be.

analytics.js

analytics.js is a simple JS script I wrote to prove how easy it is to track user sessions with little effort. The entire script is around 100 lines:

window.analytics_track_url = "/api/track";

let evt_queue = [];
try {
  const evts = JSON.parse(sessionStorage.getItem("evt_queue"));
  if (Array.isArray(evts)) evt_queue = evts;
} catch {}

async function send() {
  const evts = evt_queue.slice();
  if (evts.length == 0) return;
  evt_queue.length = 0;
  sessionStorage.setItem("evt_queue", "[]");
  await fetch(analytics_track_url, {
    method: "post",
    mode: "no-cors",
    body: JSON.stringify(evts),
  }).then((res) => res.json()).catch(() => {});
}
window.addEventListener("load", send);

let send_timeout = null;
function debounce_send() {
  if (send_timeout) return;
  send_timeout = setTimeout(async () => {
    await send();
    send_timeout = null;
  }, 3000);
}

function track(properties, flush = false) {
  const { action, name, node, ...restProps } = properties;
  evt_queue.push({
    action,
    node,
    date: new Date().toISOString(),
    location: location.href,
    name,
    properties: restProps,
  });
  sessionStorage.setItem("evt_queue", JSON.stringify(evt_queue));
  return flush ? send() : debounce_send();
}

const getName = (el) => (el.ariaLabel || el.textContent).trim() || undefined;

document.querySelectorAll(
  "a,audio,button,details,input,textarea,video",
).forEach((el) => {
  const props = {
    node: el.nodeName.toLowerCase(),
    name: getName(el),
  };
  switch (props.node) {
    case "a": {
      el.addEventListener("click", async (e) => {
        e.preventDefault();
        await track({ ...props, action: "click", target: el.href }, true);
        if (el.href) location.href = el.href;
      });
      break;
    }
    case "audio":
    case "video": {
      el.addEventListener("play", async () => {
        await track({ ...props, action: "click", state: "playing" });
      });
      el.addEventListener("pause", async () => {
        await track({ ...props, action: "click", state: "paused" });
      });
      break;
    }
    case "input":
    case "textarea": {
      el.addEventListener("change", async (e) => {
        await track({ ...props, action: "type", name: e.target.value });
      });
      break;
    }
    case "details": {
      el.addEventListener("toggle", async (e) => {
        await track({
          ...props,
          action: "click",
          name: getName(e.target.querySelector("summary")),
          state: el.open ? "expanded" : "collapsed",
        });
      });
      break;
    }
    default: {
      el.addEventListener("click", async () => {
        await track({ ...props, action: "click" });
      });
    }
  }
});

const observer = new IntersectionObserver((entries, observer) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    track({
      action: "view",
      node: entry.target.nodeName.toLowerCase(),
      name: entry.target.dataset.inview,
    });
    observer.unobserve(entry.target);
  }
});
document.querySelectorAll("[data-inview]").forEach((el) =>
  observer.observe(el)
);

I'll walk you through how it works.

Usage

The script automatically posts tracking events to an endpoint. All you need to do is set the endpoint to ingest events:

<script src="./analytics.js"></script>
<script>
  window.analytics_track_url = "https://api.company.com/track"
</script>

Tracking events are sent to this endpoint in a POST request as an array of events with the following format:

{
  action: 'view' | 'click' | 'type' // type of action the user took
  node: string                      // name of element that sent event
  date: string                      // ISO string
  location: string                  // full page URL
  name: string                      // name (or text content) of element
  properties: Record<string, any>   // any additional properties
}

To handle and ingest these events, they can be consumed by a microservice and/or routed directly to a SQL database. If you want to track individual user sessions, you can save the user ID or email as an additional field. You don't even need RUM for this - it's trivial to construct user sessions via SQL query by grouping events by user ID and ordering by date.

Interactions and Views

The script captures two kinds of events: interactions and views.

Interactions are automatically tracked for "interactive elements" by attaching relevant event listeners to every element, ensuring no user interaction gets missed. These event listeners are element-specific. For example, <input> and <textarea> elements fire a tracking event for every change.

Unlike interactions, views are not automatic and can be opted-in with a data-inview attribute on the element to be monitored:

<div data-inview="myElementName">
  <p>hello</p>
</div>

This element is then observed with an IntersectionObserver that fires a single view event once the element has been shown in the viewport.

Out of the box, no elements in shadow roots can be tracked with this script due to their isolated DOM.

Batching

All tracking events are batched in a queue and debounced so requests to send events to the destination only occur at most every three seconds.

Session and Hard Navigation

This script also preserves events across hard navigations. All events are saved to session storage before getting sent to the destination. If a hard navigation occurs, the events will not be lost. Once the user returns to the site, any queued events are sent on page load. If events need to be captured between sessions and tabs, you could also use local storage.

Conclusion

analytics.js is a short script that provides the majority of functionality you'll ever need for an analytics solution for your company. You can modify the script however you like to fit your specific needs. As an industry we need to stop overcomplicating analytics.