Tracking Core Web Vitals in Adobe Analytics

Steve Webb
Station10
Published in
15 min readFeb 1, 2024

Google’s core web vitals are the industry standard for measuring a website's performance and its impact on user experience. They measure things like loading performance, interactivity, and the stability of content as it loads.

You can see Core Web Vitals for a page using the Lighthouse tab in the Chrome developer console:

This is useful for a one-off snapshot, but what if you want to track these metrics over time? Or break them down by different user segments or any of the other things you might be tracking in Adobe Analytics? This blog will guide you through the steps you can take to measure Core Web Vitals in Adobe Analytics!

Web Vitals metrics over time, split by Device Type

The web vitals library

Happily, Google provides the tools to measure core web vitals in a handy JavaScript library which you can find here: https://github.com/GoogleChrome/web-vitals

This library is a tiny modular library for measuring all the Web Vitals metrics on real users, in a way that accurately matches how they’re measured by Chrome and reported to other Google tools (e.g. Chrome User Experience Report, Page Speed Insights, Search Console’s Speed Report).

Setting up the tracking in Adobe Launch

Adding the library to Adobe Launch

The examples in the GitHub docs either load the library as an NPM package or from a CDN, and I’d recommend using these methods if you can. But, if you have no support from your website developers and want to achieve this 100% in Adobe Launch custom code, the library is small enough to just copy and paste the whole thing into a Launch custom code action.

So for this example, that’s what we’re doing:

  1. Create a new rule that executes on ‘Core — Library Loaded (Page Top)’
  2. Navigate to https://unpkg.com/web-vitals@3/dist/web-vitals.iife.js and paste the whole script into a Custom Code action:
Quick and dirty way to load the Web Vitals library via Adobe Launch

Writing our script

Now what we’re going to do is take a couple of examples from the GitHub docs that are intended for sending the data to Google Analytics, and mash them together to create our own script for sending the data to Adobe Analytics instead.

We can start with this example where we’re simply sending some data to Google Analytics:

import {onCLS, onFID, onLCP} from 'web-vitals';

function sendToGoogleAnalytics({name, delta, value, id}) {
// Assumes the global `gtag()` function exists, see:
// https://developers.google.com/analytics/devguides/collection/ga4
gtag('event', name, {
// Built-in params:
value: delta, // Use `delta` so the value can be summed.
// Custom params:
metric_id: id, // Needed to aggregate events.
metric_value: value, // Optional.
metric_delta: delta, // Optional.

// OPTIONAL: any additional params or debug info here.
// See: https://web.dev/articles/debug-performance-in-the-field
// metric_rating: 'good' | 'needs-improvement' | 'poor',
// debug_info: '...',
// ...
});
}

onCLS(sendToGoogleAnalytics);
onFID(sendToGoogleAnalytics);
onLCP(sendToGoogleAnalytics);

This is a simple script that just sends an event to GA4 on each of the CLS, FID and LCP events, with a few dimensions that come from the web vitals event.

To repurpose this for Adobe Analytics, we can do the following:

Variable Definitions

First, let's set a couple of objects that we can use to define which evars and events we want to put our data in, when we send it to Analytics:

// define which evars and events to use
var eventMap = {
'CLSvalue': 'event100',
'FIDvalue': 'event101',
'LCPvalue': 'event102',
'FCPvalue': 'event103',
'TTFBvalue': 'event104',
'INPvalue': 'event105',
};

var evarMap = {
'CLSrating': 'eVar110',
'FIDrating': 'eVar111',
'LCPrating': 'eVar112',
'FCPrating': 'eVar116',
'TTFBrating': 'eVar118',
'INPrating': 'eVar120',
'CLSnavigationType': 'eVar113',
'FIDnavigationType': 'eVar114',
'LCPnavigationType': 'eVar115',
'FCPnavigationType': 'eVar117',
'TTFBnavigationType': 'eVar119',
'INPnavigationType': 'eVar121',
};
  1. eventMap: A mapping of Core Web Vitals metric names to their corresponding Adobe Analytics event number.
  2. evarMap: A similar mapping, but for Adobe Analytics eVars, where we want to put some of the dimensions that we get back from the web vitals library.

