Skip to content

Instantly share code, notes, and snippets.

@smhmic
Last active November 25, 2020 15:47
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save smhmic/96a5bd7770ccfc903ce1d7314fa6f182 to your computer and use it in GitHub Desktop.
Save smhmic/96a5bd7770ccfc903ce1d7314fa6f182 to your computer and use it in GitHub Desktop.
Track Web Vitals via GTM

This is a pared-down version of Google's web-vitals library for tracking core web vitals RUM via GTM, with various changes.

Google recommends loading it's web-vitals library via a CDN, which adversely impacts performance -- the very thing we're trying to measure & optimize! Using a common CDN has certain benefits mitigating the performance impact, but if we're already loading GTM it would be ideal to instead add the library as a GTM tag. (Actually the ideal way is to package this library with first-party code, but the purpose of Web Vitals trackings aligns nicely with the core needs of teams using GTM.) We lose the advantage of automatic library updates, but we want to make some tweaks anyway...

Primary changes:

  • Only include what we need. This script is ~1/3 smaller than the UMD base+polyfill version of G's library. This script focuses on the three core web vitals, and exludes two metrics available in G's library:
    • TTFB, since this it's already tracked by GA out of the box (as Server Response Time).
    • FCP, since it's less indicative of quality UX than it's cousin metric: LCP.
  • Send CLS & LCP only once per page load after configurable timeout (fixes GA issues caused by G's library)
  • Provide a mechanism for hardcoding page hidden check on-page (works w/ or w/o)

More opinionated changes:

  • Centralized config options (see CONFIG object at top, which can be moved to a GTM variable to separate from core listener code).
  • Built in UA event naming nomenclature, incl good/ok/bad indicators (thresholds customizable via config)
  • Robust (toggleable) logging.

TODO:

(function(){
var VERSION = '0.3';
var CONFIG = {
eventCategory : 'web_vitals',
maxWaitMs : 39 * 1000, // Max time to wait to send CLS & LCP, in milliseconds
jsGlobalNamespace : '_seerGtmFwVars',
bucketBoundaries : {
// format: [ good/moderate threshold, moderate/bad threshold ]
// values should be in milliseconds (except CLS which has it's own score scale)
'LCP': [ 2500, 4000 ],
'FID': [ 100, 300 ],
'CLS': [ 0.1, 0.25 ],
},
debug : true,
};
var pageCache = ( window[CONFIG.jsGlobalNamespace] = window[CONFIG.jsGlobalNamespace] || {} );
var tryItSafe = function( fn ){ if( CONFIG.debug ) fn(); else try{ fn() }catch(ex){} };
var log = function( str ){ if( CONFIG.debug ) console.log('[WebVitalsListener]',str) };
var main = function(){
var track = function( name, value, PageLoadId ){
var namespace = 'fw.perf', frame = { event: namespace+':'+name }, bucket;
var round = function( v, parts ){
try {
if( parts )
v = (Math.round( v * parts ) / parts).toFixed( Math.ceil( parts / 10 ) );
return Math.round( v ).toFixed( 0 );
}catch(ex){ return '(error) v='+value+'; error: '+ex; }
};
//var deltaSeconds = Math.round( value / 1000 );
//var deltaMilliseconds = Math.round( value );
//var eventAction, eventLabel, eventValue;
if( CONFIG.bucketBoundaries && (bucket = CONFIG.bucketBoundaries[name] ) ){
if( value < 0 ) bucket = 'NEGATIVE'; // should never happen
else if( value < bucket[0] ) bucket = '✅'; //
else if( value < bucket[1] ) bucket = '🔶️'; //
else bucket = '🔴'; //
}
bucket = bucket ? ' ' + bucket : '';
var valueStr = (function(){
switch( name ){
case 'LCP': return '~'+round( value/1000, 2 )+'s'; // round to nearest half second
case 'FID': return '~'+round( value / 1000, 10 ) + 's'; // round to nearest tenth second
case 'CLS': return 'Score (0-1): '+round( value, 10 ); // round to nearest tenth
default: return value+' (?)';
}
})();
frame[namespace] = {
'LCP' : undefined,
'FID' : undefined,
'CLS' : undefined,
'eventCategory' : CONFIG.eventCategory || namespace,
'eventAction' : name + ' [v' + VERSION + ']' + bucket,
// The 'id' value will be unique to the current page load. When sending
// multiple values from the same page (e.g. for CLS), Google Analytics can
// compute a total by grouping on this ID (note: requires `eventLabel` to
// be a dimension in your report).
'eventLabel' : valueStr + ( PageLoadId ? ' PageLoadId:' + PageLoadId : '' ),// + bucket,
// Google Analytics metrics must be integers, so the value is rounded.
// For CLS the value is first multiplied by 1000 for greater precision
// (note: increase the multiplier for greater precision if needed).
'eventValue' : 0,
'nonInteraction' : true,
'timingMilliseconds' : Math.round(value*(name==='CLS'?1000:1)),
};
frame[namespace][name] = valueStr;
dataLayer.push( frame );
};
// Keep track of whether (and when) the page was first hidden (for LCP & FID).
// see: https://github.com/w3c/page-visibility/issues/29
// NOTE: ideally this check would be performed in the document <head>
// to avoid cases where the visibility state changes before this code runs.
if( ! pageCache.hasOwnProperty('firstHiddenTime') ){
pageCache.firstHiddenTime = document.visibilityState === 'hidden' ? 0 : Infinity;
}
document.addEventListener( 'visibilitychange', function( event ){
log( 'visibilitychange triggered; pageCache.firstHiddenTime='+pageCache.firstHiddenTime );
pageCache.firstHiddenTime = Math.min( pageCache.firstHiddenTime, event.timeStamp );
}, { once : true } );
/// LCP ///
// https://web.dev/lcp/#measure-lcp-in-javascript
// https://github.com/GoogleChrome/web-vitals/blob/master/src/getLCP.ts
/*
LCP should not be reported if the page was loaded in a background tab. The above code partially addresses this, but it's not perfect since the page could have been hidden and then shown prior to this code running. A solution to this problem is being discussed in the Page Visibility API spec
*/
// Use a try/catch instead of feature detecting `largest-contentful-paint`
// support, since some browsers throw when using the new `type` option.
// https://bugs.webkit.org/show_bug.cgi?id=209216
tryItSafe( function(){
var updateLCP = function updateLCP( entry ){
log( 'LCP > updateLCP() entry.startTime='+entry.startTime+' pageCache.firstHiddenTime='+pageCache.firstHiddenTime );
// Only include an LCP entry if the page wasn't hidden prior to
// the entry being dispatched. This typically happens when a page is
// loaded in a background tab.
if( entry.startTime < pageCache.firstHiddenTime ){
// NOTE: the `startTime` value is a getter that returns the entry's
// `renderTime` value, if available, or its `loadTime` value otherwise.
// The `renderTime` value may not be available if the element is an image
// that's loaded cross-origin without the `Timing-Allow-Origin` header.
lcp = entry.startTime;
}
};
// Create a PerformanceObserver that calls `updateLCP` for each entry.
// Create a variable to hold the latest LCP value (since it can change).
var lcp;
var po = new PerformanceObserver( function( entryList, po ){
entryList.getEntries().forEach( function( entry ){
return updateLCP( entry, po );
} );
} );
// Observe entries of type `largest-contentful-paint`, including buffered entries,
// i.e. entries that occurred before calling `observe()` below.
po.observe( {
type : 'largest-contentful-paint',
buffered : true
} );
var timeoutId;
var visibilityChangeHandler = function visibilityChangeHandler( event ){
if( document.visibilityState === 'hidden' ){
log( 'LCP > updateLCP() > visibilitychange=hidden ' );
sendFinalValue();
}
};
var sendFinalValue = function(){
removeEventListener( 'visibilitychange', visibilityChangeHandler, true );
window.clearTimeout( timeoutId );
// Force any pending records to be dispatched and stop listening for LCP updates.
po.takeRecords().forEach( function( entry ){
return updateLCP( entry, po );
} );
po.disconnect();
if( lcp ){
track( 'LCP', lcp );
}
};
// Log the final score once the page's lifecycle state changes to hidden, or after CONFIG.maxWaitMs (whichever happens first).
addEventListener( 'visibilitychange', visibilityChangeHandler, true );
timeoutId = window.setTimeout( sendFinalValue, CONFIG.maxWaitMs );
//} catch( ex ){}// Do nothing if the browser doesn't support this API.
});
/// Element Timing ///
// https://web.dev/custom-metrics/#element-timing-api
/*
The Largest Contentful Paint (LCP) metric is useful for knowing when the largest image or text block was painted to the screen, but in some cases you want to measure the render time of a different element.
For these cases, you can use the Element Timing API. In fact, the Largest Contentful Paint API is actually built on top of the Element Timing API and adds automatic reporting of the largest contentful element, but you can report on additional elements by explicitly adding the elementtiming attribute to them, and registering a PerformanceObserver to observe the element entry type.
<img elementtiming="hero-image" />
<p elementtiming="important-paragraph">This is text I care about.</p>
*/
// Catch errors since some browsers throw when using the new `type` option.
// https://bugs.webkit.org/show_bug.cgi?id=209216
/* tryItSafe( function(){
// Create the performance observer.
var po = new PerformanceObserver( function( entryList ){
entryList.getEntries().map( function( entry ){
// Log the entry and all associated details.
console.log( entry.toJSON() );
} );
} ); // Start listening for `element` entries to be dispatched.
po.observe( {
type : 'element',
buffered : true
} );
//} catch( ex ){}// Do nothing if the browser doesn't support this API.
});*/
/// FID ///
// https://web.dev/fid/#measure-fid-in-javascript
// https://github.com/GoogleChrome/web-vitals/blob/master/src/getFID.ts
/*
Due to the expected variance in FID values, it's critical that when reporting on FID you look at the distribution of values and focus on the higher percentiles.
While choice of percentile for all Core Web Vitals thresholds is the 75th, for FID in particular we still strongly recommend looking at the 95th–99th percentiles, as those will correspond to the particularly bad first experiences users are having with your site. And it will show you the areas that need the most improvement.
This is true even if you segment your reports by device category or type. For example, if you run separate reports for desktop and mobile, the FID value you care most about on desktop should be the 95th–99th percentile of desktop users, and the FID value you care about most on mobile should be the 95th–99th percentile of mobile users.
*/
// Use a try/catch instead of feature detecting `first-input`
// support, since some browsers throw when using the new `type` option.
// https://bugs.webkit.org/show_bug.cgi?id=209216
tryItSafe( function(){
var onFirstInputEntry = function onFirstInputEntry( entry, po ){
// Only report FID if the page wasn't hidden prior to
// the entry being dispatched. This typically happens when a
// page is loaded in a background tab.
if( entry.startTime < pageCache.firstHiddenTime ){
var fid = entry.processingStart - entry.startTime;
po.disconnect(); // Disconnect the observer.
track( 'FID', fid ); // Report the FID value to an analytics endpoint.
}
};
// Create a PerformanceObserver that calls `onFirstInputEntry` for each entry.
var po = new PerformanceObserver( function( entryList, po ){
entryList.getEntries().forEach( function( entry ){
return onFirstInputEntry( entry, po );
} );
} );
// Observe entries of type `first-input`, including buffered entries,
// i.e. entries that occurred before calling `observe()` below.
po.observe( {
type : 'first-input',
buffered : true
} );
//} catch( ex ){}// Do nothing if the browser doesn't support this API.
});
/// CLS ///
// https://web.dev/cls/#measure-cls-in-javascript
// https://github.com/GoogleChrome/web-vitals/blob/master/src/getCLS.ts
/*
CLS is the sum of those individual layout-shift entries that didn't occur with recent user input. To calculate CLS, declare a variable that stores the current score, and then increment it any time a new, unexpected layout shift is detected.
*/
// Use a try/catch instead of feature detecting `layout-shift`
// support, since some browsers throw when using the new `type` option.
// https://bugs.webkit.org/show_bug.cgi?id=209216
tryItSafe( function(){
var onLayoutShiftEntry = function onLayoutShiftEntry( entry ){
log( 'CLS > onLayoutShiftEntry()' );
// Only count layout shifts without recent user input.
if( !entry.hadRecentInput ){
cls += entry.value;
}
};
// Create a PerformanceObserver that calls `onLayoutShiftEntry` for each entry.
// Store the current layout shift score for the page.
var cls = 0;
var po = new PerformanceObserver( function( entryList, po ){
entryList.getEntries().forEach( function( entry ){
return onLayoutShiftEntry( entry, po );
} );
} );
// Observe entries of type `layout-shift`, including buffered entries,
// i.e. entries that occurred before calling `observe()` below.
po.observe( {
type : 'layout-shift',
buffered : true
} );
var timeoutId;
var visibilityChangeHandler = function visibilityChangeHandler( event ){
if( document.visibilityState === 'hidden' ){
log( 'LCP > updateLCP() > visibilitychange=hidden ' );
sendFinalValue();
}
};
var sendFinalValue = function(){
removeEventListener( 'visibilitychange', visibilityChangeHandler, true );
window.clearTimeout( timeoutId );
// Force any pending records to be dispatched and disconnect the observer.
po.takeRecords().forEach( function( entry ){
return onLayoutShiftEntry( entry, po );
} );
po.disconnect();
track( 'CLS', cls );
};
// Log the final score once the page's lifecycle state changes to hidden, or after CONFIG.maxWaitMs (whichever happens first).
addEventListener( 'visibilitychange', visibilityChangeHandler, true );
timeoutId = window.setTimeout( sendFinalValue, CONFIG.maxWaitMs );
// Alternative - Log the CLS score whenever the page becomes hidden until CONFIG.maxWaitMs, and then send final value at CONFIG.maxWaitMs.
/*
var sendValue = function(){
// Force any pending records to be dispatched and disconnect the observer.
po.takeRecords().forEach( function( entry ){
return onLayoutShiftEntry( entry, po );
} );
po.disconnect();
track( 'CLS', cls );
};
var visibilityChangeHandler = function visibilityChangeHandler( event ){
if( document.visibilityState === 'hidden' ){
log( 'LCP > updateLCP() > visibilitychange=hidden ' );
sendValue();
}
};
addEventListener( 'visibilitychange', visibilityChangeHandler, true );
window.setTimeout( sendValue, function(){
removeEventListener( 'visibilitychange', visibilityChangeHandler, true );
sendValue();
}, CONFIG.maxWaitMs ); */
//} catch( ex ){}// Do nothing if the browser doesn't support this API.
});
};
tryItSafe(main);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment