diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts index 4c461ec6776c..e047566a476e 100644 --- a/packages/browser-utils/src/metrics/instrument.ts +++ b/packages/browser-utils/src/metrics/instrument.ts @@ -95,7 +95,14 @@ interface Metric { * support that API). For pages that are restored from the bfcache, this * value will be 'back-forward-cache'. */ - navigationType: 'navigate' | 'reload' | 'back-forward' | 'back-forward-cache' | 'prerender' | 'restore'; + navigationType: + | 'navigate' + | 'reload' + | 'back-forward' + | 'back-forward-cache' + | 'prerender' + | 'restore' + | 'soft-navigation'; } type InstrumentHandlerType = InstrumentHandlerTypeMetric | InstrumentHandlerTypePerformanceObserver; diff --git a/packages/browser-utils/src/metrics/web-vitals/README.md b/packages/browser-utils/src/metrics/web-vitals/README.md index a57937246cdd..1fe1582e32b2 100644 --- a/packages/browser-utils/src/metrics/web-vitals/README.md +++ b/packages/browser-utils/src/metrics/web-vitals/README.md @@ -2,10 +2,10 @@ > A modular library for measuring the [Web Vitals](https://web.dev/vitals/) metrics on real users. -This was vendored from: https://github.com/GoogleChrome/web-vitals: v5.0.2 +This was vendored from: https://github.com/GoogleChrome/web-vitals: v5.0.3 The commit SHA used is: -[463abbd425cda01ed65e0b5d18be9f559fe446cb](https://github.com/GoogleChrome/web-vitals/tree/463abbd425cda01ed65e0b5d18be9f559fe446cb) +[e22d23b22c1440e69c5fc25a2f373b1a425cc940](https://github.com/GoogleChrome/web-vitals/tree/e22d23b22c1440e69c5fc25a2f373b1a425cc940) Current vendored web vitals are: diff --git a/packages/browser-utils/src/metrics/web-vitals/getCLS.ts b/packages/browser-utils/src/metrics/web-vitals/getCLS.ts index c40f993f8ca8..8ffa68316e7c 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getCLS.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getCLS.ts @@ -16,13 +16,15 @@ import { WINDOW } from '../../types'; import { bindReporter } from './lib/bindReporter'; +import { getVisibilityWatcher } from './lib/getVisibilityWatcher'; import { initMetric } from './lib/initMetric'; import { initUnique } from './lib/initUnique'; import { LayoutShiftManager } from './lib/LayoutShiftManager'; import { observe } from './lib/observe'; import { runOnce } from './lib/runOnce'; +import { getSoftNavigationEntry, softNavs } from './lib/softNavs'; import { onFCP } from './onFCP'; -import type { CLSMetric, MetricRatingThresholds, ReportOpts } from './types'; +import type { CLSMetric, Metric, 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]; @@ -49,17 +51,42 @@ export const CLSThresholds: MetricRatingThresholds = [0.1, 0.25]; * during the same page load._ */ export const onCLS = (onReport: (metric: CLSMetric) => void, opts: ReportOpts = {}) => { + const softNavsEnabled = softNavs(opts); + let reportedMetric = false; + let metricNavStartTime = 0; + + const visibilityWatcher = getVisibilityWatcher(); + // 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( runOnce(() => { - const metric = initMetric('CLS', 0); + let metric = initMetric('CLS', 0); let report: ReturnType; const layoutShiftManager = initUnique(opts, LayoutShiftManager); + const initNewCLSMetric = (navigation?: Metric['navigationType'], navigationId?: string) => { + metric = initMetric('CLS', 0, navigation, navigationId); + layoutShiftManager._sessionValue = 0; + report = bindReporter(onReport, metric, CLSThresholds, opts.reportAllChanges); + reportedMetric = false; + if (navigation === 'soft-navigation') { + const softNavEntry = getSoftNavigationEntry(navigationId); + metricNavStartTime = softNavEntry?.startTime ?? 0; + } + }; + const handleEntries = (entries: LayoutShift[]) => { for (const entry of entries) { + // If the entry is for a new navigationId than previous, then we have + // entered a new soft nav, so emit the final CLS and reinitialize the + // metric. + if (softNavsEnabled && entry.navigationId && entry.navigationId !== metric.navigationId) { + report(true); + initNewCLSMetric('soft-navigation', entry.navigationId); + } + layoutShiftManager._processEntry(entry); } @@ -72,17 +99,48 @@ export const onCLS = (onReport: (metric: CLSMetric) => void, opts: ReportOpts = } }; - const po = observe('layout-shift', handleEntries); + const po = observe('layout-shift', handleEntries, opts); if (po) { report = bindReporter(onReport, metric, CLSThresholds, opts.reportAllChanges); - WINDOW.document?.addEventListener('visibilitychange', () => { - if (WINDOW.document?.visibilityState === 'hidden') { - handleEntries(po.takeRecords() as CLSMetric['entries']); - report(true); - } + visibilityWatcher.onHidden(() => { + handleEntries(po.takeRecords() as CLSMetric['entries']); + report(true); + reportedMetric = true; }); + // Soft navs may be detected by navigationId changes in metrics above + // But where no metric is issued we need to also listen for soft nav + // entries, then emit the final metric for the previous navigation and + // reset the metric for the new navigation. + // + // As PO is ordered by time, these should not happen before metrics. + // + // We add a check on startTime as we may be processing many entries that + // are already dealt with so just checking navigationId differs from + // current metric's navigation id, as we did above, is not sufficient. + const handleSoftNavEntries = (entries: SoftNavigationEntry[]) => { + for (const entry of entries) { + const navId = entry.navigationId; + const softNavEntry = navId ? getSoftNavigationEntry(navId) : null; + if ( + navId && + navId !== metric.navigationId && + softNavEntry && + (softNavEntry.startTime || 0) > metricNavStartTime + ) { + handleEntries(po.takeRecords() as CLSMetric['entries']); + if (!reportedMetric) report(true); + initNewCLSMetric('soft-navigation', entry.navigationId); + report = bindReporter(onReport, metric, CLSThresholds, opts.reportAllChanges); + } + } + }; + + if (softNavsEnabled) { + observe('soft-navigation', handleSoftNavEntries, opts); + } + // Queue a task to report (if nothing else triggers a report first). // This allows CLS to be reported as soon as FCP fires when // `reportAllChanges` is true. diff --git a/packages/browser-utils/src/metrics/web-vitals/getINP.ts b/packages/browser-utils/src/metrics/web-vitals/getINP.ts index f5efbcbc3afc..49374071e25e 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getINP.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getINP.ts @@ -15,15 +15,16 @@ */ import { bindReporter } from './lib/bindReporter'; +import { getVisibilityWatcher } from './lib/getVisibilityWatcher'; import { initMetric } from './lib/initMetric'; import { initUnique } from './lib/initUnique'; import { InteractionManager } from './lib/InteractionManager'; import { observe } from './lib/observe'; -import { onHidden } from './lib/onHidden'; import { initInteractionCountPolyfill } from './lib/polyfills/interactionCountPolyfill'; +import { getSoftNavigationEntry, softNavs } from './lib/softNavs'; import { whenActivated } from './lib/whenActivated'; import { whenIdleOrHidden } from './lib/whenIdleOrHidden'; -import type { INPMetric, INPReportOpts, MetricRatingThresholds } from './types'; +import type { INPMetric, INPReportOpts, Metric, MetricRatingThresholds } from './types'; /** Thresholds for INP. See https://web.dev/articles/inp#what_is_a_good_inp_score */ export const INPThresholds: MetricRatingThresholds = [200, 500]; @@ -67,17 +68,47 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: INPReportOpts return; } + let reportedMetric = false; + let metricNavStartTime = 0; + const softNavsEnabled = softNavs(opts); + const visibilityWatcher = getVisibilityWatcher(); + whenActivated(() => { // TODO(philipwalton): remove once the polyfill is no longer needed. - initInteractionCountPolyfill(); + initInteractionCountPolyfill(softNavsEnabled); - const metric = initMetric('INP'); - // eslint-disable-next-line prefer-const + let metric = initMetric('INP'); let report: ReturnType; const interactionManager = initUnique(opts, InteractionManager); + const initNewINPMetric = (navigation?: Metric['navigationType'], navigationId?: string) => { + interactionManager._resetInteractions(); + metric = initMetric('INP', -1, navigation, navigationId); + report = bindReporter(onReport, metric, INPThresholds, opts.reportAllChanges); + reportedMetric = false; + if (navigation === 'soft-navigation') { + const softNavEntry = getSoftNavigationEntry(navigationId); + metricNavStartTime = softNavEntry?.startTime ?? 0; + } + }; + + const updateINPMetric = () => { + const inp = interactionManager._estimateP98LongestInteraction(); + + if (inp && (inp._latency !== metric.value || opts.reportAllChanges)) { + metric.value = inp._latency; + metric.entries = inp.entries; + } + }; + const handleEntries = (entries: INPMetric['entries']) => { + // Only process entries, if at least some of them have interaction ids + // (otherwise run into lots of errors later for empty INP entries) + if (entries.filter(entry => entry.interactionId).length === 0) { + return; + } + // 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 @@ -89,13 +120,8 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: INPReportOpts interactionManager._processEntry(entry); } - const inp = interactionManager._estimateP98LongestInteraction(); - - if (inp && inp._latency !== metric.value) { - metric.value = inp._latency; - metric.entries = inp.entries; - report(); - } + updateINPMetric(); + report(); }); }; @@ -107,22 +133,58 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: INPReportOpts // just one or two frames is likely not worth the insight that could be // gained. durationThreshold: opts.durationThreshold ?? DEFAULT_DURATION_THRESHOLD, - }); + opts, + } as PerformanceObserverInit); report = bindReporter(onReport, metric, INPThresholds, 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 }); + po.observe({ + type: 'first-input', + buffered: true, + includeSoftNavigationObservations: softNavsEnabled, + }); - // sentry: we use onHidden instead of directly listening to visibilitychange - // because some browsers we still support (Safari <14.4) don't fully support - // `visibilitychange` or have known bugs w.r.t the `visibilitychange` event. - onHidden(() => { + visibilityWatcher.onHidden(() => { handleEntries(po.takeRecords() as INPMetric['entries']); report(true); }); + + // Soft navs may be detected by navigationId changes in metrics above + // But where no metric is issued we need to also listen for soft nav + // entries, then emit the final metric for the previous navigation and + // reset the metric for the new navigation. + // + // As PO is ordered by time, these should not happen before metrics. + // + // We add a check on startTime as we may be processing many entries that + // are already dealt with so just checking navigationId differs from + // current metric's navigation id, as we did above, is not sufficient. + const handleSoftNavEntries = (entries: SoftNavigationEntry[]) => { + entries.forEach(entry => { + const softNavEntry = getSoftNavigationEntry(entry.navigationId); + const softNavEntryStartTime = softNavEntry?.startTime ?? 0; + if ( + entry.navigationId && + entry.navigationId !== metric.navigationId && + softNavEntryStartTime > metricNavStartTime + ) { + // Queue in whenIdleOrHidden in case entry processing for previous + // metric are queued. + whenIdleOrHidden(() => { + handleEntries(po.takeRecords() as INPMetric['entries']); + if (!reportedMetric && metric.value > 0) report(true); + initNewINPMetric('soft-navigation', entry.navigationId); + }); + } + }); + }; + + if (softNavsEnabled) { + observe('soft-navigation', handleSoftNavEntries, opts); + } } }); }; diff --git a/packages/browser-utils/src/metrics/web-vitals/getLCP.ts b/packages/browser-utils/src/metrics/web-vitals/getLCP.ts index 6eafee698673..9a458b344591 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getLCP.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getLCP.ts @@ -14,18 +14,19 @@ * limitations under the License. */ -import { WINDOW } from '../../types'; import { bindReporter } from './lib/bindReporter'; import { getActivationStart } from './lib/getActivationStart'; +import { getNavigationEntry } from './lib/getNavigationEntry'; import { getVisibilityWatcher } from './lib/getVisibilityWatcher'; +import { addPageListener } from './lib/globalListeners'; import { initMetric } from './lib/initMetric'; import { initUnique } from './lib/initUnique'; import { LCPEntryManager } from './lib/LCPEntryManager'; import { observe } from './lib/observe'; -import { runOnce } from './lib/runOnce'; +import { getSoftNavigationEntry, softNavs } from './lib/softNavs'; import { whenActivated } from './lib/whenActivated'; import { whenIdleOrHidden } from './lib/whenIdleOrHidden'; -import type { LCPMetric, MetricRatingThresholds, ReportOpts } from './types'; +import type { LCPMetric, Metric, 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]; @@ -42,22 +43,63 @@ export const LCPThresholds: MetricRatingThresholds = [2500, 4000]; * been determined. */ export const onLCP = (onReport: (metric: LCPMetric) => void, opts: ReportOpts = {}) => { + let reportedMetric = false; + const softNavsEnabled = softNavs(opts); + let metricNavStartTime = 0; + const hardNavId = getNavigationEntry()?.navigationId || '1'; + let finalizeNavId = ''; + whenActivated(() => { - const visibilityWatcher = getVisibilityWatcher(); - const metric = initMetric('LCP'); + let visibilityWatcher = getVisibilityWatcher(); + let metric = initMetric('LCP'); let report: ReturnType; const lcpEntryManager = initUnique(opts, LCPEntryManager); + const initNewLCPMetric = (navigation?: Metric['navigationType'], navigationId?: string) => { + metric = initMetric('LCP', 0, navigation, navigationId); + report = bindReporter(onReport, metric, LCPThresholds, opts.reportAllChanges); + reportedMetric = false; + if (navigation === 'soft-navigation') { + visibilityWatcher = getVisibilityWatcher(true); + const softNavEntry = getSoftNavigationEntry(navigationId); + metricNavStartTime = softNavEntry?.startTime ?? 0; + } + }; + const handleEntries = (entries: LCPMetric['entries']) => { // If reportAllChanges is set then call this function for each entry, - // otherwise only consider the last one. - if (!opts.reportAllChanges) { + // otherwise only consider the last one, unless soft navs are enabled. + if (!opts.reportAllChanges && !softNavsEnabled) { // eslint-disable-next-line no-param-reassign entries = entries.slice(-1); } for (const entry of entries) { + if (softNavsEnabled && entry?.navigationId !== metric.navigationId) { + // If the entry is for a new navigationId than previous, then we have + // entered a new soft nav, so emit the final LCP and reinitialize the + // metric. + if (!reportedMetric) report(true); + initNewLCPMetric('soft-navigation', entry.navigationId); + } + let value = 0; + if (!entry.navigationId || entry.navigationId === hardNavId) { + // 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 + // where `activationStart` occurs after the LCP, this time should be + // clamped at 0. + value = Math.max(entry.startTime - getActivationStart(), 0); + } else { + // As a soft nav needs an interaction, it should never be before + // getActivationStart so can just cap to 0 + const softNavEntry = getSoftNavigationEntry(entry.navigationId); + const softNavEntryStartTime = softNavEntry?.startTime ?? 0; + value = Math.max(entry.startTime - softNavEntryStartTime, 0); + } + lcpEntryManager._processEntry(entry); // Only report if the page wasn't hidden prior to LCP. @@ -68,40 +110,79 @@ export const onLCP = (onReport: (metric: LCPMetric) => void, opts: ReportOpts = // rather than navigation start if the page was prerendered. But in cases // where `activationStart` occurs after the LCP, this time should be // clamped at 0. - metric.value = Math.max(entry.startTime - getActivationStart(), 0); + metric.value = value; metric.entries = [entry]; report(); } } }; - const po = observe('largest-contentful-paint', handleEntries); + const po = observe('largest-contentful-paint', handleEntries, opts); if (po) { report = bindReporter(onReport, metric, LCPThresholds, opts.reportAllChanges); - // Ensure this logic only runs once, since it can be triggered from - // any of three different event listeners below. - const stopListening = runOnce(() => { - handleEntries(po.takeRecords() as LCPMetric['entries']); - po.disconnect(); - report(true); - }); + const finalizeLCP = (event: Event) => { + if (event.isTrusted && !reportedMetric) { + // Finalize the current navigationId metric. + finalizeNavId = metric.navigationId; + // Wrap the listener in an idle callback so it's run in a separate + // task to reduce potential INP impact. + // https://github.com/GoogleChrome/web-vitals/issues/383 + whenIdleOrHidden(() => { + if (!reportedMetric) { + handleEntries(po.takeRecords() as LCPMetric['entries']); + if (!softNavsEnabled) { + po.disconnect(); + removeEventListener(event.type, finalizeLCP); + } + if (metric.navigationId === finalizeNavId) { + reportedMetric = true; + report(true); + } + } + }); + } + }; // Stop listening after input or visibilitychange. // Note: while scrolling is an input that stops LCP observation, it's // unreliable since it can be programmatically generated. // See: https://github.com/GoogleChrome/web-vitals/issues/75 for (const type of ['keydown', 'click', 'visibilitychange']) { - // Wrap the listener in an idle callback so it's run in a separate - // task to reduce potential INP impact. - // https://github.com/GoogleChrome/web-vitals/issues/383 - if (WINDOW.document) { - addEventListener(type, () => whenIdleOrHidden(stopListening), { - capture: true, - once: true, - }); - } + addPageListener(type, finalizeLCP, { + capture: true, + }); + } + + // Soft navs may be detected by navigationId changes in metrics above + // But where no metric is issued we need to also listen for soft nav + // entries, then emit the final metric for the previous navigation and + // reset the metric for the new navigation. + // + // As PO is ordered by time, these should not happen before metrics. + // + // We add a check on startTime as we may be processing many entries that + // are already dealt with so just checking navigationId differs from + // current metric's navigation id, as we did above, is not sufficient. + const handleSoftNavEntries = (entries: SoftNavigationEntry[]) => { + entries.forEach(entry => { + const softNavEntry = entry.navigationId ? getSoftNavigationEntry(entry.navigationId) : null; + if ( + entry?.navigationId !== metric.navigationId && + softNavEntry?.startTime && + softNavEntry.startTime > metricNavStartTime + ) { + handleEntries(po.takeRecords() as LCPMetric['entries']); + if (!reportedMetric) report(true); + initNewLCPMetric('soft-navigation', entry.navigationId); + } + }); + }; + + if (softNavsEnabled) { + observe('interaction-contentful-paint', handleEntries, opts); + observe('soft-navigation', handleSoftNavEntries, opts); } } }); diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/LCPEntryManager.ts b/packages/browser-utils/src/metrics/web-vitals/lib/LCPEntryManager.ts index 752c6c41469b..8fe346384706 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/LCPEntryManager.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/LCPEntryManager.ts @@ -17,10 +17,10 @@ // eslint-disable-next-line jsdoc/require-jsdoc export class LCPEntryManager { // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility - _onBeforeProcessingEntry?: (entry: LargestContentfulPaint) => void; + _onBeforeProcessingEntry?: (entry: LargestContentfulPaint | InteractionContentfulPaint) => void; // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility, jsdoc/require-jsdoc - _processEntry(entry: LargestContentfulPaint) { + _processEntry(entry: LargestContentfulPaint | InteractionContentfulPaint) { this._onBeforeProcessingEntry?.(entry); } } diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/getActivationStart.ts b/packages/browser-utils/src/metrics/web-vitals/lib/getActivationStart.ts index 33677466faf9..6866ec306689 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/getActivationStart.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/getActivationStart.ts @@ -17,6 +17,7 @@ import { getNavigationEntry } from './getNavigationEntry'; export const getActivationStart = (): number => { - const navEntry = getNavigationEntry(); - return navEntry?.activationStart ?? 0; + const hardNavEntry = getNavigationEntry(); + + return hardNavEntry?.activationStart ?? 0; }; 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 3a6c0a2e42a9..cdaff99de6d5 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts @@ -16,8 +16,10 @@ import { WINDOW } from '../../../types'; import { getActivationStart } from './getActivationStart'; +import { addPageListener, removePageListener } from './globalListeners'; let firstHiddenTime = -1; +const onHiddenFunctions: Set<() => void> = new Set(); const initHiddenTime = () => { // If the document is hidden when this code runs, assume it was always @@ -29,38 +31,41 @@ const initHiddenTime = () => { }; const onVisibilityUpdate = (event: Event) => { - // If the document is 'hidden' and no previous hidden timestamp has been - // set, update it based on the current event data. - if (WINDOW.document!.visibilityState === 'hidden' && firstHiddenTime > -1) { - // If the event is a 'visibilitychange' event, it means the page was - // visible prior to this change, so the event timestamp is the first - // hidden time. - // However, if the event is not a 'visibilitychange' event, then it must - // be a 'prerenderingchange' event, and the fact that the document is - // still 'hidden' from the above check means the tab was activated - // in a background state and so has always been hidden. - firstHiddenTime = event.type === 'visibilitychange' ? event.timeStamp : 0; + // Handle changes to hidden state + if (isPageHidden(event) && firstHiddenTime > -1) { + // Sentry-specific change: Also call onHidden callbacks for pagehide events + // to support older browsers (Safari <14.4) that don't properly fire visibilitychange + if (event.type === 'visibilitychange' || event.type === 'pagehide') { + for (const onHiddenFunction of onHiddenFunctions) { + onHiddenFunction(); + } + } - // Remove all listeners now that a `firstHiddenTime` value has been set. - removeChangeListeners(); - } -}; + // If the document is 'hidden' and no previous hidden timestamp has been + // set (so is infinity), update it based on the current event data. + if (!isFinite(firstHiddenTime)) { + // If the event is a 'visibilitychange' event, it means the page was + // visible prior to this change, so the event timestamp is the first + // hidden time. + // However, if the event is not a 'visibilitychange' event, then it must + // be a 'prerenderingchange' or 'pagehide' event, and the fact that the document is + // still 'hidden' from the above check means the tab was activated + // in a background state and so has always been hidden. + firstHiddenTime = event.type === 'visibilitychange' ? event.timeStamp : 0; -const addChangeListeners = () => { - addEventListener('visibilitychange', onVisibilityUpdate, true); - // IMPORTANT: when a page is prerendering, its `visibilityState` is - // 'hidden', so in order to account for cases where this module checks for - // visibility during prerendering, an additional check after prerendering - // completes is also required. - addEventListener('prerenderingchange', onVisibilityUpdate, true); + // We no longer need the `prerenderingchange` event listener now we've + // set an initial init time so remove that + // (we'll keep the visibilitychange and pagehide ones for onHiddenFunction above) + removePageListener('prerenderingchange', onVisibilityUpdate, true); + } + } }; -const removeChangeListeners = () => { - removeEventListener('visibilitychange', onVisibilityUpdate, true); - removeEventListener('prerenderingchange', onVisibilityUpdate, true); -}; +export const getVisibilityWatcher = (reset = false) => { + if (reset) { + firstHiddenTime = Infinity; + } -export const getVisibilityWatcher = () => { if (WINDOW.document && firstHiddenTime < 0) { // Check if we have a previous hidden `visibility-state` performance entry. const activationStart = getActivationStart(); @@ -75,14 +80,39 @@ export const getVisibilityWatcher = () => { // a perfect heuristic, but it's the best we can do until the // `visibility-state` performance entry becomes available in all browsers. firstHiddenTime = firstVisibilityStateHiddenTime ?? initHiddenTime(); - // We're still going to listen to for changes so we can handle things like - // bfcache restores and/or prerender without having to examine individual - // timestamps in detail. - addChangeListeners(); + // Listen for visibility changes so we can handle things like bfcache + // restores and/or prerender without having to examine individual + // timestamps in detail and also for onHidden function calls. + addPageListener('visibilitychange', onVisibilityUpdate, true); + + // Sentry-specific change: Some browsers have buggy implementations of visibilitychange, + // so we use pagehide in addition, just to be safe. This is also required for older + // Safari versions (<14.4) that we still support. + addPageListener('pagehide', onVisibilityUpdate, true); + + // IMPORTANT: when a page is prerendering, its `visibilityState` is + // 'hidden', so in order to account for cases where this module checks for + // visibility during prerendering, an additional check after prerendering + // completes is also required. + addPageListener('prerenderingchange', onVisibilityUpdate, true); } + return { get firstHiddenTime() { return firstHiddenTime; }, + onHidden(cb: () => void) { + onHiddenFunctions.add(cb); + }, }; }; + +/** + * Check if the page is hidden, uses the `pagehide` event for older browsers support that we used to have in `onHidden` function. + * Some browsers we still support (Safari <14.4) don't fully support `visibilitychange` + * or have known bugs w.r.t the `visibilitychange` event. + * // TODO (v11): If we decide to drop support for Safari 14.4, we can use the logic from web-vitals 4.2.4 + */ +function isPageHidden(event: Event) { + return event.type === 'pagehide' || WINDOW.document?.visibilityState === 'hidden'; +} diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/globalListeners.ts b/packages/browser-utils/src/metrics/web-vitals/lib/globalListeners.ts new file mode 100644 index 000000000000..0e391cff17c2 --- /dev/null +++ b/packages/browser-utils/src/metrics/web-vitals/lib/globalListeners.ts @@ -0,0 +1,20 @@ +import { WINDOW } from '../../../types'; + +/** + * web-vitals 5.1.0 switched listeners to be added on the window rather than the document. + * Instead of having to check for window/document every time we add a listener, we can use this function. + */ +export function addPageListener(type: string, listener: EventListener, options?: boolean | AddEventListenerOptions) { + if (WINDOW.document) { + WINDOW.addEventListener(type, listener, options); + } +} +/** + * web-vitals 5.1.0 switched listeners to be removed from the window rather than the document. + * Instead of having to check for window/document every time we remove a listener, we can use this function. + */ +export function removePageListener(type: string, listener: EventListener, options?: boolean | AddEventListenerOptions) { + if (WINDOW.document) { + WINDOW.removeEventListener(type, listener, options); + } +} diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/initMetric.ts b/packages/browser-utils/src/metrics/web-vitals/lib/initMetric.ts index 8771a5966c9f..cd61514a2725 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/initMetric.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/initMetric.ts @@ -19,24 +19,33 @@ import type { MetricType } from '../types'; import { generateUniqueID } from './generateUniqueID'; import { getActivationStart } from './getActivationStart'; import { getNavigationEntry } from './getNavigationEntry'; +import { getSoftNavigationEntry } from './softNavs'; -export const initMetric = (name: MetricName, value: number = -1) => { - const navEntry = getNavigationEntry(); +export const initMetric = ( + name: MetricName, + value: number = -1, + navigation?: MetricType['navigationType'], + navigationId?: string, +) => { + const hardNavId = getNavigationEntry()?.navigationId || '1'; + const hardNavEntry = getNavigationEntry(); let navigationType: MetricType['navigationType'] = 'navigate'; - if (navEntry) { + if (navigation) { + // If it was passed in, then use that + navigationType = navigation; + } else if (hardNavEntry) { if (WINDOW.document?.prerendering || getActivationStart() > 0) { navigationType = 'prerender'; } else if (WINDOW.document?.wasDiscarded) { navigationType = 'restore'; - } else if (navEntry.type) { - navigationType = navEntry.type.replace(/_/g, '-') as MetricType['navigationType']; + } else if (hardNavEntry.type) { + navigationType = hardNavEntry.type.replace(/_/g, '-') as MetricType['navigationType']; } } // Use `entries` type specific for the metric. const entries: Extract['entries'] = []; - return { name, value, @@ -45,5 +54,7 @@ export const initMetric = (name: MetricNa entries, id: generateUniqueID(), navigationType, + navigationId: navigationId || hardNavId, + navigationURL: getSoftNavigationEntry(navigationId)?.name || getNavigationEntry()?.name, }; }; 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 6071893dfa8e..965dc3e4e98b 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts @@ -13,16 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { softNavs } from './softNavs'; interface PerformanceEntryMap { event: PerformanceEventTiming[]; 'first-input': PerformanceEventTiming[]; + 'interaction-contentful-paint': InteractionContentfulPaint[]; 'layout-shift': LayoutShift[]; 'largest-contentful-paint': LargestContentfulPaint[]; 'long-animation-frame': PerformanceLongAnimationFrameTiming[]; paint: PerformancePaintTiming[]; navigation: PerformanceNavigationTiming[]; resource: PerformanceResourceTiming[]; + 'soft-navigation': SoftNavigationEntry[]; // Sentry-specific change: // We add longtask as a supported entry type as we use this in // our `instrumentPerformanceObserver` function also observes 'longtask' @@ -46,6 +49,8 @@ export const observe = ( callback: (entries: PerformanceEntryMap[K]) => void, opts: PerformanceObserverInit = {}, ): PerformanceObserver | undefined => { + const includeSoftNavigationObservations = softNavs(opts); + try { if (PerformanceObserver.supportedEntryTypes.includes(type)) { const po = new PerformanceObserver(list => { @@ -57,7 +62,13 @@ export const observe = ( callback(list.getEntries() as PerformanceEntryMap[K]); }); }); - po.observe({ type, buffered: true, ...opts }); + po.observe({ + type, + buffered: true, + includeSoftNavigationObservations, + ...opts, + } as PerformanceObserverInit); + return po; } } catch { 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 5a3c1b4fc810..d9dc2f6718ed 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts @@ -15,6 +15,7 @@ */ import { WINDOW } from '../../../types'; +import { addPageListener } from './globalListeners'; export interface OnHiddenCallback { (event: Event): void; @@ -37,10 +38,8 @@ export const onHidden = (cb: OnHiddenCallback) => { } }; - if (WINDOW.document) { - addEventListener('visibilitychange', onHiddenOrPageHide, true); - // Some browsers have buggy implementations of visibilitychange, - // so we use pagehide in addition, just to be safe. - addEventListener('pagehide', onHiddenOrPageHide, true); - } + addPageListener('visibilitychange', onHiddenOrPageHide, true); + // Some browsers have buggy implementations of visibilitychange, + // so we use pagehide in addition, just to be safe. + addPageListener('pagehide', onHiddenOrPageHide, true); }; 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 4da20a602335..4c1404ca1e28 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 @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +import { getNavigationEntry } from '../getNavigationEntry'; import { observe } from '../observe'; declare global { @@ -25,10 +25,23 @@ declare global { let interactionCountEstimate = 0; let minKnownInteractionId = Infinity; let maxKnownInteractionId = 0; +let currentNavId = ''; +let softNavsEnabled = false; const updateEstimate = (entries: PerformanceEventTiming[]) => { + if (!currentNavId) { + currentNavId = getNavigationEntry()?.navigationId || '1'; + } + entries.forEach(e => { if (e.interactionId) { + if (softNavsEnabled && e.navigationId && e.navigationId !== currentNavId) { + currentNavId = e.navigationId; + interactionCountEstimate = 0; + minKnownInteractionId = Infinity; + maxKnownInteractionId = 0; + } + minKnownInteractionId = Math.min(minKnownInteractionId, e.interactionId); maxKnownInteractionId = Math.max(maxKnownInteractionId, e.interactionId); @@ -50,12 +63,15 @@ export const getInteractionCount = (): number => { /** * Feature detects native support or initializes the polyfill if needed. */ -export const initInteractionCountPolyfill = (): void => { +export const initInteractionCountPolyfill = (softNavs?: boolean) => { if ('interactionCount' in performance || po) return; + softNavsEnabled = softNavs || false; + po = observe('event', updateEstimate, { type: 'event', buffered: true, durationThreshold: 0, + includeSoftNavigationObservations: softNavsEnabled, } as PerformanceObserverInit); }; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/softNavs.ts b/packages/browser-utils/src/metrics/web-vitals/lib/softNavs.ts new file mode 100644 index 000000000000..acd4b5b90a00 --- /dev/null +++ b/packages/browser-utils/src/metrics/web-vitals/lib/softNavs.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2023 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 { ReportOpts } from '../types'; + +export const softNavs = (opts?: ReportOpts) => { + return PerformanceObserver.supportedEntryTypes.includes('soft-navigation') && opts && opts.reportSoftNavs; +}; + +export const getSoftNavigationEntry = (navigationId?: string): SoftNavigationEntry | undefined => { + if (!navigationId) return; + + const softNavEntry = globalThis.performance + .getEntriesByType('soft-navigation') + .filter(entry => entry.navigationId === navigationId); + if (softNavEntry) return softNavEntry[0]; + + return; +}; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts b/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts index 32dae5f30f8b..008aac8dc4c2 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts @@ -15,6 +15,7 @@ */ import { WINDOW } from '../../../types.js'; +import { addPageListener, removePageListener } from './globalListeners.js'; import { onHidden } from './onHidden.js'; import { runOnce } from './runOnce.js'; @@ -32,7 +33,13 @@ export const whenIdleOrHidden = (cb: () => void) => { } else { // eslint-disable-next-line no-param-reassign cb = runOnce(cb); - rIC(cb); + addPageListener('visibilitychange', cb, { once: true, capture: true }); + rIC(() => { + cb(); + // Remove the above event listener since no longer required. + // See: https://github.com/GoogleChrome/web-vitals/issues/622 + removePageListener('visibilitychange', cb, { capture: true }); + }); // sentry: we use onHidden instead of directly listening to visibilitychange // because some browsers we still support (Safari <14.4) don't fully support // `visibilitychange` or have known bugs w.r.t the `visibilitychange` event. diff --git a/packages/browser-utils/src/metrics/web-vitals/onFCP.ts b/packages/browser-utils/src/metrics/web-vitals/onFCP.ts index 12fd51e29ef7..f309cd45d50f 100644 --- a/packages/browser-utils/src/metrics/web-vitals/onFCP.ts +++ b/packages/browser-utils/src/metrics/web-vitals/onFCP.ts @@ -16,11 +16,13 @@ import { bindReporter } from './lib/bindReporter'; import { getActivationStart } from './lib/getActivationStart'; +import { getNavigationEntry } from './lib/getNavigationEntry'; import { getVisibilityWatcher } from './lib/getVisibilityWatcher'; import { initMetric } from './lib/initMetric'; import { observe } from './lib/observe'; +import { getSoftNavigationEntry, softNavs } from './lib/softNavs'; import { whenActivated } from './lib/whenActivated'; -import type { FCPMetric, MetricRatingThresholds, ReportOpts } from './types'; +import type { FCPMetric, Metric, 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]; @@ -32,31 +34,80 @@ export const FCPThresholds: MetricRatingThresholds = [1800, 3000]; * value is a `DOMHighResTimeStamp`. */ export const onFCP = (onReport: (metric: FCPMetric) => void, opts: ReportOpts = {}) => { + // Set defaults + const softNavsEnabled = softNavs(opts); + let metricNavStartTime = 0; + const hardNavId = getNavigationEntry()?.navigationId || '1'; + whenActivated(() => { - const visibilityWatcher = getVisibilityWatcher(); - const metric = initMetric('FCP'); + let visibilityWatcher = getVisibilityWatcher(); + let metric = initMetric('FCP'); let report: ReturnType; + const initNewFCPMetric = (navigation?: Metric['navigationType'], navigationId?: string) => { + metric = initMetric('FCP', 0, navigation, navigationId); + report = bindReporter(onReport, metric, FCPThresholds, opts.reportAllChanges); + if (navigation === 'soft-navigation') { + visibilityWatcher = getVisibilityWatcher(true); + const softNavEntry = navigationId ? getSoftNavigationEntry(navigationId) : null; + metricNavStartTime = softNavEntry ? softNavEntry.startTime || 0 : 0; + } + }; + const handleEntries = (entries: FCPMetric['entries']) => { for (const entry of entries) { if (entry.name === 'first-contentful-paint') { - po!.disconnect(); + if (!softNavsEnabled) { + // If we're not using soft navs monitoring, we should not see + // any more FCPs so can disconnect the performance observer + po!.disconnect(); + } else if (entry.navigationId && entry.navigationId !== metric.navigationId) { + // If the entry is for a new navigationId than previous, then we have + // entered a new soft nav, so reinitialize the metric. + initNewFCPMetric('soft-navigation', entry.navigationId); + } + + let value = 0; - // Only report if the page wasn't hidden prior to the first paint. - if (entry.startTime < visibilityWatcher.firstHiddenTime) { + if (!entry.navigationId || entry.navigationId === hardNavId) { + // Only report if the page wasn't hidden prior to the first paint. // The activationStart reference is used because FCP should be // relative to page activation rather than navigation start if the // page was prerendered. But in cases where `activationStart` occurs // after the FCP, this time should be clamped at 0. - metric.value = Math.max(entry.startTime - getActivationStart(), 0); + value = Math.max(entry.startTime - getActivationStart(), 0); + } else { + const softNavEntry = getSoftNavigationEntry(entry.navigationId); + const softNavStartTime = softNavEntry?.startTime ?? 0; + // As a soft nav needs an interaction, it should never be before + // getActivationStart so can just cap to 0 + value = Math.max(entry.startTime - softNavStartTime, 0); + } + + // Only report if the page wasn't hidden prior to FCP. + // Or it's a soft nav FCP + const softNavEntry = + softNavsEnabled && entry.navigationId ? getSoftNavigationEntry(entry.navigationId) : null; + const softNavEntryStartTime = softNavEntry?.startTime ?? 0; + if ( + entry.startTime < visibilityWatcher.firstHiddenTime || + (softNavsEnabled && + entry.navigationId && + entry.navigationId !== metric.navigationId && + entry.navigationId !== hardNavId && + softNavEntryStartTime > metricNavStartTime) + ) { + metric.value = value; metric.entries.push(entry); + metric.navigationId = entry.navigationId || '1'; + // FCP should only be reported once so can report right report(true); } } } }; - const po = observe('paint', handleEntries); + const po = observe('paint', handleEntries, opts); if (po) { 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 4633b3cd83cb..7dfb9ee57aa9 100644 --- a/packages/browser-utils/src/metrics/web-vitals/onTTFB.ts +++ b/packages/browser-utils/src/metrics/web-vitals/onTTFB.ts @@ -19,6 +19,8 @@ import { bindReporter } from './lib/bindReporter'; import { getActivationStart } from './lib/getActivationStart'; import { getNavigationEntry } from './lib/getNavigationEntry'; import { initMetric } from './lib/initMetric'; +import { observe } from './lib/observe'; +import { softNavs } from './lib/softNavs'; import { whenActivated } from './lib/whenActivated'; import type { MetricRatingThresholds, ReportOpts, TTFBMetric } from './types'; @@ -56,21 +58,40 @@ const whenReady = (callback: () => void) => { * and server processing time. */ export const onTTFB = (onReport: (metric: TTFBMetric) => void, opts: ReportOpts = {}) => { - const metric = initMetric('TTFB'); - const report = bindReporter(onReport, metric, TTFBThresholds, opts.reportAllChanges); + // Set defaults + const softNavsEnabled = softNavs(opts); - whenReady(() => { - const navigationEntry = getNavigationEntry(); + let metric = initMetric('TTFB'); + let report = bindReporter(onReport, metric, TTFBThresholds, opts.reportAllChanges); - if (navigationEntry) { + whenReady(() => { + const hardNavEntry = getNavigationEntry(); + if (hardNavEntry) { + const responseStart = hardNavEntry.responseStart; // 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(navigationEntry.responseStart - getActivationStart(), 0); + metric.value = Math.max(responseStart - getActivationStart(), 0); - metric.entries = [navigationEntry]; + metric.entries = [hardNavEntry]; report(true); + + // Listen for soft-navigation entries and emit a dummy 0 TTFB entry + const reportSoftNavTTFBs = (entries: SoftNavigationEntry[]) => { + entries.forEach(entry => { + if (entry.navigationId) { + metric = initMetric('TTFB', 0, 'soft-navigation', entry.navigationId); + metric.entries = [entry]; + report = bindReporter(onReport, metric, TTFBThresholds, opts.reportAllChanges); + report(true); + } + }); + }; + + if (softNavsEnabled) { + observe('soft-navigation', reportSoftNavTTFBs, opts); + } } }); }; diff --git a/packages/browser-utils/src/metrics/web-vitals/types.ts b/packages/browser-utils/src/metrics/web-vitals/types.ts index 8146849182b5..3d8abb8a1317 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types.ts @@ -30,6 +30,8 @@ interface PerformanceEntryMap { navigation: PerformanceNavigationTiming; resource: PerformanceResourceTiming; paint: PerformancePaintTiming; + 'interaction-contentful-paint': InteractionContentfulPaint; + 'soft-navigation': SoftNavigationEntry; } // Update built-in types to be more accurate. @@ -45,20 +47,28 @@ declare global { getEntriesByType(type: K): PerformanceEntryMap[K][]; } + // https://w3c.github.io/event-timing/#sec-modifications-perf-timeline + interface PerformancePaintTiming extends PerformanceEntry { + navigationId?: string; + } + // https://w3c.github.io/event-timing/#sec-modifications-perf-timeline interface PerformanceObserverInit { durationThreshold?: number; + includeSoftNavigationObservations?: boolean; } // https://wicg.github.io/nav-speculation/prerendering.html#performance-navigation-timing-extension interface PerformanceNavigationTiming { activationStart?: number; + navigationId?: string; } // https://wicg.github.io/event-timing/#sec-performance-event-timing interface PerformanceEventTiming extends PerformanceEntry { duration: DOMHighResTimeStamp; interactionId: number; + navigationId?: string; } // https://wicg.github.io/layout-instability/#sec-layout-shift-attribution @@ -66,6 +76,7 @@ declare global { node: Node | null; previousRect: DOMRectReadOnly; currentRect: DOMRectReadOnly; + navigationId?: string; } // https://wicg.github.io/layout-instability/#sec-layout-shift @@ -73,6 +84,7 @@ declare global { value: number; sources: LayoutShiftAttribution[]; hadRecentInput: boolean; + navigationId?: string; } // https://w3c.github.io/largest-contentful-paint/#sec-largest-contentful-paint-interface @@ -83,6 +95,22 @@ declare global { readonly id: string; readonly url: string; readonly element: Element | null; + navigationId?: string; + } + + // https://github.com/WICG/soft-navigations + interface SoftNavigationEntry extends PerformanceEntry { + navigationId?: string; + } + + interface InteractionContentfulPaint extends PerformanceEntry { + readonly renderTime: DOMHighResTimeStamp; + readonly loadTime: DOMHighResTimeStamp; + readonly size: number; + readonly id: string; + readonly url: string; + readonly element: Element | null; + navigationId?: string; } // https://w3c.github.io/long-animation-frame/#sec-PerformanceLongAnimationFrameTiming 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 02cb566011ac..c9c2debda433 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/base.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/base.ts @@ -72,8 +72,30 @@ export interface Metric { * - 'prerender': for pages that were prerendered. * - 'restore': for pages that were discarded by the browser and then * restored by the user. + * - 'soft-navigation': for soft navigations. */ - navigationType: 'navigate' | 'reload' | 'back-forward' | 'back-forward-cache' | 'prerender' | 'restore'; + navigationType: + | 'navigate' + | 'reload' + | 'back-forward' + | 'back-forward-cache' + | 'prerender' + | 'restore' + | 'soft-navigation'; + + /** + * The navigationId the metric happened for. This is particularly relevant for soft navigations where + * the metric may be reported for a previous URL. + * + * navigationIds are UUID strings. + */ + navigationId: string; + + /** + * The navigation URL the metric happened for. This is particularly relevant for soft navigations where + * the metric may be reported for a previous URL. + */ + navigationURL?: string; } /** The union of supported metric types. */ @@ -113,10 +135,12 @@ export interface ReportCallback { export interface ReportOpts { reportAllChanges?: boolean; + durationThreshold?: number; + reportSoftNavs?: boolean; } export interface AttributionReportOpts extends ReportOpts { - generateTarget?: (el: Node | null) => string; + generateTarget?: (el: Node | null) => string | undefined; } /** 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 5acaaa27c9ab..6048c616e1f0 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/cls.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/cls.ts @@ -34,7 +34,8 @@ export interface CLSAttribution { * By default, a selector identifying the first element (in document order) * that shifted when the single largest layout shift that contributed to the * page's CLS score occurred. If the `generateTarget` configuration option - * was passed, then this will instead be the return value of that function. + * was passed, then this will instead be the return value of that function, + * falling back to the default if that returns null or undefined. */ largestShiftTarget?: string; /** 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 ce668192766f..f93f627d3b51 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/fcp.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/fcp.ts @@ -52,9 +52,9 @@ export interface FCPAttribution { /** * 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 + * navigationEntry.serverTiming */ - navigationEntry?: PerformanceNavigationTiming; + navigationEntry?: PerformanceNavigationTiming | SoftNavigationEntry; } /** 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 e73743866301..d2b2063c7d04 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/inp.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/inp.ts @@ -60,7 +60,8 @@ export interface INPAttribution { * occurred. If this value is an empty string, that generally means the * element was removed from the DOM after the interaction. If the * `generateTarget` configuration option was passed, then this will instead - * be the return value of that function. + * be the return value of that function, falling back to the default if that + * returns null or undefined. */ interactionTarget: string; /** 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 293531b3d45c..1e9bac10044c 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts @@ -21,7 +21,7 @@ import type { Metric } from './base.js'; */ export interface LCPMetric extends Metric { name: 'LCP'; - entries: LargestContentfulPaint[]; + entries: (LargestContentfulPaint | InteractionContentfulPaint)[]; } /** @@ -34,7 +34,8 @@ export interface LCPAttribution { * By default, a selector identifying the element corresponding to the * largest contentful paint for the page. If the `generateTarget` * configuration option was passed, then this will instead be the return - * value of that function. + * value of that function, falling back to the default if that returns null + * or undefined. */ target?: string; /** @@ -69,18 +70,19 @@ export interface LCPAttribution { /** * 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 + * navigationEntry.serverTiming */ - navigationEntry?: PerformanceNavigationTiming; + navigationEntry?: PerformanceNavigationTiming | SoftNavigationEntry; /** * The `resource` entry for the LCP resource (if applicable), which is useful * for diagnosing resource load issues. */ lcpResourceEntry?: PerformanceResourceTiming; /** - * The `LargestContentfulPaint` entry corresponding to LCP. + * The `LargestContentfulPaint` entry corresponding to LCP + * (or `InteractionContentfulPaint` for soft navigations). */ - lcpEntry?: LargestContentfulPaint; + lcpEntry?: LargestContentfulPaint | InteractionContentfulPaint; } /** 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 2a43668d7d8f..d9d12a65bc16 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/ttfb.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/ttfb.ts @@ -21,7 +21,7 @@ import type { Metric } from './base'; */ export interface TTFBMetric extends Metric { name: 'TTFB'; - entries: PerformanceNavigationTiming[]; + entries: PerformanceNavigationTiming[] | SoftNavigationEntry[]; } /** @@ -65,9 +65,9 @@ export interface TTFBAttribution { /** * 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 + * example: navigationEntry.serverTiming */ - navigationEntry?: PerformanceNavigationTiming; + navigationEntry?: PerformanceNavigationTiming | SoftNavigationEntry; } /**