Functions

Next, we want to create a couple of functions that handle setting the s.events string and the s.eVar values.

createEventsString(requestBody, eventMap)

// function to set s.event string
function returnEventsString(requestBody, eventMap) {
var eventString = '';
for (var i = 0; i < requestBody.length; i++) {
var entry = requestBody[i];
var eventName = eventMap[entry.name + 'value'];
if (eventName) {
eventString = (eventString ? eventString + ',' : '') + eventName + '=' + entry.value;
}
}
_satellite.logger.log("Core Web Vitals Handler: s.events set to ", eventString);
return eventString;
}
  • This function iterates over the requestBody (More on that later, but in short, it's an array of Core Web Vitals metrics that we get back from the library).
  • For each metric, it finds the corresponding event in eventMap and adds it to the eventString.

returnEvars(requestBody, evarMap):

// function to return the evars we want as an object
function returnEvars(requestBody, evarMap) {
var evars = {};
for (var i = 0; i < requestBody.length; i++) {
var entry = requestBody[i];
var evarName = evarMap[entry.name + 'rating'];
if (evarName) {
evars[evarName] = entry.rating;
}
var evarName = evarMap[entry.name + 'navigationType'];
if (evarName) {
evars[evarName] = entry.navigationType;
}
}
return evars;
}
  • Similar to createEventsString, but this function constructs an object of eVars instead of a string of events.
  • It iterates over requestBody, and for the rating and navigationType dimensions, it sets the corresponding eVar names and values in the returned object.

Batching multiple reports together

Rather than reporting each individual Web Vitals metric separately, we want to minimize the number of server calls we send to Adobe, so we can follow this example in the docs where we batch multiple metric reports together in a single network request.

This is done by creating a ‘queue’ to which we add the events, and then we send off all the events as the user navigates away from the page.

addToQueue(metric)

const queue = new Set();
function addToQueue(metric) {
queue.add(metric);
}
  • This adds a metric to the queue, a Set used to store Core Web Vitals metrics before they are sent to Adobe Analytics.

flushQueue():

function flushQueue() {
if (queue.size > 0) {
try {
const requestBody = [...queue];

// set the s.events object
s.events = returnEventsString(requestBody, eventMap)

// for the events in the event string, set s.linkTrackEvents to a comma separeated list of the events
s.linkTrackEvents = s.events.split(',').map(function (event) {
return event.split('=')[0];
}).join(',');

// set the evars
evars = returnEvars(requestBody, evarMap);
for (var evarName in evars) {
if (evars.hasOwnProperty(evarName)) {
// set evar
s[evarName] = evars[evarName];
// add to linkTrackVars
s.linkTrackVars = s.linkTrackVars ? s.linkTrackVars + ',' + evarName : evarName;
_satellite.logger.log("Core Web Vitals Handler: s." + evarName + " set to", s[evarName]);
}
}

// call function to send data to Adobe
s.tl(true, 'o', 'Core Web Vitals');

//console logging
_satellite.logger.log("Core Web Vitals Handler: requestBody", requestBody);
_satellite.logger.log("Core Web Vitals Handler: event sent");

queue.clear();
} catch (err) {
_satellite.logger.log("Core Web Vitals Handler: error", err);
}
}
}
  • This function is called to send the collected metrics to Adobe Analytics.
  • It converts the queue to an array (this is the requestBodythat we referenced earlier) and uses createEventsString and returnEvars functions to set up the s.events and s.eVars objects.
  • s.linkTrackEvents and s.linkTrackVars are updated for tracking in Adobe Analytics.
  • s.tl() function is called to send the data to Adobe Analytics.
  • The function logs the sent data and finally clears the queue.

Add Event Listeners

webVitals.onCLS(addToQueue);
webVitals.onFID(addToQueue);
webVitals.onLCP(addToQueue);
webVitals.onFCP(addToQueue);
webVitals.onTTFB(addToQueue);
webVitals.onINP(addToQueue);
  • Add event listeners for Core Web Vitals so they are added to the queue when they are available.

Page Visibility and Unload Handling

// Report all available metrics whenever the page is backgrounded or unloaded.
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
flushQueue();
}
});

