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.
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 forstart
andclick
events.from
: the path of the previous page, used forclick
andend
events.duration
: how long the user stayed on the previous page, in milliseconds, and used forclick
andend
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.