From 87b789cfc3a8e12ec40221c3ba3737ba531143cd Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 2 Dec 2024 10:55:40 +0100 Subject: [PATCH] feat(browser-utils): Update `web-vitals` to v4.2.4 (#14439) Update the vendored `web-vitals` library from [3.5.2 to 4.2.4](https://github.com/GoogleChrome/web-vitals/blob/main/CHANGELOG.md) Some noteable `web-vitals` changes: - breaking type changes (version 4.0.0) - INP fixes and code refactors - minor LCP fix for web vitals library being late-initialized. I don't think this applies to us but who knows... Further changes from our end: - The `onHidden` utility function was NOT updated to 4.2.4 due to the new version no longer triggering correctly for Safari 12.1-14.0 (which we [still support](https://docs.sentry.io/platforms/javascript/troubleshooting/supported-browsers/)). More details in the code comment - Added an optional param to `getNavigationEntry` since ww 4.2.4 only returns the entry if the `responseStart` time value is plausible. This is a good change for the library but since we also use the function to create other spans and attributes, I opted to leave things as they are for these use cases by passing in the flag to skip the plausibility check. This seems to be primarily a problem with Safari, which reports `responseStart: 0` sometimes. - Continued to add checks for the existence of `WINDOW.document` which `web-vitals` assumes to be present - Continued to add `longtask` to the array of available types in the `observe` function --- .../src/metrics/browserMetrics.ts | 4 +- .../src/metrics/web-vitals/README.md | 10 +- .../src/metrics/web-vitals/getCLS.ts | 6 +- .../src/metrics/web-vitals/getFID.ts | 5 +- .../src/metrics/web-vitals/getINP.ts | 160 ++++-------------- .../src/metrics/web-vitals/getLCP.ts | 36 ++-- .../web-vitals/lib/generateUniqueID.ts | 2 +- .../web-vitals/lib/getNavigationEntry.ts | 23 ++- .../web-vitals/lib/getVisibilityWatcher.ts | 12 +- .../metrics/web-vitals/lib/interactions.ts | 136 +++++++++++++++ .../src/metrics/web-vitals/lib/observe.ts | 13 +- .../src/metrics/web-vitals/lib/onHidden.ts | 10 ++ .../lib/polyfills/interactionCountPolyfill.ts | 5 +- .../src/metrics/web-vitals/lib/runOnce.ts | 10 +- .../src/metrics/web-vitals/lib/whenIdle.ts | 40 +++++ .../src/metrics/web-vitals/onFCP.ts | 8 +- .../src/metrics/web-vitals/onTTFB.ts | 22 +-- .../src/metrics/web-vitals/types.ts | 38 ++--- .../src/metrics/web-vitals/types/base.ts | 40 ++--- .../src/metrics/web-vitals/types/cls.ts | 14 -- .../src/metrics/web-vitals/types/fcp.ts | 17 +- .../src/metrics/web-vitals/types/fid.ts | 22 +-- .../src/metrics/web-vitals/types/inp.ts | 102 +++++++---- .../src/metrics/web-vitals/types/lcp.ts | 19 +-- .../src/metrics/web-vitals/types/polyfills.ts | 13 -- .../src/metrics/web-vitals/types/ttfb.ts | 51 +++--- 26 files changed, 443 insertions(+), 375 deletions(-) create mode 100644 packages/browser-utils/src/metrics/web-vitals/lib/interactions.ts create mode 100644 packages/browser-utils/src/metrics/web-vitals/lib/whenIdle.ts diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index cac52aba468d..a3200abdeeaa 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -420,7 +420,7 @@ export function _addMeasureSpans( duration: number, timeOrigin: number, ): number { - const navEntry = getNavigationEntry(); + const navEntry = getNavigationEntry(false); const requestTime = msToSec(navEntry ? navEntry.requestStart : 0); // Because performance.measure accepts arbitrary timestamps it can produce // spans that happen before the browser even makes a request for the page. @@ -671,7 +671,7 @@ function setResourceEntrySizeData( * ttfb information is added via vendored web vitals library. */ function _addTtfbRequestTimeToMeasurements(_measurements: Measurements): void { - const navEntry = getNavigationEntry(); + const navEntry = getNavigationEntry(false); if (!navEntry) { return; } diff --git a/packages/browser-utils/src/metrics/web-vitals/README.md b/packages/browser-utils/src/metrics/web-vitals/README.md index d779969dbe5d..c4b2b1a1c0cf 100644 --- a/packages/browser-utils/src/metrics/web-vitals/README.md +++ b/packages/browser-utils/src/metrics/web-vitals/README.md @@ -5,7 +5,7 @@ This was vendored from: https://github.com/GoogleChrome/web-vitals: v3.5.2 The commit SHA used is: -[7b44bea0d5ba6629c5fd34c3a09cc683077871d0](https://github.com/GoogleChrome/web-vitals/tree/7b44bea0d5ba6629c5fd34c3a09cc683077871d0) +[3d2b3dc8576cc003618952fa39902fab764a53e2](https://github.com/GoogleChrome/web-vitals/tree/3d2b3dc8576cc003618952fa39902fab764a53e2) Current vendored web vitals are: @@ -27,6 +27,14 @@ web-vitals only report once per pageload. ## CHANGELOG +https://github.com/getsentry/sentry-javascript/pull/14439 + +- Bumped from Web Vitals v3.5.2 to v4.2.4 + +https://github.com/getsentry/sentry-javascript/pull/11391 + +- Bumped from Web Vitals v3.0.4 to v3.5.2 + https://github.com/getsentry/sentry-javascript/pull/5987 - Bumped from Web Vitals v2.1.0 to v3.0.4 diff --git a/packages/browser-utils/src/metrics/web-vitals/getCLS.ts b/packages/browser-utils/src/metrics/web-vitals/getCLS.ts index 380cf2e54d47..a9b6f9f26999 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getCLS.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getCLS.ts @@ -20,7 +20,7 @@ import { observe } from './lib/observe'; import { onHidden } from './lib/onHidden'; import { runOnce } from './lib/runOnce'; import { onFCP } from './onFCP'; -import type { CLSMetric, CLSReportCallback, MetricRatingThresholds, ReportOpts } from './types'; +import type { CLSMetric, MetricRatingThresholds, ReportOpts } from './types'; /** Thresholds for CLS. See https://web.dev/articles/cls#what_is_a_good_cls_score */ export const CLSThresholds: MetricRatingThresholds = [0.1, 0.25]; @@ -46,7 +46,7 @@ export const CLSThresholds: MetricRatingThresholds = [0.1, 0.25]; * hidden. As a result, the `callback` function might be called multiple times * during the same page load._ */ -export const onCLS = (onReport: CLSReportCallback, opts: ReportOpts = {}): void => { +export const onCLS = (onReport: (metric: CLSMetric) => void, opts: ReportOpts = {}) => { // Start monitoring FCP so we can only report CLS if FCP is also reported. // Note: this is done to match the current behavior of CrUX. onFCP( @@ -57,7 +57,7 @@ export const onCLS = (onReport: CLSReportCallback, opts: ReportOpts = {}): void let sessionValue = 0; let sessionEntries: LayoutShift[] = []; - const handleEntries = (entries: LayoutShift[]): void => { + const handleEntries = (entries: LayoutShift[]) => { entries.forEach(entry => { // Only count layout shifts without recent user input. if (!entry.hadRecentInput) { diff --git a/packages/browser-utils/src/metrics/web-vitals/getFID.ts b/packages/browser-utils/src/metrics/web-vitals/getFID.ts index 92543b89e170..e8fd4fa908e7 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getFID.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getFID.ts @@ -21,7 +21,7 @@ import { observe } from './lib/observe'; import { onHidden } from './lib/onHidden'; import { runOnce } from './lib/runOnce'; import { whenActivated } from './lib/whenActivated'; -import type { FIDMetric, FIDReportCallback, MetricRatingThresholds, ReportOpts } from './types'; +import type { FIDMetric, MetricRatingThresholds, ReportOpts } from './types'; /** Thresholds for FID. See https://web.dev/articles/fid#what_is_a_good_fid_score */ export const FIDThresholds: MetricRatingThresholds = [100, 300]; @@ -35,7 +35,7 @@ export const FIDThresholds: MetricRatingThresholds = [100, 300]; * _**Important:** since FID is only reported after the user interacts with the * page, it's possible that it will not be reported for some page loads._ */ -export const onFID = (onReport: FIDReportCallback, opts: ReportOpts = {}) => { +export const onFID = (onReport: (metric: FIDMetric) => void, opts: ReportOpts = {}) => { whenActivated(() => { const visibilityWatcher = getVisibilityWatcher(); const metric = initMetric('FID'); @@ -56,6 +56,7 @@ export const onFID = (onReport: FIDReportCallback, opts: ReportOpts = {}) => { }; const po = observe('first-input', handleEntries); + report = bindReporter(onReport, metric, FIDThresholds, opts.reportAllChanges); if (po) { diff --git a/packages/browser-utils/src/metrics/web-vitals/getINP.ts b/packages/browser-utils/src/metrics/web-vitals/getINP.ts index e66f17eed2a1..ba6234e43c18 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getINP.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getINP.ts @@ -17,100 +17,18 @@ import { WINDOW } from '../../types'; import { bindReporter } from './lib/bindReporter'; import { initMetric } from './lib/initMetric'; +import { DEFAULT_DURATION_THRESHOLD, estimateP98LongestInteraction, processInteractionEntry } from './lib/interactions'; import { observe } from './lib/observe'; import { onHidden } from './lib/onHidden'; -import { getInteractionCount, initInteractionCountPolyfill } from './lib/polyfills/interactionCountPolyfill'; +import { initInteractionCountPolyfill } from './lib/polyfills/interactionCountPolyfill'; import { whenActivated } from './lib/whenActivated'; -import type { INPMetric, INPReportCallback, MetricRatingThresholds, ReportOpts } from './types'; +import { whenIdle } from './lib/whenIdle'; -interface Interaction { - id: number; - latency: number; - entries: PerformanceEventTiming[]; -} +import type { INPMetric, MetricRatingThresholds, ReportOpts } from './types'; /** Thresholds for INP. See https://web.dev/articles/inp#what_is_a_good_inp_score */ export const INPThresholds: MetricRatingThresholds = [200, 500]; -// Used to store the interaction count after a bfcache restore, since p98 -// interaction latencies should only consider the current navigation. -const prevInteractionCount = 0; - -/** - * Returns the interaction count since the last bfcache restore (or for the - * full page lifecycle if there were no bfcache restores). - */ -const getInteractionCountForNavigation = () => { - return getInteractionCount() - prevInteractionCount; -}; - -// 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) => { - // The least-long of the 10 longest interactions. - const minLongestInteraction = longestInteractionList[longestInteractionList.length - 1]; - - 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 || - (minLongestInteraction && 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 = { - 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 = () => { - const candidateInteractionIndex = Math.min( - longestInteractionList.length - 1, - Math.floor(getInteractionCountForNavigation() / 50), - ); - - return longestInteractionList[candidateInteractionIndex]; -}; - /** * Calculates the [INP](https://web.dev/articles/inp) value for the current * page and calls the `callback` function once the value is ready, along with @@ -138,7 +56,12 @@ const estimateP98LongestInteraction = () => { * hidden. As a result, the `callback` function might be called multiple times * during the same page load._ */ -export const onINP = (onReport: INPReportCallback, opts: ReportOpts = {}) => { +export const onINP = (onReport: (metric: INPMetric) => void, opts: ReportOpts = {}) => { + // Return if the browser doesn't support all APIs needed to measure INP. + if (!('PerformanceEventTiming' in WINDOW && 'interactionId' in PerformanceEventTiming.prototype)) { + return; + } + whenActivated(() => { // TODO(philipwalton): remove once the polyfill is no longer needed. initInteractionCountPolyfill(); @@ -148,37 +71,23 @@ export const onINP = (onReport: INPReportCallback, opts: ReportOpts = {}) => { let report: ReturnType; const handleEntries = (entries: INPMetric['entries']) => { - 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 (the only browser - // that currently supports INP). - // 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); - } + // Queue the `handleEntries()` callback in the next idle task. + // This is needed to increase the chances that all event entries that + // occurred between the user interaction and the next paint + // have been dispatched. Note: there is currently an experiment + // running in Chrome (EventTimingKeypressAndCompositionInteractionId) + // 123+ that if rolled out fully may make this no longer necessary. + whenIdle(() => { + entries.forEach(processInteractionEntry); + + const inp = estimateP98LongestInteraction(); + + if (inp && inp.latency !== metric.value) { + metric.value = inp.latency; + metric.entries = inp.entries; + report(); } }); - - const inp = estimateP98LongestInteraction(); - - if (inp && inp.latency !== metric.value) { - metric.value = inp.latency; - metric.entries = inp.entries; - report(); - } }; const po = observe('event', handleEntries, { @@ -188,29 +97,18 @@ export const onINP = (onReport: INPReportCallback, opts: ReportOpts = {}) => { // 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 != null ? opts.durationThreshold : 40, - } as PerformanceObserverInit); + durationThreshold: opts.durationThreshold != null ? opts.durationThreshold : DEFAULT_DURATION_THRESHOLD, + }); report = bindReporter(onReport, metric, INPThresholds, opts.reportAllChanges); if (po) { - // If browser supports interactionId (and so supports INP), also - // observe entries of type `first-input`. This is useful in cases + // Also observe entries of type `first-input`. This is useful in cases // where the first interaction is less than the `durationThreshold`. - if ('PerformanceEventTiming' in WINDOW && 'interactionId' in PerformanceEventTiming.prototype) { - po.observe({ type: 'first-input', buffered: true }); - } + 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/browser-utils/src/metrics/web-vitals/getLCP.ts b/packages/browser-utils/src/metrics/web-vitals/getLCP.ts index b50358c98d61..17fd374e7611 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getLCP.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getLCP.ts @@ -23,7 +23,8 @@ import { observe } from './lib/observe'; import { onHidden } from './lib/onHidden'; import { runOnce } from './lib/runOnce'; import { whenActivated } from './lib/whenActivated'; -import type { LCPMetric, LCPReportCallback, MetricRatingThresholds, ReportOpts } from './types'; +import { whenIdle } from './lib/whenIdle'; +import type { LCPMetric, MetricRatingThresholds, ReportOpts } from './types'; /** Thresholds for LCP. See https://web.dev/articles/lcp#what_is_a_good_lcp_score */ export const LCPThresholds: MetricRatingThresholds = [2500, 4000]; @@ -41,28 +42,34 @@ const reportedMetricIDs: Record = {}; * performance entry is dispatched, or once the final value of the metric has * been determined. */ -export const onLCP = (onReport: LCPReportCallback, opts: ReportOpts = {}) => { +export const onLCP = (onReport: (metric: LCPMetric) => void, opts: ReportOpts = {}) => { whenActivated(() => { const visibilityWatcher = getVisibilityWatcher(); const metric = initMetric('LCP'); let report: ReturnType; const handleEntries = (entries: LCPMetric['entries']) => { - const lastEntry = entries[entries.length - 1] as LargestContentfulPaint; - if (lastEntry) { + // If reportAllChanges is set then call this function for each entry, + // otherwise only consider the last one. + if (!opts.reportAllChanges) { + // eslint-disable-next-line no-param-reassign + entries = entries.slice(-1); + } + + entries.forEach(entry => { // Only report if the page wasn't hidden prior to LCP. - if (lastEntry.startTime < visibilityWatcher.firstHiddenTime) { + if (entry.startTime < visibilityWatcher.firstHiddenTime) { // The startTime attribute returns the value of the renderTime if it is // not 0, and the value of the loadTime otherwise. The activationStart // reference is used because LCP should be relative to page activation - // rather than navigation start if the page was prerendered. But in cases + // rather than navigation start if the page was pre-rendered. But in cases // where `activationStart` occurs after the LCP, this time should be // clamped at 0. - metric.value = Math.max(lastEntry.startTime - getActivationStart(), 0); - metric.entries = [lastEntry]; + metric.value = Math.max(entry.startTime - getActivationStart(), 0); + metric.entries = [entry]; report(); } - } + }); }; const po = observe('largest-contentful-paint', handleEntries); @@ -83,11 +90,14 @@ export const onLCP = (onReport: LCPReportCallback, opts: ReportOpts = {}) => { // stops LCP observation, it's unreliable since it can be programmatically // generated. See: https://github.com/GoogleChrome/web-vitals/issues/75 ['keydown', 'click'].forEach(type => { + // Wrap in a setTimeout so the callback is run in a separate task + // to avoid extending the keyboard/click handler to reduce INP impact + // https://github.com/GoogleChrome/web-vitals/issues/383 if (WINDOW.document) { - // Wrap in a setTimeout so the callback is run in a separate task - // to avoid extending the keyboard/click handler to reduce INP impact - // https://github.com/GoogleChrome/web-vitals/issues/383 - addEventListener(type, () => setTimeout(stopListening, 0), true); + addEventListener(type, () => whenIdle(stopListening as () => void), { + once: true, + capture: true, + }); } }); diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/generateUniqueID.ts b/packages/browser-utils/src/metrics/web-vitals/lib/generateUniqueID.ts index bdecdc6220ad..637d01398e0a 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/generateUniqueID.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/generateUniqueID.ts @@ -20,5 +20,5 @@ * @return {string} */ export const generateUniqueID = () => { - return `v3-${Date.now()}-${Math.floor(Math.random() * (9e12 - 1)) + 1e12}`; + return `v4-${Date.now()}-${Math.floor(Math.random() * (9e12 - 1)) + 1e12}`; }; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/getNavigationEntry.ts b/packages/browser-utils/src/metrics/web-vitals/lib/getNavigationEntry.ts index 63cfa04b3ad4..1e8521c2ddc6 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/getNavigationEntry.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/getNavigationEntry.ts @@ -15,8 +15,25 @@ */ import { WINDOW } from '../../../types'; -import type { NavigationTimingPolyfillEntry } from '../types'; -export const getNavigationEntry = (): PerformanceNavigationTiming | NavigationTimingPolyfillEntry | undefined => { - return WINDOW.performance && performance.getEntriesByType && performance.getEntriesByType('navigation')[0]; +// sentry-specific change: +// add optional param to not check for responseStart (see comment below) +export const getNavigationEntry = (checkResponseStart = true): PerformanceNavigationTiming | void => { + const navigationEntry = + WINDOW.performance && WINDOW.performance.getEntriesByType && WINDOW.performance.getEntriesByType('navigation')[0]; + // Check to ensure the `responseStart` property is present and valid. + // In some cases no value is reported by the browser (for + // privacy/security reasons), and in other cases (bugs) the value is + // negative or is larger than the current page time. Ignore these cases: + // https://github.com/GoogleChrome/web-vitals/issues/137 + // https://github.com/GoogleChrome/web-vitals/issues/162 + // https://github.com/GoogleChrome/web-vitals/issues/275 + if ( + // sentry-specific change: + // We don't want to check for responseStart for our own use of `getNavigationEntry` + !checkResponseStart || + (navigationEntry && navigationEntry.responseStart > 0 && navigationEntry.responseStart < performance.now()) + ) { + return navigationEntry; + } }; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts b/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts index c254ad1259d9..b658be9475e9 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts @@ -24,7 +24,7 @@ const initHiddenTime = () => { // that visibility state is always 'hidden' during prerendering, so we have // to ignore that case until prerendering finishes (see: `prerenderingchange` // event logic below). - firstHiddenTime = WINDOW.document!.visibilityState === 'hidden' && !WINDOW.document!.prerendering ? 0 : Infinity; + return WINDOW.document!.visibilityState === 'hidden' && !WINDOW.document!.prerendering ? 0 : Infinity; }; const onVisibilityUpdate = (event: Event) => { @@ -41,8 +41,7 @@ const onVisibilityUpdate = (event: Event) => { firstHiddenTime = event.type === 'visibilitychange' ? event.timeStamp : 0; // Remove all listeners now that a `firstHiddenTime` value has been set. - removeEventListener('visibilitychange', onVisibilityUpdate, true); - removeEventListener('prerenderingchange', onVisibilityUpdate, true); + removeChangeListeners(); } }; @@ -55,13 +54,18 @@ const addChangeListeners = () => { addEventListener('prerenderingchange', onVisibilityUpdate, true); }; +const removeChangeListeners = () => { + removeEventListener('visibilitychange', onVisibilityUpdate, true); + removeEventListener('prerenderingchange', onVisibilityUpdate, true); +}; + export const getVisibilityWatcher = () => { if (WINDOW.document && firstHiddenTime < 0) { // If the document is hidden when this code runs, assume it was hidden // since navigation start. This isn't a perfect heuristic, but it's the // best we can do until an API is available to support querying past // visibilityState. - initHiddenTime(); + firstHiddenTime = initHiddenTime(); addChangeListeners(); } return { diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/interactions.ts b/packages/browser-utils/src/metrics/web-vitals/lib/interactions.ts new file mode 100644 index 000000000000..6d6390755656 --- /dev/null +++ b/packages/browser-utils/src/metrics/web-vitals/lib/interactions.ts @@ -0,0 +1,136 @@ +/* + * Copyright 2024 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 { getInteractionCount } from './polyfills/interactionCountPolyfill'; + +interface Interaction { + id: number; + latency: number; + entries: PerformanceEventTiming[]; +} + +interface EntryPreProcessingHook { + (entry: PerformanceEventTiming): void; +} + +// A list of longest interactions on the page (by latency) sorted so the +// longest one is first. The list is at most MAX_INTERACTIONS_TO_CONSIDER long. +export const longestInteractionList: Interaction[] = []; + +// A mapping of longest interactions by their interaction ID. +// This is used for faster lookup. +export const longestInteractionMap: Map = new Map(); + +// The default `durationThreshold` used across this library for observing +// `event` entries via PerformanceObserver. +export const DEFAULT_DURATION_THRESHOLD = 40; + +// Used to store the interaction count after a bfcache restore, since p98 +// interaction latencies should only consider the current navigation. +let prevInteractionCount = 0; + +/** + * Returns the interaction count since the last bfcache restore (or for the + * full page lifecycle if there were no bfcache restores). + */ +const getInteractionCountForNavigation = () => { + return getInteractionCount() - prevInteractionCount; +}; + +export const resetInteractions = () => { + prevInteractionCount = getInteractionCount(); + longestInteractionList.length = 0; + longestInteractionMap.clear(); +}; + +/** + * Returns the estimated p98 longest interaction based on the stored + * interaction candidates and the interaction count for the current page. + */ +export const estimateP98LongestInteraction = () => { + const candidateInteractionIndex = Math.min( + longestInteractionList.length - 1, + Math.floor(getInteractionCountForNavigation() / 50), + ); + + return longestInteractionList[candidateInteractionIndex]; +}; + +// 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 callback functions to run before each entry is processed. + * Exposing this list allows the attribution build to hook into the + * entry processing pipeline. + */ +export const entryPreProcessingCallbacks: EntryPreProcessingHook[] = []; + +/** + * 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. + */ +export const processInteractionEntry = (entry: PerformanceEventTiming) => { + entryPreProcessingCallbacks.forEach(cb => cb(entry)); + + // Skip further processing for entries that cannot be INP candidates. + if (!(entry.interactionId || entry.entryType === 'first-input')) return; + + // The least-long of the 10 longest interactions. + const minLongestInteraction = longestInteractionList[longestInteractionList.length - 1]; + + const existingInteraction = longestInteractionMap.get(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 || + (minLongestInteraction && entry.duration > minLongestInteraction.latency) + ) { + // If the interaction already exists, update it. Otherwise create one. + if (existingInteraction) { + // If the new entry has a longer duration, replace the old entries, + // otherwise add to the array. + if (entry.duration > existingInteraction.latency) { + existingInteraction.entries = [entry]; + existingInteraction.latency = entry.duration; + } else if ( + entry.duration === existingInteraction.latency && + entry.startTime === (existingInteraction.entries[0] && existingInteraction.entries[0].startTime) + ) { + existingInteraction.entries.push(entry); + } + } else { + const interaction = { + id: entry.interactionId!, + latency: entry.duration, + entries: [entry], + }; + longestInteractionMap.set(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); + if (longestInteractionList.length > MAX_INTERACTIONS_TO_CONSIDER) { + longestInteractionList.splice(MAX_INTERACTIONS_TO_CONSIDER).forEach(i => longestInteractionMap.delete(i.id)); + } + } +}; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts b/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts index d763b14dfdf0..ad71468b6fb6 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts @@ -14,16 +14,19 @@ * limitations under the License. */ -import type { FirstInputPolyfillEntry, NavigationTimingPolyfillEntry } from '../types'; - interface PerformanceEntryMap { event: PerformanceEventTiming[]; - paint: PerformancePaintTiming[]; + 'first-input': PerformanceEventTiming[]; 'layout-shift': LayoutShift[]; 'largest-contentful-paint': LargestContentfulPaint[]; - 'first-input': PerformanceEventTiming[] | FirstInputPolyfillEntry[]; - navigation: PerformanceNavigationTiming[] | NavigationTimingPolyfillEntry[]; + 'long-animation-frame': PerformanceLongAnimationFrameTiming[]; + paint: PerformancePaintTiming[]; + navigation: PerformanceNavigationTiming[]; resource: PerformanceResourceTiming[]; + // Sentry-specific change: + // We add longtask as a supported entry type as we use this in + // our `instrumentPerformanceObserver` function also observes 'longtask' + // entries. longtask: PerformanceEntry[]; } diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts b/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts index 9f65196d27a2..81d83caa53b5 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts @@ -20,6 +20,16 @@ export interface OnHiddenCallback { (event: Event): void; } +// Sentry-specific change: +// This function's logic was NOT updated to web-vitals 4.2.4 but we continue +// to use the web-vitals 3.5.2 due to us having stricter browser support. +// PR with context that made the changes: https://github.com/GoogleChrome/web-vitals/pull/442/files#r1530492402 +// The PR removed listening to the `pagehide` event, in favour of only listening to `visibilitychange` event. +// This is "more correct" but some browsers we still support (Safari 12.1-14.0) don't fully support `visibilitychange` +// or have known bugs w.r.t the `visibilitychange` event. +// TODO (v9): If we decide to drop support for Safari 12.1-14.0, we can use the logic from web-vitals 4.2.4 +// In this case, we also need to update the integration tests that currently trigger the `pagehide` event to +// simulate the page being hidden. export const onHidden = (cb: OnHiddenCallback) => { const onHiddenOrPageHide = (event: Event) => { if (event.type === 'pagehide' || (WINDOW.document && WINDOW.document.visibilityState === 'hidden')) { diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/polyfills/interactionCountPolyfill.ts b/packages/browser-utils/src/metrics/web-vitals/lib/polyfills/interactionCountPolyfill.ts index f6f02c04817b..4da20a602335 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/polyfills/interactionCountPolyfill.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/polyfills/interactionCountPolyfill.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import type { Metric } from '../../types'; import { observe } from '../observe'; declare global { @@ -27,8 +26,8 @@ let interactionCountEstimate = 0; let minKnownInteractionId = Infinity; let maxKnownInteractionId = 0; -const updateEstimate = (entries: Metric['entries']): void => { - (entries as PerformanceEventTiming[]).forEach(e => { +const updateEstimate = (entries: PerformanceEventTiming[]) => { + entries.forEach(e => { if (e.interactionId) { minKnownInteractionId = Math.min(minKnownInteractionId, e.interactionId); maxKnownInteractionId = Math.max(maxKnownInteractionId, e.interactionId); diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/runOnce.ts b/packages/browser-utils/src/metrics/web-vitals/lib/runOnce.ts index c232fa16b487..f2de2eadd2d9 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/runOnce.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/runOnce.ts @@ -14,15 +14,11 @@ * limitations under the License. */ -export interface RunOnceCallback { - (arg: unknown): void; -} - -export const runOnce = (cb: RunOnceCallback) => { +export const runOnce = (cb: () => void) => { let called = false; - return (arg: unknown) => { + return () => { if (!called) { - cb(arg); + cb(); called = true; } }; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/whenIdle.ts b/packages/browser-utils/src/metrics/web-vitals/lib/whenIdle.ts new file mode 100644 index 000000000000..c140864b3539 --- /dev/null +++ b/packages/browser-utils/src/metrics/web-vitals/lib/whenIdle.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2024 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 { WINDOW } from '../../../types'; +import { onHidden } from './onHidden'; +import { runOnce } from './runOnce'; + +/** + * Runs the passed callback during the next idle period, or immediately + * if the browser's visibility state is (or becomes) hidden. + */ +export const whenIdle = (cb: () => void): number => { + const rIC = WINDOW.requestIdleCallback || WINDOW.setTimeout; + + let handle = -1; + // eslint-disable-next-line no-param-reassign + cb = runOnce(cb) as () => void; + // If the document is hidden, run the callback immediately, otherwise + // race an idle callback with the next `visibilitychange` event. + if (WINDOW.document && WINDOW.document.visibilityState === 'hidden') { + cb(); + } else { + handle = rIC(cb); + onHidden(cb); + } + return handle; +}; diff --git a/packages/browser-utils/src/metrics/web-vitals/onFCP.ts b/packages/browser-utils/src/metrics/web-vitals/onFCP.ts index b08973fefb40..d01001ad48ec 100644 --- a/packages/browser-utils/src/metrics/web-vitals/onFCP.ts +++ b/packages/browser-utils/src/metrics/web-vitals/onFCP.ts @@ -20,7 +20,7 @@ import { getVisibilityWatcher } from './lib/getVisibilityWatcher'; import { initMetric } from './lib/initMetric'; import { observe } from './lib/observe'; import { whenActivated } from './lib/whenActivated'; -import type { FCPMetric, FCPReportCallback, MetricRatingThresholds, ReportOpts } from './types'; +import type { FCPMetric, MetricRatingThresholds, ReportOpts } from './types'; /** Thresholds for FCP. See https://web.dev/articles/fcp#what_is_a_good_fcp_score */ export const FCPThresholds: MetricRatingThresholds = [1800, 3000]; @@ -31,14 +31,14 @@ export const FCPThresholds: MetricRatingThresholds = [1800, 3000]; * relevant `paint` performance entry used to determine the value. The reported * value is a `DOMHighResTimeStamp`. */ -export const onFCP = (onReport: FCPReportCallback, opts: ReportOpts = {}): void => { +export const onFCP = (onReport: (metric: FCPMetric) => void, opts: ReportOpts = {}) => { whenActivated(() => { const visibilityWatcher = getVisibilityWatcher(); const metric = initMetric('FCP'); let report: ReturnType; const handleEntries = (entries: FCPMetric['entries']) => { - (entries as PerformancePaintTiming[]).forEach(entry => { + entries.forEach(entry => { if (entry.name === 'first-contentful-paint') { po!.disconnect(); @@ -59,7 +59,7 @@ export const onFCP = (onReport: FCPReportCallback, opts: ReportOpts = {}): void const po = observe('paint', handleEntries); if (po) { - report = bindReporter(onReport, metric, FCPThresholds, opts!.reportAllChanges); + report = bindReporter(onReport, metric, FCPThresholds, opts.reportAllChanges); } }); }; diff --git a/packages/browser-utils/src/metrics/web-vitals/onTTFB.ts b/packages/browser-utils/src/metrics/web-vitals/onTTFB.ts index 85f9b99bc0f4..7c8c1bb0b5c1 100644 --- a/packages/browser-utils/src/metrics/web-vitals/onTTFB.ts +++ b/packages/browser-utils/src/metrics/web-vitals/onTTFB.ts @@ -20,7 +20,7 @@ import { getActivationStart } from './lib/getActivationStart'; import { getNavigationEntry } from './lib/getNavigationEntry'; import { initMetric } from './lib/initMetric'; import { whenActivated } from './lib/whenActivated'; -import type { MetricRatingThresholds, ReportOpts, TTFBReportCallback } from './types'; +import type { MetricRatingThresholds, ReportOpts, TTFBMetric } from './types'; /** Thresholds for TTFB. See https://web.dev/articles/ttfb#what_is_a_good_ttfb_score */ export const TTFBThresholds: MetricRatingThresholds = [800, 1800]; @@ -55,31 +55,21 @@ const whenReady = (callback: () => void) => { * includes time spent on DNS lookup, connection negotiation, network latency, * and server processing time. */ -export const onTTFB = (onReport: TTFBReportCallback, opts: ReportOpts = {}) => { +export const onTTFB = (onReport: (metric: TTFBMetric) => void, opts: ReportOpts = {}) => { const metric = initMetric('TTFB'); const report = bindReporter(onReport, metric, TTFBThresholds, opts.reportAllChanges); whenReady(() => { - const navEntry = getNavigationEntry(); - - if (navEntry) { - const responseStart = navEntry.responseStart; - - // In some cases no value is reported by the browser (for - // privacy/security reasons), and in other cases (bugs) the value is - // negative or is larger than the current page time. Ignore these cases: - // https://github.com/GoogleChrome/web-vitals/issues/137 - // https://github.com/GoogleChrome/web-vitals/issues/162 - // https://github.com/GoogleChrome/web-vitals/issues/275 - if (responseStart <= 0 || responseStart > performance.now()) return; + const navigationEntry = getNavigationEntry(); + if (navigationEntry) { // The activationStart reference is used because TTFB should be // relative to page activation rather than navigation start if the // page was prerendered. But in cases where `activationStart` occurs // after the first byte is received, this time should be clamped at 0. - metric.value = Math.max(responseStart - getActivationStart(), 0); + metric.value = Math.max(navigationEntry.responseStart - getActivationStart(), 0); - metric.entries = [navEntry]; + metric.entries = [navigationEntry]; report(true); } }); diff --git a/packages/browser-utils/src/metrics/web-vitals/types.ts b/packages/browser-utils/src/metrics/web-vitals/types.ts index 41793221311b..5a17b811db96 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -import type { FirstInputPolyfillCallback } from './types/polyfills'; - export * from './types/base'; export * from './types/polyfills'; @@ -26,22 +24,6 @@ export * from './types/inp'; export * from './types/lcp'; export * from './types/ttfb'; -// -------------------------------------------------------------------------- -// Web Vitals package globals -// -------------------------------------------------------------------------- - -export interface WebVitalsGlobal { - firstInputPolyfill: (onFirstInput: FirstInputPolyfillCallback) => void; - resetFirstInputPolyfill: () => void; - firstHiddenTime: number; -} - -declare global { - interface Window { - webVitals: WebVitalsGlobal; - } -} - // -------------------------------------------------------------------------- // Everything below is modifications to built-in modules. // -------------------------------------------------------------------------- @@ -78,7 +60,7 @@ declare global { // https://wicg.github.io/event-timing/#sec-performance-event-timing interface PerformanceEventTiming extends PerformanceEntry { duration: DOMHighResTimeStamp; - interactionId?: number; + interactionId: number; } // https://wicg.github.io/layout-instability/#sec-layout-shift-attribution @@ -97,11 +79,17 @@ declare global { // https://w3c.github.io/largest-contentful-paint/#sec-largest-contentful-paint-interface interface LargestContentfulPaint extends PerformanceEntry { - renderTime: DOMHighResTimeStamp; - loadTime: DOMHighResTimeStamp; - size: number; - id: string; - url: string; - element?: Element; + readonly renderTime: DOMHighResTimeStamp; + readonly loadTime: DOMHighResTimeStamp; + readonly size: number; + readonly id: string; + readonly url: string; + readonly element: Element | null; + } + + // https://w3c.github.io/long-animation-frame/#sec-PerformanceLongAnimationFrameTiming + interface PerformanceLongAnimationFrameTiming extends PerformanceEntry { + renderStart: DOMHighResTimeStamp; + duration: DOMHighResTimeStamp; } } diff --git a/packages/browser-utils/src/metrics/web-vitals/types/base.ts b/packages/browser-utils/src/metrics/web-vitals/types/base.ts index 6279edffabd4..6b612e7e22c5 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/base.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/base.ts @@ -14,13 +14,12 @@ * limitations under the License. */ -import type { CLSMetric } from './cls'; -import type { FCPMetric } from './fcp'; -import type { FIDMetric } from './fid'; -import type { INPMetric } from './inp'; -import type { LCPMetric } from './lcp'; -import type { FirstInputPolyfillEntry, NavigationTimingPolyfillEntry } from './polyfills'; -import type { TTFBMetric } from './ttfb'; +import type { CLSMetric, CLSMetricWithAttribution } from './cls'; +import type { FCPMetric, FCPMetricWithAttribution } from './fcp'; +import type { FIDMetric, FIDMetricWithAttribution } from './fid'; +import type { INPMetric, INPMetricWithAttribution } from './inp'; +import type { LCPMetric, LCPMetricWithAttribution } from './lcp'; +import type { TTFBMetric, TTFBMetricWithAttribution } from './ttfb'; export interface Metric { /** @@ -61,7 +60,7 @@ export interface Metric { * The array may also be empty if the metric value was not based on any * entries (e.g. a CLS value of 0 given no layout shifts). */ - entries: (PerformanceEntry | LayoutShift | FirstInputPolyfillEntry | NavigationTimingPolyfillEntry)[]; + entries: PerformanceEntry[]; /** * The type of navigation. @@ -70,6 +69,7 @@ export interface Metric { * `undefined` if the browser doesn't support that API), with the following * exceptions: * - 'back-forward-cache': for pages that are restored from the bfcache. + * - 'back_forward' is renamed to 'back-forward' for consistency. * - 'prerender': for pages that were prerendered. * - 'restore': for pages that were discarded by the browser and then * restored by the user. @@ -80,17 +80,14 @@ export interface Metric { /** The union of supported metric types. */ export type MetricType = CLSMetric | FCPMetric | FIDMetric | INPMetric | LCPMetric | TTFBMetric; -/** - * A version of the `Metric` that is used with the attribution build. - */ -export interface MetricWithAttribution extends Metric { - /** - * An object containing potentially-helpful debugging information that - * can be sent along with the metric value for the current page visit in - * order to help identify issues happening to real-users in the field. - */ - attribution: { [key: string]: unknown }; -} +/** The union of supported metric attribution types. */ +export type MetricWithAttribution = + | CLSMetricWithAttribution + | FCPMetricWithAttribution + | FIDMetricWithAttribution + | INPMetricWithAttribution + | LCPMetricWithAttribution + | TTFBMetricWithAttribution; /** * The thresholds of metric's "good", "needs improvement", and "poor" ratings. @@ -107,6 +104,11 @@ export interface MetricWithAttribution extends Metric { */ export type MetricRatingThresholds = [number, number]; +/** + * @deprecated Use metric-specific function types instead, such as: + * `(metric: LCPMetric) => void`. If a single callback type is needed for + * multiple metrics, use `(metric: MetricType) => void`. + */ export interface ReportCallback { (metric: MetricType): void; } diff --git a/packages/browser-utils/src/metrics/web-vitals/types/cls.ts b/packages/browser-utils/src/metrics/web-vitals/types/cls.ts index a95a8fb30770..1d17c2d3eedb 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/cls.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/cls.ts @@ -72,17 +72,3 @@ export interface CLSAttribution { export interface CLSMetricWithAttribution extends CLSMetric { attribution: CLSAttribution; } - -/** - * A CLS-specific version of the ReportCallback function. - */ -export interface CLSReportCallback { - (metric: CLSMetric): void; -} - -/** - * A CLS-specific version of the ReportCallback function with attribution. - */ -export interface CLSReportCallbackWithAttribution { - (metric: CLSMetricWithAttribution): void; -} diff --git a/packages/browser-utils/src/metrics/web-vitals/types/fcp.ts b/packages/browser-utils/src/metrics/web-vitals/types/fcp.ts index 1a4c7d4962a3..ce668192766f 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/fcp.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/fcp.ts @@ -15,7 +15,6 @@ */ import type { LoadState, Metric } from './base'; -import type { NavigationTimingPolyfillEntry } from './polyfills'; /** * An FCP-specific version of the Metric object. @@ -55,7 +54,7 @@ export interface FCPAttribution { * general page load issues. This can be used to access `serverTiming` for example: * navigationEntry?.serverTiming */ - navigationEntry?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry; + navigationEntry?: PerformanceNavigationTiming; } /** @@ -64,17 +63,3 @@ export interface FCPAttribution { export interface FCPMetricWithAttribution extends FCPMetric { attribution: FCPAttribution; } - -/** - * An FCP-specific version of the ReportCallback function. - */ -export interface FCPReportCallback { - (metric: FCPMetric): void; -} - -/** - * An FCP-specific version of the ReportCallback function with attribution. - */ -export interface FCPReportCallbackWithAttribution { - (metric: FCPMetricWithAttribution): void; -} diff --git a/packages/browser-utils/src/metrics/web-vitals/types/fid.ts b/packages/browser-utils/src/metrics/web-vitals/types/fid.ts index 2001269c9b46..953607adff98 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/fid.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/fid.ts @@ -15,14 +15,13 @@ */ import type { LoadState, Metric } from './base'; -import type { FirstInputPolyfillEntry } from './polyfills'; /** * An FID-specific version of the Metric object. */ export interface FIDMetric extends Metric { name: 'FID'; - entries: (PerformanceEventTiming | FirstInputPolyfillEntry)[]; + entries: PerformanceEventTiming[]; } /** @@ -46,10 +45,9 @@ export interface FIDAttribution { */ eventType: string; /** - * The `PerformanceEventTiming` entry corresponding to FID (or the - * polyfill entry in browsers that don't support Event Timing). + * The `PerformanceEventTiming` entry corresponding to FID. */ - eventEntry: PerformanceEventTiming | FirstInputPolyfillEntry; + eventEntry: PerformanceEventTiming; /** * The loading state of the document at the time when the first interaction * occurred (see `LoadState` for details). If the first interaction occurred @@ -65,17 +63,3 @@ export interface FIDAttribution { export interface FIDMetricWithAttribution extends FIDMetric { attribution: FIDAttribution; } - -/** - * An FID-specific version of the ReportCallback function. - */ -export interface FIDReportCallback { - (metric: FIDMetric): void; -} - -/** - * An FID-specific version of the ReportCallback function with attribution. - */ -export interface FIDReportCallbackWithAttribution { - (metric: FIDMetricWithAttribution): void; -} diff --git a/packages/browser-utils/src/metrics/web-vitals/types/inp.ts b/packages/browser-utils/src/metrics/web-vitals/types/inp.ts index e83e0a0ece2a..c19be79a1ce0 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/inp.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/inp.ts @@ -31,31 +31,87 @@ export interface INPMetric extends Metric { */ 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. + * A selector identifying the element that the user first interacted with + * as part of the frame where the INP candidate interaction occurred. + * If this value is an empty string, that generally means the element was + * removed from the DOM after the interaction. */ - eventTarget?: string; + interactionTarget: string; /** - * The time when the user interacted for the event corresponding to INP. - * This time will match the `timeStamp` value of the `event` dispatched. + * A reference to the HTML element identified by `interactionTargetSelector`. + * NOTE: for attribution purpose, a selector identifying the element is + * typically more useful than the element itself. However, the element is + * also made available in case additional context is needed. */ - eventTime?: number; + interactionTargetElement: Node | undefined; /** - * The `type` of the `event` dispatched corresponding to INP. + * The time when the user first interacted during the frame where the INP + * candidate interaction occurred (if more than one interaction occurred + * within the frame, only the first time is reported). */ - eventType?: string; + interactionTime: DOMHighResTimeStamp; /** - * The `PerformanceEventTiming` entry corresponding to INP. + * The best-guess timestamp of the next paint after the interaction. + * In general, this timestamp is the same as the `startTime + duration` of + * the event timing entry. However, since `duration` values are rounded to + * the nearest 8ms, it can sometimes appear that the paint occurred before + * processing ended (which cannot happen). This value clamps the paint time + * so it's always after `processingEnd` from the Event Timing API and + * `renderStart` from the Long Animation Frame API (where available). + * It also averages the duration values for all entries in the same + * animation frame, which should be closer to the "real" value. */ - eventEntry?: PerformanceEventTiming; + nextPaintTime: DOMHighResTimeStamp; /** - * The loading state of the document at the time when the event 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. + * The type of interaction, based on the event type of the `event` entry + * that corresponds to the interaction (i.e. the first `event` entry + * containing an `interactionId` dispatched in a given animation frame). + * For "pointerdown", "pointerup", or "click" events this will be "pointer", + * and for "keydown" or "keyup" events this will be "keyboard". */ - loadState?: LoadState; + interactionType: 'pointer' | 'keyboard'; + /** + * An array of Event Timing entries that were processed within the same + * animation frame as the INP candidate interaction. + */ + processedEventEntries: PerformanceEventTiming[]; + /** + * If the browser supports the Long Animation Frame API, this array will + * include any `long-animation-frame` entries that intersect with the INP + * candidate interaction's `startTime` and the `processingEnd` time of the + * last event processed within that animation frame. If the browser does not + * support the Long Animation Frame API or no `long-animation-frame` entries + * are detect, this array will be empty. + */ + longAnimationFrameEntries: PerformanceLongAnimationFrameTiming[]; + /** + * The time from when the user interacted with the page until when the + * browser was first able to start processing event listeners for that + * interaction. This time captures the delay before event processing can + * begin due to the main thread being busy with other work. + */ + inputDelay: number; + /** + * The time from when the first event listener started running in response to + * the user interaction until when all event listener processing has finished. + */ + processingDuration: number; + /** + * The time from when the browser finished processing all event listeners for + * the user interaction until the next frame is presented on the screen and + * visible to the user. This time includes work on the main thread (such as + * `requestAnimationFrame()` callbacks, `ResizeObserver` and + * `IntersectionObserver` callbacks, and style/layout calculation) as well + * as off-main-thread work (such as compositor, GPU, and raster work). + */ + presentationDelay: number; + /** + * The loading state of the document at the time when the interaction + * 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; } /** @@ -64,17 +120,3 @@ export interface INPAttribution { export interface INPMetricWithAttribution extends INPMetric { attribution: INPAttribution; } - -/** - * An INP-specific version of the ReportCallback function. - */ -export interface INPReportCallback { - (metric: INPMetric): void; -} - -/** - * An INP-specific version of the ReportCallback function with attribution. - */ -export interface INPReportCallbackWithAttribution { - (metric: INPMetricWithAttribution): void; -} diff --git a/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts b/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts index aaed1213508e..2dd5ea34f798 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts @@ -15,7 +15,6 @@ */ import type { Metric } from './base'; -import type { NavigationTimingPolyfillEntry } from './polyfills'; /** * An LCP-specific version of the Metric object. @@ -57,7 +56,7 @@ export interface LCPAttribution { * otherwise 0). See [Optimize LCP](https://web.dev/articles/optimize-lcp) for * details. */ - resourceLoadTime: number; + resourceLoadDuration: number; /** * The delta between when the LCP resource finishes loading until the LCP * element is fully rendered. See [Optimize @@ -69,7 +68,7 @@ export interface LCPAttribution { * general page load issues. This can be used to access `serverTiming` for example: * navigationEntry?.serverTiming */ - navigationEntry?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry; + navigationEntry?: PerformanceNavigationTiming; /** * The `resource` entry for the LCP resource (if applicable), which is useful * for diagnosing resource load issues. @@ -87,17 +86,3 @@ export interface LCPAttribution { export interface LCPMetricWithAttribution extends LCPMetric { attribution: LCPAttribution; } - -/** - * An LCP-specific version of the ReportCallback function. - */ -export interface LCPReportCallback { - (metric: LCPMetric): void; -} - -/** - * An LCP-specific version of the ReportCallback function with attribution. - */ -export interface LCPReportCallbackWithAttribution { - (metric: LCPMetricWithAttribution): void; -} diff --git a/packages/browser-utils/src/metrics/web-vitals/types/polyfills.ts b/packages/browser-utils/src/metrics/web-vitals/types/polyfills.ts index 4216d72ac732..c4314c0697fa 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/polyfills.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/polyfills.ts @@ -19,16 +19,3 @@ export type FirstInputPolyfillEntry = Omit & { - type: PerformanceNavigationTiming['type']; -}; diff --git a/packages/browser-utils/src/metrics/web-vitals/types/ttfb.ts b/packages/browser-utils/src/metrics/web-vitals/types/ttfb.ts index 6a91394ad059..2a43668d7d8f 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/ttfb.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/ttfb.ts @@ -15,48 +15,59 @@ */ import type { Metric } from './base'; -import type { NavigationTimingPolyfillEntry } from './polyfills'; /** * A TTFB-specific version of the Metric object. */ export interface TTFBMetric extends Metric { name: 'TTFB'; - entries: PerformanceNavigationTiming[] | NavigationTimingPolyfillEntry[]; + entries: PerformanceNavigationTiming[]; } /** * An object containing potentially-helpful debugging information that * can be sent along with the TTFB value for the current page visit in order * to help identify issues happening to real-users in the field. + * + * NOTE: these values are primarily useful for page loads not handled via + * service worker, as browsers differ in what they report when service worker + * is involved, see: https://github.com/w3c/navigation-timing/issues/199 */ export interface TTFBAttribution { /** * The total time from when the user initiates loading the page to when the - * DNS lookup begins. This includes redirects, service worker startup, and - * HTTP cache lookup times. + * page starts to handle the request. Large values here are typically due + * to HTTP redirects, though other browser processing contributes to this + * duration as well (so even without redirect it's generally not zero). + */ + waitingDuration: number; + /** + * The total time spent checking the HTTP cache for a match. For navigations + * handled via service worker, this duration usually includes service worker + * start-up time as well as time processing `fetch` event listeners, with + * some exceptions, see: https://github.com/w3c/navigation-timing/issues/199 */ - waitingTime: number; + cacheDuration: number; /** - * The total time to resolve the DNS for the current request. + * The total time to resolve the DNS for the requested domain. */ - dnsTime: number; + dnsDuration: number; /** * The total time to create the connection to the requested domain. */ - connectionTime: number; + connectionDuration: number; /** - * The time time from when the request was sent until the first byte of the + * The total time from when the request was sent until the first byte of the * response was received. This includes network time as well as server * processing time. */ - requestTime: number; + requestDuration: number; /** * The `navigation` entry of the current page, which is useful for diagnosing - * general page load issues. This can be used to access `serverTiming` for example: - * navigationEntry?.serverTiming + * general page load issues. This can be used to access `serverTiming` for + * example: navigationEntry?.serverTiming */ - navigationEntry?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry; + navigationEntry?: PerformanceNavigationTiming; } /** @@ -65,17 +76,3 @@ export interface TTFBAttribution { export interface TTFBMetricWithAttribution extends TTFBMetric { attribution: TTFBAttribution; } - -/** - * A TTFB-specific version of the ReportCallback function. - */ -export interface TTFBReportCallback { - (metric: TTFBMetric): void; -} - -/** - * A TTFB-specific version of the ReportCallback function with attribution. - */ -export interface TTFBReportCallbackWithAttribution { - (metric: TTFBMetricWithAttribution): void; -}