// NOTE: Safari does not reliably fire the `visibilitychange` event when the
// page is being unloaded. If Safari support is needed, you should also flush
// the queue in the `pagehide` event.
addEventListener('pagehide', flushQueue);
  • Then lastly we add an event listener to the visibilitychange event. So that when the page is hidden (i.e. the user navigates away from the page) flushQueue is called to send off all the metrics to Adobe.
  • As per Google’s example code, we also add another event listener to better support Safari.

Sampling

Now, with Adobe Analytics we likely don’t want to increase our server calls too much as that can increase costs. You’ll notice if you start tracking these events, even with the batching method detailed above, that you’ll likely be getting at least a couple of events per page.

To mitigate this I recommend adding a Sampling condition to your Adobe Launch rule so that it only fires in something like 5% of page loads. In my experience, this is more than enough to get useful metrics, without significantly driving up your server call volumes.

A sampling condition on our Launch rule

Putting that all together

So we should end up with a rule that looks like this:

And within the ‘Custom Code’ action, we have the following script:

// load the standard core web vitals library
// copied from 'https://unpkg.com/web-vitals@3/dist/web-vitals.iife.js';
// reference: https://github.com/GoogleChrome/web-vitals
var webVitals = function (e) { "use strict"; var n, t, r, i, o, a = -1, c = function (e) { addEventListener("pageshow", (function (n) { n.persisted && (a = n.timeStamp, e(n)) }), !0) }, u = function () { return window.performance && performance.getEntriesByType && performance.getEntriesByType("navigation")[0] }, f = function () { var e = u(); return e && e.activationStart || 0 }, s = function (e, n) { var t = u(), r = "navigate"; return a >= 0 ? r = "back-forward-cache" : t && (r = document.prerendering || f() > 0 ? "prerender" : document.wasDiscarded ? "restore" : t.type.replace(/_/g, "-")), { name: e, value: void 0 === n ? -1 : n, rating: "good", delta: 0, entries: [], id: "v3-".concat(Date.now(), "-").concat(Math.floor(8999999999999 * Math.random()) + 1e12), navigationType: r } }, d = function (e, n, t) { try { if (PerformanceObserver.supportedEntryTypes.includes(e)) { var r = new PerformanceObserver((function (e) { Promise.resolve().then((function () { n(e.getEntries()) })) })); return r.observe(Object.assign({ type: e, buffered: !0 }, t || {})), r } } catch (e) { } }, l = function (e, n, t, r) { var i, o; return function (a) { n.value >= 0 && (a || r) && ((o = n.value - (i || 0)) || void 0 === i) && (i = n.value, n.delta = o, n.rating = function (e, n) { return e > n[1] ? "poor" : e > n[0] ? "needs-improvement" : "good" }(n.value, t), e(n)) } }, v = function (e) { requestAnimationFrame((function () { return requestAnimationFrame((function () { return e() })) })) }, p = function (e) { var n = function (n) { "pagehide" !== n.type && "hidden" !== document.visibilityState || e(n) }; addEventListener("visibilitychange", n, !0), addEventListener("pagehide", n, !0) }, m = function (e) { var n = !1; return function (t) { n || (e(t), n = !0) } }, h = -1, g = function () { return "hidden" !== document.visibilityState || document.prerendering ? 1 / 0 : 0 }, y = function (e) { "hidden" === document.visibilityState && h > -1 && (h = "visibilitychange" === e.type ? e.timeStamp : 0, E()) }, T = function () { addEventListener("visibilitychange", y, !0), addEventListener("prerenderingchange", y, !0) }, E = function () { removeEventListener("visibilitychange", y, !0), removeEventListener("prerenderingchange", y, !0) }, C = function () { return h < 0 && (h = g(), T(), c((function () { setTimeout((function () { h = g(), T() }), 0) }))), { get firstHiddenTime() { return h } } }, L = function (e) { document.prerendering ? addEventListener("prerenderingchange", (function () { return e() }), !0) : e() }, b = function (e, n) { n = n || {}, L((function () { var t, r = [1800, 3e3], i = C(), o = s("FCP"), a = d("paint", (function (e) { e.forEach((function (e) { "first-contentful-paint" === e.name && (a.disconnect(), e.startTime < i.firstHiddenTime && (o.value = Math.max(e.startTime - f(), 0), o.entries.push(e), t(!0))) })) })); a && (t = l(e, o, r, n.reportAllChanges), c((function (i) { o = s("FCP"), t = l(e, o, r, n.reportAllChanges), v((function () { o.value = performance.now() - i.timeStamp, t(!0) })) }))) })) }, w = function (e, n) { n = n || {}, b(m((function () { var t, r = [.1, .25], i = s("CLS", 0), o = 0, a = [], u = function (e) { e.forEach((function (e) { if (!e.hadRecentInput) { var n = a[0], t = a[a.length - 1]; o && e.startTime - t.startTime < 1e3 && e.startTime - n.startTime < 5e3 ? (o += e.value, a.push(e)) : (o = e.value, a = [e]) } })), o > i.value && (i.value = o, i.entries = a, t()) }, f = d("layout-shift", u); f && (t = l(e, i, r, n.reportAllChanges), p((function () { u(f.takeRecords()), t(!0) })), c((function () { o = 0, i = s("CLS", 0), t = l(e, i, r, n.reportAllChanges), v((function () { return t() })) })), setTimeout(t, 0)) }))) }, S = { passive: !0, capture: !0 }, P = new Date, I = function (e, i) { n || (n = i, t = e, r = new Date, M(removeEventListener), A()) }, A = function () { if (t >= 0 && t < r - P) { var e = { entryType: "first-input", name: n.type, target: n.target, cancelable: n.cancelable, startTime: n.timeStamp, processingStart: n.timeStamp + t }; i.forEach((function (n) { n(e) })), i = [] } }, F = function (e) { if (e.cancelable) { var n = (e.timeStamp > 1e12 ? new Date : performance.now()) - e.timeStamp; "pointerdown" == e.type ? function (e, n) { var t = function () { I(e, n), i() }, r = function () { i() }, i = function () { removeEventListener("pointerup", t, S), removeEventListener("pointercancel", r, S) }; addEventListener("pointerup", t, S), addEventListener("pointercancel", r, S) }(n, e) : I(n, e) } }, M = function (e) { ["mousedown", "keydown", "touchstart", "pointerdown"].forEach((function (n) { return e(n, F, S) })) }, D = function (e, r) { r = r || {}, L((function () { var o, a = [100, 300], u = C(), f = s("FID"), v = function (e) { e.startTime < u.firstHiddenTime && (f.value = e.processingStart - e.startTime, f.entries.push(e), o(!0)) }, h = function (e) { e.forEach(v) }, g = d("first-input", h); o = l(e, f, a, r.reportAllChanges), g && p(m((function () { h(g.takeRecords()), g.disconnect() }))), g && c((function () { var c; f = s("FID"), o = l(e, f, a, r.reportAllChanges), i = [], t = -1, n = null, M(addEventListener), c = v, i.push(c), A() })) })) }, k = 0, B = 1 / 0, x = 0, R = function (e) { e.forEach((function (e) { e.interactionId && (B = Math.min(B, e.interactionId), x = Math.max(x, e.interactionId), k = x ? (x - B) / 7 + 1 : 0) })) }, H = function () { return o ? k : performance.interactionCount || 0 }, N = function () { "interactionCount" in performance || o || (o = d("event", R, { type: "event", buffered: !0, durationThreshold: 0 })) }, O = 0, _ = function () { return H() - O }, j = [], q = {}, V = function (e) { var n = j[j.length - 1], t = q[e.interactionId]; if (t || j.length < 10 || e.duration > n.latency) { if (t) t.entries.push(e), t.latency = Math.max(t.latency, e.duration); else { var r = { id: e.interactionId, latency: e.duration, entries: [e] }; q[r.id] = r, j.push(r) } j.sort((function (e, n) { return n.latency - e.latency })), j.splice(10).forEach((function (e) { delete q[e.id] })) } }, z = function (e, n) { n = n || {}, L((function () { var t = [200, 500]; N(); var r, i = s("INP"), o = function (e) { e.forEach((function (e) { (e.interactionId && V(e), "first-input" === e.entryType) && (!j.some((function (n) { return n.entries.some((function (n) { return e.duration === n.duration && e.startTime === n.startTime })) })) && V(e)) })); var n, t = (n = Math.min(j.length - 1, Math.floor(_() / 50)), j[n]); t && t.latency !== i.value && (i.value = t.latency, i.entries = t.entries, r()) }, a = d("event", o, { durationThreshold: n.durationThreshold || 40 }); r = l(e, i, t, n.reportAllChanges), a && (a.observe({ type: "first-input", buffered: !0 }), p((function () { o(a.takeRecords()), i.value < 0 && _() > 0 && (i.value = 0, i.entries = []), r(!0) })), c((function () { j = [], O = H(), i = s("INP"), r = l(e, i, t, n.reportAllChanges) }))) })) }, G = {}, J = function (e, n) { n = n || {}, L((function () { var t, r = [2500, 4e3], i = C(), o = s("LCP"), a = function (e) { var n = e[e.length - 1]; if (n) { var r = Math.max(n.startTime - f(), 0); r < i.firstHiddenTime && (o.value = r, o.entries = [n], t()) } }, u = d("largest-contentful-paint", a); if (u) { t = l(e, o, r, n.reportAllChanges); var h = m((function () { G[o.id] || (a(u.takeRecords()), u.disconnect(), G[o.id] = !0, t(!0)) }));["keydown", "click"].forEach((function (e) { addEventListener(e, h, !0) })), p(h), c((function (i) { o = s("LCP"), t = l(e, o, r, n.reportAllChanges), v((function () { o.value = performance.now() - i.timeStamp, G[o.id] = !0, t(!0) })) })) } })) }, K = function e(n) { document.prerendering ? L((function () { return e(n) })) : "complete" !== document.readyState ? addEventListener("load", (function () { return e(n) }), !0) : setTimeout(n, 0) }, Q = function (e, n) { n = n || {}; var t = [800, 1800], r = s("TTFB"), i = l(e, r, t, n.reportAllChanges); K((function () { var o = u(); if (o) { var a = o.responseStart; if (a <= 0 || a > performance.now()) return; r.value = Math.max(a - f(), 0), r.entries = [o], i(!0), c((function () { r = s("TTFB", 0), (i = l(e, r, t, n.reportAllChanges))(!0) })) } })) }; return e.getCLS = w, e.getFCP = b, e.getFID = D, e.getINP = z, e.getLCP = J, e.getTTFB = Q, e.onCLS = w, e.onFCP = b, e.onFID = D, e.onINP = z, e.onLCP = J, e.onTTFB = Q, Object.defineProperty(e, "__esModule", { value: !0 }), e }({});

