UNPKG

boomerangjs

Version:

boomerang always comes back, except when it hits something

597 lines (515 loc) 18.1 kB
/** * The EventTiming plugin collects interaction metrics exposed by the W3C * [Event Timing]{@link https://github.com/w3c/event-timing} proposal. * * This plugin calculates metrics such as: * * **First Input Delay** (FID): For the first interaction on the page, how responsive was it? * * **Interaction to Next Paint** (INP): Highest value of interaction latency on the page * * **Incremental Interaction to Next Paint** (IINP): Highest value of interaction latency for the current navigation * * ## Interaction Phases * * Each interaction on the page can be broken down into three phases: * * * **Input Latency**: How long it took for the browser to trigger event handlers for the physical interaction * * **Processing Latency**: How long it takes for all event handlers to execute * * **Presentation Latency**: How long it takes to draw the next frame (visual update) * * FID and INP/IINP measure different phases of interactions. * * ## First Input Delay * * If the user interacts with the page, the EventTiming plugin will measure how * long it took for the JavaScript event handler to fire (Input Latency). * * This can give you an indication of the page being otherwise busy and unresponsive * to the user if the callback is delayed. * * Processing Latency and Presentation Latency are not included in the First Input Delay calculation. * * This time (measured in milliseconds) is added to the beacon as `et.fid`. * * ## Interation to Next Paint * * After every interaction on the page, the total interaction duration is measured. * * The sum of the input, processing and presentation latency for each interaction is * calculated as that interactions' _Interaction to Next Paint_. * * For every page load, Boomerang will report on (one of) the longest interactions * as the page's _Interaction to Next Paint_ (INP) metric. For page with less than 50 * interactions, INP is the worst interaction. For pages with over 50 interactions, * INP is the 98th percentile interaction. * * This time (measured in milliseconds) is added to the beacon as `et.inp`, on the * Unload beacon. * * ## Incremental Interation to Next Paint * * Boomerang will also add the "Incremental INP" (incremental being since the last beacon) * as `et.inp.inc`. * * For MPA websites, this means the Page Load beacon will have an Incremental INP (if * any interactions happened before the Page Load event). The Unload beacon's `et.inp` * will be the "final" INP value. * * For SPA websites, the SPA Hard and all SPA Soft beacons will contain an Incremental INP, * which tracks any interactions since the previous Hard/Soft beacon. This way you can * track INP for long-lived SPA websites, split by each route. * * For information on how to include this plugin, see the {@tutorial building} tutorial. * * ## Beacon Parameters * * All beacon parameters are prefixed with `et.`. * * This plugin adds the following parameters to the beacon: * * * `et.e`: Compressed EventTiming events * * `et.fid`: Observed First Input Delay * * `et.inp`: Interaction to Next Paint (full page, on Unload beacon) * * `et.inp.e`: INP target element * * `et.inp.t`: INP timestamp that the interaction occurred * * `et.inp.inc`: Incremental Interaction to Next Paint (for the Page Load and each SPA Soft nav) * * `et.inp.inc.e`: Incremental INP target element * * `et.inp.inc.t`: Incremental INP timestamp that the interaction occurred * * @see {@link https://github.com/w3c/event-timing/} * @class BOOMR.plugins.EventTiming */ (function() { BOOMR = window.BOOMR || {}; BOOMR.plugins = BOOMR.plugins || {}; if (BOOMR.plugins.EventTiming) { return; } /** * Event names */ var EVENT_TYPES = { "click": 0, "dblclick": 1, "mousedown": 2, "mouseup": 3, "mousemove": 4, "touchstart": 5, "touchend": 6, "touchmove": 7, "keydown": 8, "keyup": 9, "keypress": 10, "wheel": 11, "pointerdown": 12, "pointerup": 13, "pointermove": 14, "compositionstart": 17, "compositionupdate": 18, "compositionend": 19, "contextmenu": 20, "pointerover": 21, "mouseover": 22, "pointerenter": 23, "auxclick": 24, "beforeinput": 25, "dragend": 26, "dragenter": 27, "dragleave": 28, "dragover": 29, "dragstart": 30, "drop": 31, "gotpointercapture": 32, "input": 33, "lostpointercapture": 34, "mouseenter": 35, "mouseleave": 36, "mouseout": 37, "pointercancel": 38, "pointerleave": 39, "pointerout": 40, "touchcancel": 41 }; /** * Maximum number of EventTiming entries to keep (by default). * * The number of entries kept will affect INP calculations, as it * uses the 98th percentile. */ var MAX_ENTRIES_DEFAULT = 100; /** * EventTiming duration threshold. * * The spec's default value is 104, and minimum possible is 16. * * We set to 16 to be notified of the maximum number of EventTiming events * possible. */ var DURATION_THRESHOLD_DEFAULT = 16; /** * Private implementation */ var impl = { /** * Whether or not we've initialized yet */ initialized: false, /** * Whether or not the browser supports EventTiming (cached value) */ supported: null, /** * The PerformanceObserver for 'event' */ observerEvent: null, /** * The PerformanceObserver for 'firstInput' */ observerFirstInput: null, /** * List of EventTiming entries */ entries: [], /** * Maximum number of EventTiming entries to keep (after which, no new entries are added). * * Set to -1 for unlimited. */ maxEntries: MAX_ENTRIES_DEFAULT, /** * EventTiming event Duration threshold */ durationThreshold: DURATION_THRESHOLD_DEFAULT, /** * Map of page interactions (excluding those since last beacon), * split by Interaction ID */ interactions: {}, /** * Map of page interactions since last beacon */ interactionsSinceLastBeacon: {}, /** * First Input Delay (calculated) */ firstInputDelay: null, /** * Time to First Interaction */ timeToFirstInteraction: null, /** * Executed on `before_beacon` */ onBeforeBeacon: function() { var i; // gather all stored entries since last beacon if (impl.entries && impl.entries.length) { var compressed = []; for (i = 0; i < impl.entries.length; i++) { var entry = { n: typeof EVENT_TYPES[impl.entries[i].name] !== "undefined" ? EVENT_TYPES[impl.entries[i].name] : impl.entries[i].name, s: Math.round(impl.entries[i].startTime).toString(36), d: Math.round(impl.entries[i].duration).toString(36), p: Math.round(impl.entries[i].processingEnd - impl.entries[i].processingStart).toString(36), c: impl.entries[i].cancelable ? 1 : 0, fi: impl.entries[i].entryType === "first-input" ? 1 : undefined, i: impl.entries[i].interactionId ? impl.entries[i].interactionId.toString(36) : undefined }; if (impl.entries[i].target) { entry.t = BOOMR.utils.makeSelector(impl.entries[i].target); } compressed.push(entry); } BOOMR.addVar("et.e", BOOMR.utils.serializeForUrl(compressed), true); } // clear until the next beacon impl.entries = []; // First Input Delay if (impl.firstInputDelay !== null) { BOOMR.addVar("et.fid", Math.ceil(impl.firstInputDelay), true); // should only go out on one beacon impl.firstInputDelay = null; } // Incremental Interaction to Next Paint var iinp = BOOMR.plugins.EventTiming.metrics .interactionToNextPaintData(impl.interactionsSinceLastBeacon); if (iinp) { BOOMR.addVar("et.inp.inc", iinp.duration, true); BOOMR.addVar("et.inp.inc.e", iinp.target, true); BOOMR.addVar("et.inp.inc.t", iinp.startTime, true); } // put all interactionsSinceLastBeacon into interactions for (var interactionId in impl.interactionsSinceLastBeacon) { impl.interactions[interactionId] = impl.interactionsSinceLastBeacon[interactionId]; } // clear our interactions since last beacon impl.interactionsSinceLastBeacon = {}; }, /** * Fired as the page is unloading */ onPageUnload: function(data) { // merge any recent interactions into the full interactions list // NOTE: Object.assign is OK to use as all browsers w/ EventTiming support Object.assign Object.assign(impl.interactions, impl.interactionsSinceLastBeacon); // Interaction to Next Paint var inp = BOOMR.plugins.EventTiming.metrics .interactionToNextPaintData(impl.interactions); if (inp) { BOOMR.addVar("et.inp", inp.duration, true); BOOMR.addVar("et.inp.e", inp.target, true); BOOMR.addVar("et.inp.t", inp.startTime, true); } }, /** * Fired on each EventTiming event * * @param {object[]} list List of EventTimings */ onEventTiming: function(list) { var entries = list.getEntries(); // look for the max INP for (var i = 0; i < entries.length; i++) { if (!entries[i].interactionId) { // // If interactionId is missing or 0, it means it's not a real // user interaction (e.g. !isTrusted or not a specific interaction event). // In this case, we won't use these EventTiming events for INP calculation. // // Ref: // https://www.w3.org/TR/2022/WD-event-timing-20220524/#sec-computing-interactionid // continue; } var interactionId = entries[i].interactionId; // save the max duration for this interaction impl.interactionsSinceLastBeacon[interactionId] = impl.interactionsSinceLastBeacon[interactionId] || {}; // update the latest duration if (!impl.interactionsSinceLastBeacon[interactionId].duration || entries[i].duration > impl.interactionsSinceLastBeacon[interactionId].duration) { // this duration is higher than what we saw for this ID before impl.interactionsSinceLastBeacon[interactionId] = { duration: Math.ceil(entries[i].duration), target: BOOMR.utils.makeSelector(entries[i].target), startTime: Math.floor(entries[i].startTime) }; } } // add to our tracked entries if (impl.maxEntries > 0 && impl.entries.length >= impl.maxEntries) { return; } // note we may add a few extra beyond maxEntries if the list is more than one impl.entries = impl.entries.concat(entries); }, /** * Fired on each FirstInput event * * @param {object[]} list List of EventTimings */ onFirstInput: function(list) { var i, newEntries = list.getEntries(); var fid = newEntries[0]; impl.entries = impl.entries.concat(newEntries); impl.firstInputDelay = Math.ceil(fid.processingStart - fid.startTime); // TTFI -- can be offset by Prerendered activationStart impl.timeToFirstInteraction = BOOMR.getPrerenderedOffset(Math.floor(fid.startTime)); // consider FID for INP impl.interactionsSinceLastBeacon.fid = { duration: Math.ceil(fid.duration), target: BOOMR.utils.makeSelector(fid.target), startTime: Math.floor(fid.startTime) }; } }; // // Exports // BOOMR.plugins.EventTiming = { /** * Initializes the plugin. * * @param {object} config Configuration * @param {boolean} [config.EventTiming.maxEntries=100] Maximum number of EventTiming entries to track, set * to -1 for unlimited * @param {number} [config.EventTiming.durationThreshold=16] EventTiming duration threshold * * @returns {@link BOOMR.plugins.EventTiming} The EventTiming plugin for chaining * @memberof BOOMR.plugins.EventTiming */ init: function(config) { BOOMR.utils.pluginConfig( impl, config, "EventTiming", ["enabled", "maxEntries", "durationThreshold"]); // skip initialization if not supported if (!this.is_supported()) { impl.initialized = true; } if (!impl.initialized) { BOOMR.subscribe("before_beacon", impl.onBeforeBeacon, null, impl); try { var w = BOOMR.window; impl.observerEvent = new w.PerformanceObserver(impl.onEventTiming); impl.observerEvent.observe({ type: ["event"], buffered: true, durationThreshold: impl.durationThreshold }); impl.observerFirstInput = new w.PerformanceObserver(impl.onFirstInput); impl.observerFirstInput.observe({ type: ["first-input"], buffered: true }); } catch (e) { impl.supported = false; } // Send some data (e.g. INP) at Unload BOOMR.subscribe("page_unload", impl.onPageUnload, null, impl); impl.initialized = true; } return this; }, /** * Whether or not this plugin is complete * * @returns {boolean} `true` if the plugin is complete * @memberof BOOMR.plugins.EventTiming */ is_complete: function() { return true; }, /** * Whether or not this plugin is enabled and EventTiming is supported. * * @returns {boolean} `true` if EventTiming plugin is enabled and supported. * @memberof BOOMR.plugins.EventTiming */ is_enabled: function() { return impl.initialized && this.is_supported(); }, /** * Whether or not EventTiming is supported in this browser. * * @returns {boolean} `true` if EventTiming is supported. * @memberof BOOMR.plugins.EventTiming */ is_supported: function() { var p; if (impl.supported !== null) { return impl.supported; } var w = BOOMR.window; // check for getEntriesByType and the entry type existing var p = BOOMR.getPerformance(); impl.supported = p && typeof w.PerformanceEventTiming !== "undefined" && typeof w.PerformanceObserver === "function"; if (impl.supported) { BOOMR.info("This user agent supports EventTiming", "et"); } return impl.supported; }, /** * Stops observing * * @memberof BOOMR.plugins.EventTiming */ stop: function() { if (impl.observerEvent) { impl.observerEvent.disconnect(); impl.observerEvent = null; } if (impl.observerFirstInput) { impl.observerFirstInput.disconnect(); impl.observerFirstInput = null; } }, /** * Exported metrics * * @memberof BOOMR.plugins.EventTiming */ metrics: { /** * Calculates the EventTiming count */ count: function() { return impl.entries.length; }, /** * Calculates the average EventTiming duration */ averageDuration: function() { if (impl.entries.length === 0) { return 0; } var sum = 0; for (var i = 0; i < impl.entries.length; i++) { sum += impl.entries[i].duration; } return sum / impl.entries.length; }, /** * Returns the observed First Input Delay */ firstInputDelay: function() { return impl.firstInputDelay; }, /** * Returns the observed Time to First Interaction */ timeToFirstInteraction: function() { return impl.timeToFirstInteraction; }, /** * Returns the Interaction to Next Paint metric for the session. */ interactionToNextPaint: function() { // merge both maps // NOTE: Object.assign is OK to use as all browsers w/ EventTiming support Object.assign var interactions = Object.assign({}, impl.interactions, impl.interactionsSinceLastBeacon); // Interaction to Next Paint from the combined list var inp = this.interactionToNextPaintData(interactions); return inp ? inp.duration : undefined; }, /** * Returns the Incremental Interaction to Next Paint (since last beacon) */ incrementalInteractionToNextPaint: function() { var iinp = this.interactionToNextPaintData(impl.interactionsSinceLastBeacon); return iinp ? iinp.duration : undefined; }, /** * Returns the INP details (duration, target, timestamp) based on the input * interaction array. * * @param {object} interactions Interactions map to use. */ interactionToNextPaintData: function(interactions) { if (typeof Object.values !== "function") { // Object.values not supported, must be an older browser that doesn't support INP anyway return null; } // reverse-sort all durations var durations = Object.values(interactions || impl.interactions).sort(function(a, b) { return b.duration - a.duration; }); // If interactionCount is not supported, we don't know how to calculate anything other than // the maximum INP. If interactionCount is less than 50, the 98th percentile is also the max. // NOTE: Discussion on interactionCount is in https://github.com/w3c/event-timing/issues/117 if (!("interactionCount" in performance)) { return durations[0]; } var percentileIndex = Math.floor(performance.interactionCount * 0.02); if (percentileIndex >= durations.length) { percentileIndex = durations.length - 1; } return durations[percentileIndex]; } } }; }());