|
(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); |
|
})(); |