// define which evars and events to use
var eventMap = {
'CLSvalue': 'event100',
'FIDvalue': 'event101',
'LCPvalue': 'event102',
'FCPvalue': 'event103',
'TTFBvalue': 'event104',
'INPvalue': 'event105',
};

var evarMap = {
'CLSrating': 'eVar110',
'FIDrating': 'eVar111',
'LCPrating': 'eVar112',
'FCPrating': 'eVar116',
'TTFBrating': 'eVar118',
'INPrating': 'eVar120',
'CLSnavigationType': 'eVar113',
'FIDnavigationType': 'eVar114',
'LCPnavigationType': 'eVar115',
'FCPnavigationType': 'eVar117',
'TTFBnavigationType': 'eVar119',
'INPnavigationType': 'eVar121',
};

// function to set s.event string
function returnEventsString(requestBody, eventMap) {
var eventString = '';
for (var i = 0; i < requestBody.length; i++) {
var entry = requestBody[i];
var eventName = eventMap[entry.name + 'value'];
if (eventName) {
eventString = (eventString ? eventString + ',' : '') + eventName + '=' + entry.value;
}
}
_satellite.logger.log("Core Web Vitals Handler: s.events set to ", eventString);
return eventString;
}

// function to return the evars we want as an object
function returnEvars(requestBody, evarMap) {
var evars = {};
for (var i = 0; i < requestBody.length; i++) {
var entry = requestBody[i];
var evarName = evarMap[entry.name + 'rating'];
if (evarName) {
evars[evarName] = entry.rating;
}
var evarName = evarMap[entry.name + 'navigationType'];
if (evarName) {
evars[evarName] = entry.navigationType;
}
}
return evars;
}

