Analytics and Real-User Metrics

Counting page views in an app that never reloads, collecting Core Web Vitals from real users, and the few numbers worth watching.

Syntaxrouter.afterEach((to, from, failure) => { if (!failure) send('page_view', to) })

In a Vue application the browser loads the document once. A page-view snippet that runs on load therefore fires once, however many screens the visitor moves through. Reporting navigations is your job.

Page views

router.afterEach((to, from, failure) => {}) runs after every navigation. The third argument is a navigation failure and is undefined when the navigation completed, so return early when it is set: an aborted navigation is not a page view. Report to.name rather than to.path when paths carry identifiers, otherwise /order/8812 and /order/8813 arrive as two different pages. to.fullPath includes the query string, which sometimes contains tokens you should not store.

Real-user metrics

Timings from your own machine describe your own machine. Core Web Vitals collected in the field describe what visitors actually got. The web-vitals package, version 5, exports onLCP, onINP, onCLS, onFCP and onTTFB. Each takes a callback and an optional options object, and calls back with a metric carrying name, value, rating, delta and id. By default each reports once per page load, when the value has settled.

What to watch

Four numbers answer most questions: error rate, LCP, INP, and the completion rate of the one flow that pays for the work. Anything else is a dashboard nobody opens.

Web Vitals are measured per page load, not per route change, so a long session reports one LCP. Version 6 adds an opt-in reportSoftNavs option for route changes; it depends on browser support for soft navigations.

Example

Example · vue
// src/analytics.ts
import type { Router } from 'vue-router'
import { onCLS, onINP, onLCP, type Metric } from 'web-vitals'

const ENDPOINT = '/api/collect'

function send(type: string, data: Record<string, unknown>): void {
  navigator.sendBeacon(ENDPOINT, JSON.stringify({ type, ...data }))
}

export function trackPageViews(router: Router): void {
  router.afterEach((to, from, failure) => {
    if (failure) return
    send('page_view', {
      route: String(to.name ?? to.path),
      from: from.name ? String(from.name) : null,
    })
  })
}

export function trackWebVitals(): void {
  const report = (metric: Metric): void => {
    send('web_vital', {
      name: metric.name,
      // CLS is a small decimal; rounding it to an integer would report every page as zero.
      value: metric.name === 'CLS' ? Number(metric.value.toFixed(3)) : Math.round(metric.value),
      rating: metric.rating,
      id: metric.id,
    })
  }
  onLCP(report)
  onINP(report)
  onCLS(report)
}

When to use it

  • Recording a screen change in an application where the browser never reloads.
  • Finding out that LCP is fine on your laptop and poor on the phones your customers use.
  • Checking whether the new checkout step is reached as often as the old one.
  • Attributing a drop in completed signups to the release that caused it.

More examples

Starting collection after mount, in production only

Collection starts after `mount`, so a failure in the analytics code cannot stop the application rendering. Vite replaces `import.meta.env.PROD` with a literal boolean at build time; in a development build it is `false`, so nothing is sent while you work and your own clicks stay out of the numbers. Gating on stored consent keeps the decision in one place rather than inside every component that reports an event.

Example · ts
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { trackPageViews, trackWebVitals } from './analytics'

const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

if (import.meta.env.PROD && localStorage.getItem('analytics-consent') === 'granted') {
  trackPageViews(router)
  trackWebVitals()
}

A composable so components do not know the endpoint

A composable is a function that uses Vue's reactivity and lifecycle APIs and is called from a component's setup. This one reads the current route with `useRoute()` and attaches it to every event, so a component reports what happened and nothing about where the data goes. Because it calls `useRoute()`, it must be called during setup, not inside a click handler.

Example · ts
// src/composables/useTracking.ts
import { useRoute } from 'vue-router'

export function useTracking() {
  const route = useRoute()

  function track(event: string, props: Record<string, string | number> = {}): void {
    navigator.sendBeacon(
      '/api/collect',
      JSON.stringify({
        type: 'event',
        event,
        route: String(route.name ?? route.path),
        ...props,
      }),
    )
  }

  return { track }
}

Reporting an interaction from a component

The report sits in the click handler, next to the emit, because that is the only place the user's intent is unambiguous. A watcher on the parent's selected plan would also fire when state is restored from storage or when a route is prefetched. The emit is declared in the call-signature longhand; since Vue 3.3 the shorthand `defineEmits<{ select: [planId: string] }>()` declares exactly the same thing, and you will meet both forms in other people's code.

Example · vue
<script setup lang="ts">
import { useTracking } from '@/composables/useTracking'

const props = defineProps<{ planId: string; price: number }>()
const emit = defineEmits<{ (e: 'select', planId: string): void }>()
const { track } = useTracking()

function choose(): void {
  track('plan_selected', { plan: props.planId, price: props.price })
  emit('select', props.planId)
}
</script>

<template>
  <button type="button" @click="choose">Choose {{ planId }}</button>
</template>

<style scoped>
button {
  padding: 0.5rem 1rem;
}
</style>

Discussion

  • Be the first to comment on this lesson.