From 568a240b8a77d10a0165ce7a6ed08b6caef1c0f5 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 28 Nov 2023 11:27:08 -0500 Subject: [PATCH] feat(web-vitals): Add INP support --- .../src/browser/web-vitals/getINP.ts | 215 ++++++++++++++++++ .../lib/polyfills/interactionCountPolyfill.ts | 62 +++++ .../src/browser/web-vitals/types/inp.ts | 80 +++++++ 3 files changed, 357 insertions(+) create mode 100644 packages/tracing-internal/src/browser/web-vitals/getINP.ts create mode 100644 packages/tracing-internal/src/browser/web-vitals/lib/polyfills/interactionCountPolyfill.ts create mode 100644 packages/tracing-internal/src/browser/web-vitals/types/inp.ts diff --git a/packages/tracing-internal/src/browser/web-vitals/getINP.ts b/packages/tracing-internal/src/browser/web-vitals/getINP.ts new file mode 100644 index 000000000000..546838bff15d --- /dev/null +++ b/packages/tracing-internal/src/browser/web-vitals/getINP.ts @@ -0,0 +1,215 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { bindReporter } from './lib/bindReporter'; +import { initMetric } from './lib/initMetric'; +import { observe } from './lib/observe'; +import { onHidden } from './lib/onHidden'; +import { getInteractionCount, initInteractionCountPolyfill } from './lib/polyfills/interactionCountPolyfill'; +import type { ReportCallback, ReportOpts } from './types'; +import type { INPMetric } from './types/inp'; + +interface Interaction { + id: number; + latency: number; + entries: PerformanceEventTiming[]; +} + +/** + * Returns the interaction count since the last bfcache restore (or for the + * full page lifecycle if there were no bfcache restores). + */ +const getInteractionCountForNavigation = (): number => { + return getInteractionCount(); +}; + +// To prevent unnecessary memory usage on pages with lots of interactions, +// store at most 10 of the longest interactions to consider as INP candidates. +const MAX_INTERACTIONS_TO_CONSIDER = 10; + +// A list of longest interactions on the page (by latency) sorted so the +// longest one is first. The list is as most MAX_INTERACTIONS_TO_CONSIDER long. +const longestInteractionList: Interaction[] = []; + +// A mapping of longest interactions by their interaction ID. +// This is used for faster lookup. +const longestInteractionMap: { [interactionId: string]: Interaction } = {}; + +/** + * Takes a performance entry and adds it to the list of worst interactions + * if its duration is long enough to make it among the worst. If the + * entry is part of an existing interaction, it is merged and the latency + * and entries list is updated as needed. + */ +const processEntry = (entry: PerformanceEventTiming): void => { + // The least-long of the 10 longest interactions. + const minLongestInteraction = longestInteractionList[longestInteractionList.length - 1]; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const existingInteraction = longestInteractionMap[entry.interactionId!]; + + // Only process the entry if it's possibly one of the ten longest, + // or if it's part of an existing interaction. + if ( + existingInteraction || + longestInteractionList.length < MAX_INTERACTIONS_TO_CONSIDER || + entry.duration > minLongestInteraction.latency + ) { + // If the interaction already exists, update it. Otherwise create one. + if (existingInteraction) { + existingInteraction.entries.push(entry); + existingInteraction.latency = Math.max(existingInteraction.latency, entry.duration); + } else { + const interaction = { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + id: entry.interactionId!, + latency: entry.duration, + entries: [entry], + }; + longestInteractionMap[interaction.id] = interaction; + longestInteractionList.push(interaction); + } + + // Sort the entries by latency (descending) and keep only the top ten. + longestInteractionList.sort((a, b) => b.latency - a.latency); + longestInteractionList.splice(MAX_INTERACTIONS_TO_CONSIDER).forEach(i => { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete longestInteractionMap[i.id]; + }); + } +}; + +/** + * Returns the estimated p98 longest interaction based on the stored + * interaction candidates and the interaction count for the current page. + */ +const estimateP98LongestInteraction = (): Interaction => { + const candidateInteractionIndex = Math.min( + longestInteractionList.length - 1, + Math.floor(getInteractionCountForNavigation() / 50), + ); + + return longestInteractionList[candidateInteractionIndex]; +}; + +/** + * Calculates the [INP](https://web.dev/responsiveness/) value for the current + * page and calls the `callback` function once the value is ready, along with + * the `event` performance entries reported for that interaction. The reported + * value is a `DOMHighResTimeStamp`. + * + * A custom `durationThreshold` configuration option can optionally be passed to + * control what `event-timing` entries are considered for INP reporting. The + * default threshold is `40`, which means INP scores of less than 40 are + * reported as 0. Note that this will not affect your 75th percentile INP value + * unless that value is also less than 40 (well below the recommended + * [good](https://web.dev/inp/#what-is-a-good-inp-score) threshold). + * + * If the `reportAllChanges` configuration option is set to `true`, the + * `callback` function will be called as soon as the value is initially + * determined as well as any time the value changes throughout the page + * lifespan. + * + * _**Important:** INP should be continually monitored for changes throughout + * the entire lifespan of a page—including if the user returns to the page after + * it's been hidden/backgrounded. However, since browsers often [will not fire + * additional callbacks once the user has backgrounded a + * page](https://developer.chrome.com/blog/page-lifecycle-api/#advice-hidden), + * `callback` is always called when the page's visibility state changes to + * hidden. As a result, the `callback` function might be called multiple times + * during the same page load._ + */ +export const onINP = (onReport: ReportCallback, opts?: ReportOpts): void => { + // Set defaults + // eslint-disable-next-line no-param-reassign + opts = opts || {}; + + // https://web.dev/inp/#what's-a-%22good%22-inp-value + // const thresholds = [200, 500]; + + // TODO(philipwalton): remove once the polyfill is no longer needed. + initInteractionCountPolyfill(); + + const metric = initMetric('INP'); + // eslint-disable-next-line prefer-const + let report: ReturnType; + + const handleEntries = (entries: INPMetric['entries']): void => { + entries.forEach(entry => { + if (entry.interactionId) { + processEntry(entry); + } + + // Entries of type `first-input` don't currently have an `interactionId`, + // so to consider them in INP we have to first check that an existing + // entry doesn't match the `duration` and `startTime`. + // Note that this logic assumes that `event` entries are dispatched + // before `first-input` entries. This is true in Chrome but it is not + // true in Firefox; however, Firefox doesn't support interactionId, so + // it's not an issue at the moment. + // TODO(philipwalton): remove once crbug.com/1325826 is fixed. + if (entry.entryType === 'first-input') { + const noMatchingEntry = !longestInteractionList.some(interaction => { + return interaction.entries.some(prevEntry => { + return entry.duration === prevEntry.duration && entry.startTime === prevEntry.startTime; + }); + }); + if (noMatchingEntry) { + processEntry(entry); + } + } + }); + + const inp = estimateP98LongestInteraction(); + + if (inp && inp.latency !== metric.value) { + metric.value = inp.latency; + metric.entries = inp.entries; + report(); + } + }; + + const po = observe('event', handleEntries, { + // Event Timing entries have their durations rounded to the nearest 8ms, + // so a duration of 40ms would be any event that spans 2.5 or more frames + // at 60Hz. This threshold is chosen to strike a balance between usefulness + // and performance. Running this callback for any interaction that spans + // just one or two frames is likely not worth the insight that could be + // gained. + durationThreshold: opts.durationThreshold || 40, + } as PerformanceObserverInit); + + report = bindReporter(onReport, metric, opts.reportAllChanges); + + if (po) { + // Also observe entries of type `first-input`. This is useful in cases + // where the first interaction is less than the `durationThreshold`. + po.observe({ type: 'first-input', buffered: true }); + + onHidden(() => { + handleEntries(po.takeRecords() as INPMetric['entries']); + + // If the interaction count shows that there were interactions but + // none were captured by the PerformanceObserver, report a latency of 0. + if (metric.value < 0 && getInteractionCountForNavigation() > 0) { + metric.value = 0; + metric.entries = []; + } + + report(true); + }); + } +}; diff --git a/packages/tracing-internal/src/browser/web-vitals/lib/polyfills/interactionCountPolyfill.ts b/packages/tracing-internal/src/browser/web-vitals/lib/polyfills/interactionCountPolyfill.ts new file mode 100644 index 000000000000..f6f02c04817b --- /dev/null +++ b/packages/tracing-internal/src/browser/web-vitals/lib/polyfills/interactionCountPolyfill.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Metric } from '../../types'; +import { observe } from '../observe'; + +declare global { + interface Performance { + interactionCount: number; + } +} + +let interactionCountEstimate = 0; +let minKnownInteractionId = Infinity; +let maxKnownInteractionId = 0; + +const updateEstimate = (entries: Metric['entries']): void => { + (entries as PerformanceEventTiming[]).forEach(e => { + if (e.interactionId) { + minKnownInteractionId = Math.min(minKnownInteractionId, e.interactionId); + maxKnownInteractionId = Math.max(maxKnownInteractionId, e.interactionId); + + interactionCountEstimate = maxKnownInteractionId ? (maxKnownInteractionId - minKnownInteractionId) / 7 + 1 : 0; + } + }); +}; + +let po: PerformanceObserver | undefined; + +/** + * Returns the `interactionCount` value using the native API (if available) + * or the polyfill estimate in this module. + */ +export const getInteractionCount = (): number => { + return po ? interactionCountEstimate : performance.interactionCount || 0; +}; + +/** + * Feature detects native support or initializes the polyfill if needed. + */ +export const initInteractionCountPolyfill = (): void => { + if ('interactionCount' in performance || po) return; + + po = observe('event', updateEstimate, { + type: 'event', + buffered: true, + durationThreshold: 0, + } as PerformanceObserverInit); +}; diff --git a/packages/tracing-internal/src/browser/web-vitals/types/inp.ts b/packages/tracing-internal/src/browser/web-vitals/types/inp.ts new file mode 100644 index 000000000000..37e8333fb2d8 --- /dev/null +++ b/packages/tracing-internal/src/browser/web-vitals/types/inp.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { LoadState, Metric, ReportCallback } from './base'; + +/** + * An INP-specific version of the Metric object. + */ +export interface INPMetric extends Metric { + name: 'INP'; + entries: PerformanceEventTiming[]; +} + +/** + * An object containing potentially-helpful debugging information that + * can be sent along with the INP value for the current page visit in order + * to help identify issues happening to real-users in the field. + */ +export interface INPAttribution { + /** + * A selector identifying the element that the user interacted with for + * the event corresponding to INP. This element will be the `target` of the + * `event` dispatched. + */ + eventTarget?: string; + /** + * The time when the user interacted for the event corresponding to INP. + * This time will match the `timeStamp` value of the `event` dispatched. + */ + eventTime?: number; + /** + * The `type` of the `event` dispatched corresponding to INP. + */ + eventType?: string; + /** + * The `PerformanceEventTiming` entry corresponding to INP. + */ + eventEntry?: PerformanceEventTiming; + /** + * The loading state of the document at the time when the even corresponding + * to INP occurred (see `LoadState` for details). If the interaction occurred + * while the document was loading and executing script (e.g. usually in the + * `dom-interactive` phase) it can result in long delays. + */ + loadState?: LoadState; +} + +/** + * An INP-specific version of the Metric object with attribution. + */ +export interface INPMetricWithAttribution extends INPMetric { + attribution: INPAttribution; +} + +/** + * An INP-specific version of the ReportCallback function. + */ +export interface INPReportCallback extends ReportCallback { + (metric: INPMetric): void; +} + +/** + * An INP-specific version of the ReportCallback function with attribution. + */ +export interface INPReportCallbackWithAttribution extends INPReportCallback { + (metric: INPMetricWithAttribution): void; +}