const queue = new Set();
function addToQueue(metric) {
queue.add(metric);
}

function flushQueue() {
if (queue.size > 0) {
try {
const requestBody = [...queue];

// set the s.events object
s.events = returnEventsString(requestBody, eventMap)

// for the events in the event string, set s.linkTrackEvents to a comma separeated list of the events
s.linkTrackEvents = s.events.split(',').map(function (event) {
return event.split('=')[0];
}).join(',');

// set the evars
evars = returnEvars(requestBody, evarMap);
for (var evarName in evars) {
if (evars.hasOwnProperty(evarName)) {
// set evar
s[evarName] = evars[evarName];
// add to linkTrackVars
s.linkTrackVars = s.linkTrackVars ? s.linkTrackVars + ',' + evarName : evarName;
_satellite.logger.log("Core Web Vitals Handler: s." + evarName + " set to", s[evarName]);
}
}

// call function to send data to Adobe
s.tl(true, 'o', 'Core Web Vitals');

//console logging
_satellite.logger.log("Core Web Vitals Handler: requestBody", requestBody);
_satellite.logger.log("Core Web Vitals Handler: event sent");

queue.clear();
} catch (err) {
_satellite.logger.log("Core Web Vitals Handler: error", err);
}
}
}

webVitals.onCLS(addToQueue);
webVitals.onFID(addToQueue);
webVitals.onLCP(addToQueue);
webVitals.onFCP(addToQueue);
webVitals.onTTFB(addToQueue);
webVitals.onINP(addToQueue);

// Report all available metrics whenever the page is backgrounded or unloaded.
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
flushQueue();
}
});

// NOTE: Safari does not reliably fire the `visibilitychange` event when the
// page is being unloaded. If Safari support is needed, you should also flush
// the queue in the `pagehide` event.
addEventListener('pagehide', flushQueue);

Adobe Analytics Setup

Lastly, you’ll want to enable the relevant custom Events and eVars in Adobe Analytics.

You’ll also want to create a Calculated Metric for each to give you the average score as a metric. That way you’ll be able to get an average score for dates, pages, or other dimensions in Adobe. For example;

Average LCP Rating
Average LCP rating over time

Wrapping Up

And there you go! Hopefully all the works and you have Core Web Vitals tracked in Adobe Analytics using only custom code in Adobe Launch.

About Us

Station10 believe in open source and sharing knowledge and our Medium content is never paywalled, so please leave a clap if you’ve found this useful. This helps motivate us to write more content and gives us a better idea of what to write more about.

--

--