Adding telemetry to a Nuxt app

When rebuilding my personal website in August of this year, I wanted to add a way to know how many people are visiting this website and what articles are they reading. In short, a simple analytics solution. However, there are a few things that made it more complicated.

Dashboard showing the most popular pages on this website

First of all, this website uses Nuxt Content, with AWS Amplify to host it as a static website. It doesn't need to reload the entire page when you click a link, but loads a payload.js file instead. There is also smart prefetching under the hood. This is great to make this website faster and create a smooth user experience. However, from an analytics perspective, it means I cannot rely on the access logs. This meant that I would have to use client-side code to generate my telemetry data.

On top of that, I didn't want to use cookies for analytics. I quite like the GDPR and want to minimise the collection of personably identifiable information if possible. That left solutions like Google Analytics out of the door. There are a few privacy-minded analytics solutions, such as Fathom.com, and I would probably consider them if it was for something more significant than a personal website. However, the starting price of 14 US$/month compared to my meagre 0.03 US$/month AWS bill seemed like a steep step-up.

Because of these problems, I decided to make a custom solution. While it's probably not the most feature-rich solution, it does what I need it to do, and it was fun building it.

Requirements

When starting this project, I had two main questions:

  • How many people visit my website?
  • Which are the most popular pages on my website?

Since I had to build a client-side solution, I could also add more data, such as tracking from which page do people come from when they click on a link, and how long are they staying on each page. I will probably add more as time goes, such as which country they are from, but that was good enough for now.

Event schema

Since a telemetry system emits events that will be aggregated centrally, it's important to think about the event structure beforehand. In the long-term, this will help when I want to add new fields, make changes, etc.

Here, I am capturing three events: start of a session, each click from one page to the next and the end of a session. A session is when someone arrives on the website until they leave. Since Nuxt uses Vue under the hood, I can use Vuejs states to share information between multiple pages. This allows me to generate and keep a single session ID while someone stays on the website. However, since this doesn't use cookies or client-side storage, if the same person visits this website multiple times, I cannot link different sessions together.

Each event will then contain a sessionId and eventType. They also contain three optional fields, based on the type of event:

  • to: the path of the page being loaded, used for start and click events.
  • from: the path of the previous page, used for click and end events.
  • duration: how long the user stayed on the previous page, in milliseconds, and used for click and end events.

Since I'm using TypeScript, I created this interface:

// Enumeration for the types of events
enum TelemetryEventType {
  Start = 'start',
  Click = 'click',
  End = 'end',
}

// Telemetry event structure
interface TelemetryBody {
  sessionId: string,
  to?: string,
  from?: string,
  duration?: number,
  eventType: TelemetryEventType,
}

Creating a plugin for Nuxt

Since a telemetry event should be used on each page, I created a plugin that would trigger a function after each page load, using the global after hooks from Vue.

In a plugin file, you can add a hook like this. On the arguments, we need app to connect to the router, and store to retrieve and save data into the Vuex store. This hook solves the start and click events, but we'll need to do something a bit different for the end event.

export default async ({ store, app }: { store: any, app: any}) => {
  // Global after hook, will run after each page load
  app.router.afterEach((to: Page, from: Page) => {
    // Put logic here
  });
};

The afterEach hook receives two arguments: the new page (to) and the previous one (from), which gives us the data we need for 2 of our fields. For the sessionId and calculating the duration, we'll need to retrieve that from the Vuex store:

    // Retrieve data from Vuex store
    const { sessionId, pageTime } = store.state.telemetry;
    // Compute the duration on the previous page by substracting the previous call.
    const duration = window.performance.now() - pageTime;

Now we can prepare and send the event. As mentioned before, this only supports the start and click events. In the first case, since this will be the first page visited, from will contain a reference to an empty page, so we can detect if this is the first page or not by checking if from.name is null.

For sending the event, I am making a POST call to an API using axios. I'll write another blog post that look at the backend architecture.

    // Prepare the event
    // We default to a start event and check afterwards for click event
    const body: TelemetryBody = {
      sessionId,
      to: to.path,
      eventType: TelemetryEventType.Start,
    };
    // If there is a from page, this is a click event
    if (from.name !== null) {
      body.from = from.path;
      body.duration = duration;
      body.eventType = TelemetryEventType.Click;
    }

    // Post it to the telemetry API
    app.$axios.$post('/telemetry', body);

The last thing we need to do is to commit the current time and page in the Vuex store. We'll need to store the page path for the end event.

    // Update information in the store
    store.commit('telemetry/set', { time: window.performance.now(), page: to.path });

Handling the end of a session

When a user ends their session on the website, they usually just close their tab or browser. This will not trigger any hook on the Vue Router, so we need to use something else to react to it.

For this, we can add a listener to a beforeunload event. From there, we can retrieve information from the store and send the end event to our telemetry api:

  // On page close
  window.addEventListener('beforeunload', () => {
    // Retrieve data from Vuex store
    const { sessionId, pageTime, page } = store.state.telemetry;
    const duration = window.performance.now() - pageTime;

    // Prepare the event
    const body: TelemetryBody = {
      sessionId,
      duration,
      from: page,
      eventType: TelemetryEventType.End,
    };

    // Post it to the telemetry API
    app.$axios.$post('/telemetry', body);
  });

Store data for a session

This uses the Vuex store, but we haven't configured it yet. We are only storing three pieces of information here: the sessionId, pageTime for the timestamp of the last event and page which will contain the path to the last visited page for the end of session event.

In this, we'll take care of setting a random session ID using the uuid library.

import { MutationTree } from 'vuex';
import { v4 as uuidv4 } from 'uuid';

// Default state
function telemetryState() {
  return {
    sessionId: uuidv4(),
    pageTime: 0,
    page: '',
  };
}

export type TelemetryState = ReturnType<typeof telemetryState>;

From there, we need to add a mutation that will allow setting the pageTime and page values.

// Configure 
const mutations: MutationTree<TelemetryState> = {
  set(state, { time, page }: { time: number, page: string}) {
    // eslint-disable-next-line no-param-reassign
    state.pageTime = time;
    // eslint-disable-next-line no-param-reassign
    state.page = page;
  },
};

export default {
  state: telemetryState,
  mutations,
};

Adding the plugin

While we have created a plugin, our application is not aware of it yet. For this, we'll need to add it into the Nuxt configuration file. Since the plugin relies on the window object for both the end event and getting a timestamp in milliseconds, the plugin should only be used client-side.

const cfg: NuxtConfig = {
  // The rest of the config goes here

  // Nuxt plugins
  plugins: [
    // ...
    { src: '@/plugins/telemetry.ts', mode: 'client' },
  ],
};

export default cfg;

Backend

This article only looks at the client-side of the problem. In a future article, I'll look at how I have implemented a serverless and functionless backend to aggregate the data and generate a dashboard.