From ccb588251dcd253be3ac5f8e77da28fdb1b3c6f8 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 3 Apr 2024 11:33:42 +0200 Subject: [PATCH 1/3] feat(browser): Bump web-vitals to 3.5.2 --- packages/tracing-internal/.eslintrc.js | 6 + .../src/browser/instrument.ts | 2 +- .../src/browser/metrics/index.ts | 39 ++++- .../src/browser/web-vitals/README.md | 4 +- .../src/browser/web-vitals/getCLS.ts | 105 +++++------ .../src/browser/web-vitals/getFID.ts | 80 ++++++--- .../src/browser/web-vitals/getINP.ts | 165 +++++++++--------- .../src/browser/web-vitals/getLCP.ts | 101 ++++++----- .../browser/web-vitals/lib/bindReporter.ts | 22 ++- .../web-vitals/lib/generateUniqueID.ts | 2 +- .../web-vitals/lib/getNavigationEntry.ts | 4 +- .../web-vitals/lib/getVisibilityWatcher.ts | 60 +++++-- .../src/browser/web-vitals/lib/initMetric.ts | 19 +- .../src/browser/web-vitals/lib/observe.ts | 14 +- .../src/browser/web-vitals/lib/onHidden.ts | 8 +- .../src/browser/web-vitals/lib/runOnce.ts | 29 +++ .../browser/web-vitals/lib/whenActivated.ts | 25 +++ .../src/browser/web-vitals/onFCP.ts | 65 +++++++ .../src/browser/web-vitals/onTTFB.ts | 49 +++--- .../src/browser/web-vitals/types.ts | 66 +------ .../src/browser/web-vitals/types/base.ts | 42 ++++- .../src/browser/web-vitals/types/cls.ts | 6 +- .../src/browser/web-vitals/types/fcp.ts | 80 +++++++++ .../src/browser/web-vitals/types/fid.ts | 6 +- .../src/browser/web-vitals/types/inp.ts | 8 +- .../src/browser/web-vitals/types/lcp.ts | 17 +- .../src/browser/web-vitals/types/ttfb.ts | 11 +- 27 files changed, 671 insertions(+), 364 deletions(-) create mode 100644 packages/tracing-internal/src/browser/web-vitals/lib/runOnce.ts create mode 100644 packages/tracing-internal/src/browser/web-vitals/lib/whenActivated.ts create mode 100644 packages/tracing-internal/src/browser/web-vitals/onFCP.ts create mode 100644 packages/tracing-internal/src/browser/web-vitals/types/fcp.ts diff --git a/packages/tracing-internal/.eslintrc.js b/packages/tracing-internal/.eslintrc.js index efa4c594a069..50f4342a74c6 100644 --- a/packages/tracing-internal/.eslintrc.js +++ b/packages/tracing-internal/.eslintrc.js @@ -7,5 +7,11 @@ module.exports = { '@sentry-internal/sdk/no-optional-chaining': 'off', }, }, + { + files: ['src/browser/web-vitals/**'], + rules: { + '@typescript-eslint/explicit-function-return-type': 'off', + }, + }, ], }; diff --git a/packages/tracing-internal/src/browser/instrument.ts b/packages/tracing-internal/src/browser/instrument.ts index 35e251983508..e4af4805315a 100644 --- a/packages/tracing-internal/src/browser/instrument.ts +++ b/packages/tracing-internal/src/browser/instrument.ts @@ -69,7 +69,7 @@ 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'; + navigationType: 'navigate' | 'reload' | 'back-forward' | 'back-forward-cache' | 'prerender' | 'restore'; } type InstrumentHandlerType = InstrumentHandlerTypeMetric | InstrumentHandlerTypePerformanceObserver; diff --git a/packages/tracing-internal/src/browser/metrics/index.ts b/packages/tracing-internal/src/browser/metrics/index.ts index 5ce107e4b701..770ec8354596 100644 --- a/packages/tracing-internal/src/browser/metrics/index.ts +++ b/packages/tracing-internal/src/browser/metrics/index.ts @@ -16,9 +16,46 @@ import { import { WINDOW } from '../types'; import { getNavigationEntry } from '../web-vitals/lib/getNavigationEntry'; import { getVisibilityWatcher } from '../web-vitals/lib/getVisibilityWatcher'; -import type { NavigatorDeviceMemory, NavigatorNetworkInformation } from '../web-vitals/types'; import { isMeasurementValue, startAndEndSpan } from './utils'; +interface NavigatorNetworkInformation { + readonly connection?: NetworkInformation; +} + +// http://wicg.github.io/netinfo/#connection-types +type ConnectionType = 'bluetooth' | 'cellular' | 'ethernet' | 'mixed' | 'none' | 'other' | 'unknown' | 'wifi' | 'wimax'; + +// http://wicg.github.io/netinfo/#effectiveconnectiontype-enum +type EffectiveConnectionType = '2g' | '3g' | '4g' | 'slow-2g'; + +// http://wicg.github.io/netinfo/#dom-megabit +type Megabit = number; +// http://wicg.github.io/netinfo/#dom-millisecond +type Millisecond = number; + +// http://wicg.github.io/netinfo/#networkinformation-interface +interface NetworkInformation extends EventTarget { + // http://wicg.github.io/netinfo/#type-attribute + readonly type?: ConnectionType; + // http://wicg.github.io/netinfo/#effectivetype-attribute + readonly effectiveType?: EffectiveConnectionType; + // http://wicg.github.io/netinfo/#downlinkmax-attribute + readonly downlinkMax?: Megabit; + // http://wicg.github.io/netinfo/#downlink-attribute + readonly downlink?: Megabit; + // http://wicg.github.io/netinfo/#rtt-attribute + readonly rtt?: Millisecond; + // http://wicg.github.io/netinfo/#savedata-attribute + readonly saveData?: boolean; + // http://wicg.github.io/netinfo/#handling-changes-to-the-underlying-connection + onchange?: EventListener; +} + +// https://w3c.github.io/device-memory/#sec-device-memory-js-api +interface NavigatorDeviceMemory { + readonly deviceMemory?: number; +} + const MAX_INT_AS_BYTES = 2147483647; /** diff --git a/packages/tracing-internal/src/browser/web-vitals/README.md b/packages/tracing-internal/src/browser/web-vitals/README.md index c87dd69d55b7..653ee22c7ff1 100644 --- a/packages/tracing-internal/src/browser/web-vitals/README.md +++ b/packages/tracing-internal/src/browser/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: v3.0.4 +This was vendored from: https://github.com/GoogleChrome/web-vitals: v3.5.2 The commit SHA used is: -[7f0ed0bfb03c356e348a558a3eda111b498a2a11](https://github.com/GoogleChrome/web-vitals/tree/7f0ed0bfb03c356e348a558a3eda111b498a2a11) +[7b44bea0d5ba6629c5fd34c3a09cc683077871d0](https://github.com/GoogleChrome/web-vitals/tree/7b44bea0d5ba6629c5fd34c3a09cc683077871d0) Current vendored web vitals are: diff --git a/packages/tracing-internal/src/browser/web-vitals/getCLS.ts b/packages/tracing-internal/src/browser/web-vitals/getCLS.ts index fdd1e867adfa..f72b0aa309a0 100644 --- a/packages/tracing-internal/src/browser/web-vitals/getCLS.ts +++ b/packages/tracing-internal/src/browser/web-vitals/getCLS.ts @@ -18,14 +18,19 @@ import { bindReporter } from './lib/bindReporter'; import { initMetric } from './lib/initMetric'; import { observe } from './lib/observe'; import { onHidden } from './lib/onHidden'; -import type { CLSMetric, ReportCallback, StopListening } from './types'; +import { runOnce } from './lib/runOnce'; +import { onFCP } from './onFCP'; +import type { CLSMetric, CLSReportCallback, 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]; /** - * Calculates the [CLS](https://web.dev/cls/) value for the current page and + * Calculates the [CLS](https://web.dev/articles/cls) value for the current page and * calls the `callback` function once the value is ready to be reported, along * with all `layout-shift` performance entries that were used in the metric * value calculation. The reported value is a `double` (corresponding to a - * [layout shift score](https://web.dev/cls/#layout-shift-score)). + * [layout shift score](https://web.dev/articles/cls#layout_shift_score)). * * If the `reportAllChanges` configuration option is set to `true`, the * `callback` function will be called as soon as the value is initially @@ -41,63 +46,65 @@ import type { CLSMetric, ReportCallback, StopListening } from './types'; * hidden. As a result, the `callback` function might be called multiple times * during the same page load._ */ -export const onCLS = (onReport: ReportCallback): StopListening | undefined => { - const metric = initMetric('CLS', 0); - let report: ReturnType; +export const onCLS = (onReport: CLSReportCallback, opts: ReportOpts = {}): void => { + // 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 report: ReturnType; - let sessionValue = 0; - let sessionEntries: PerformanceEntry[] = []; + let sessionValue = 0; + let sessionEntries: LayoutShift[] = []; - // const handleEntries = (entries: Metric['entries']) => { - const handleEntries = (entries: LayoutShift[]): void => { - entries.forEach(entry => { - // Only count layout shifts without recent user input. - if (!entry.hadRecentInput) { - const firstSessionEntry = sessionEntries[0]; - const lastSessionEntry = sessionEntries[sessionEntries.length - 1]; + const handleEntries = (entries: LayoutShift[]): void => { + entries.forEach(entry => { + // Only count layout shifts without recent user input. + if (!entry.hadRecentInput) { + const firstSessionEntry = sessionEntries[0]; + const lastSessionEntry = sessionEntries[sessionEntries.length - 1]; - // If the entry occurred less than 1 second after the previous entry and - // less than 5 seconds after the first entry in the session, include the - // entry in the current session. Otherwise, start a new session. - if ( - sessionValue && - sessionEntries.length !== 0 && - entry.startTime - lastSessionEntry.startTime < 1000 && - entry.startTime - firstSessionEntry.startTime < 5000 - ) { - sessionValue += entry.value; - sessionEntries.push(entry); - } else { - sessionValue = entry.value; - sessionEntries = [entry]; - } + // If the entry occurred less than 1 second after the previous entry + // and less than 5 seconds after the first entry in the session, + // include the entry in the current session. Otherwise, start a new + // session. + if ( + sessionValue && + entry.startTime - lastSessionEntry.startTime < 1000 && + entry.startTime - firstSessionEntry.startTime < 5000 + ) { + sessionValue += entry.value; + sessionEntries.push(entry); + } else { + sessionValue = entry.value; + sessionEntries = [entry]; + } + } + }); // If the current session value is larger than the current CLS value, // update CLS and the entries contributing to it. if (sessionValue > metric.value) { metric.value = sessionValue; metric.entries = sessionEntries; - if (report) { - report(); - } + report(); } - } - }); - }; - - const po = observe('layout-shift', handleEntries); - if (po) { - report = bindReporter(onReport, metric); + }; - const stopListening = (): void => { - handleEntries(po.takeRecords() as CLSMetric['entries']); - report(true); - }; + const po = observe('layout-shift', handleEntries); + if (po) { + report = bindReporter(onReport, metric, CLSThresholds, opts.reportAllChanges); - onHidden(stopListening); + onHidden(() => { + handleEntries(po.takeRecords() as CLSMetric['entries']); + report(true); + }); - return stopListening; - } - - return; + // 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. + setTimeout(report, 0); + } + }), + ); }; diff --git a/packages/tracing-internal/src/browser/web-vitals/getFID.ts b/packages/tracing-internal/src/browser/web-vitals/getFID.ts index fd19e112121a..feac578097de 100644 --- a/packages/tracing-internal/src/browser/web-vitals/getFID.ts +++ b/packages/tracing-internal/src/browser/web-vitals/getFID.ts @@ -14,15 +14,27 @@ * limitations under the License. */ +import { WINDOW } from '../types'; import { bindReporter } from './lib/bindReporter'; import { getVisibilityWatcher } from './lib/getVisibilityWatcher'; import { initMetric } from './lib/initMetric'; import { observe } from './lib/observe'; import { onHidden } from './lib/onHidden'; -import type { FIDMetric, PerformanceEventTiming, ReportCallback } from './types'; +import { runOnce } from './lib/runOnce'; +import { whenActivated } from './lib/whenActivated'; +import type { + FIDMetric, + FIDReportCallback, + FirstInputPolyfillCallback, + 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]; /** - * Calculates the [FID](https://web.dev/fid/) value for the current page and + * Calculates the [FID](https://web.dev/articles/fid) value for the current page and * calls the `callback` function once the value is ready, along with the * relevant `first-input` performance entry used to determine the value. The * reported value is a `DOMHighResTimeStamp`. @@ -30,32 +42,46 @@ import type { FIDMetric, PerformanceEventTiming, ReportCallback } from './types' * _**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: ReportCallback): void => { - const visibilityWatcher = getVisibilityWatcher(); - const metric = initMetric('FID'); - // eslint-disable-next-line prefer-const - let report: ReturnType; - - const handleEntry = (entry: PerformanceEventTiming): void => { - // Only report if the page wasn't hidden prior to the first input. - if (entry.startTime < visibilityWatcher.firstHiddenTime) { - metric.value = entry.processingStart - entry.startTime; - metric.entries.push(entry); - report(true); - } - }; +export const onFID = (onReport: FIDReportCallback, opts: ReportOpts = {}): void => { + whenActivated(() => { + const visibilityWatcher = getVisibilityWatcher(); + const metric = initMetric('FID'); + // eslint-disable-next-line prefer-const + let report: ReturnType; - const handleEntries = (entries: FIDMetric['entries']): void => { - (entries as PerformanceEventTiming[]).forEach(handleEntry); - }; + const handleEntry = (entry: PerformanceEventTiming) => { + // Only report if the page wasn't hidden prior to the first input. + if (entry.startTime < visibilityWatcher.firstHiddenTime) { + metric.value = entry.processingStart - entry.startTime; + metric.entries.push(entry); + report(true); + } + }; - const po = observe('first-input', handleEntries); - report = bindReporter(onReport, metric); + const handleEntries = (entries: FIDMetric['entries']) => { + (entries as PerformanceEventTiming[]).forEach(handleEntry); + }; - if (po) { - onHidden(() => { - handleEntries(po.takeRecords() as FIDMetric['entries']); - po.disconnect(); - }, true); - } + const po = observe('first-input', handleEntries); + report = bindReporter(onReport, metric, FIDThresholds, opts.reportAllChanges); + + if (po) { + onHidden( + runOnce(() => { + handleEntries(po.takeRecords() as FIDMetric['entries']); + po.disconnect(); + }), + ); + } + + if (WINDOW.__WEB_VITALS_POLYFILL__) { + // eslint-disable-next-line no-console + console.warn('The web-vitals "base+polyfill" build is deprecated. See: https://bit.ly/3aqzsGm'); + + // Prefer the native implementation if available, + if (!po) { + WINDOW.webVitals.firstInputPolyfill(handleEntry as FirstInputPolyfillCallback); + } + } + }); }; diff --git a/packages/tracing-internal/src/browser/web-vitals/getINP.ts b/packages/tracing-internal/src/browser/web-vitals/getINP.ts index 546838bff15d..5c4a185aa92f 100644 --- a/packages/tracing-internal/src/browser/web-vitals/getINP.ts +++ b/packages/tracing-internal/src/browser/web-vitals/getINP.ts @@ -14,13 +14,14 @@ * limitations under the License. */ +import { WINDOW } from '../types'; import { bindReporter } from './lib/bindReporter'; import { initMetric } from './lib/initMetric'; import { observe } from './lib/observe'; import { onHidden } from './lib/onHidden'; import { getInteractionCount, initInteractionCountPolyfill } from './lib/polyfills/interactionCountPolyfill'; -import type { ReportCallback, ReportOpts } from './types'; -import type { INPMetric } from './types/inp'; +import { whenActivated } from './lib/whenActivated'; +import type { INPMetric, INPReportCallback, MetricRatingThresholds, ReportOpts } from './types'; interface Interaction { id: number; @@ -28,12 +29,19 @@ interface Interaction { entries: PerformanceEventTiming[]; } +/** 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 = (): number => { - return getInteractionCount(); +const getInteractionCountForNavigation = () => { + return getInteractionCount() - prevInteractionCount; }; // To prevent unnecessary memory usage on pages with lots of interactions, @@ -54,7 +62,7 @@ const longestInteractionMap: { [interactionId: string]: Interaction } = {}; * entry is part of an existing interaction, it is merged and the latency * and entries list is updated as needed. */ -const processEntry = (entry: PerformanceEventTiming): void => { +const processEntry = (entry: PerformanceEventTiming) => { // The least-long of the 10 longest interactions. const minLongestInteraction = longestInteractionList[longestInteractionList.length - 1]; @@ -96,7 +104,7 @@ const processEntry = (entry: PerformanceEventTiming): void => { * Returns the estimated p98 longest interaction based on the stored * interaction candidates and the interaction count for the current page. */ -const estimateP98LongestInteraction = (): Interaction => { +const estimateP98LongestInteraction = () => { const candidateInteractionIndex = Math.min( longestInteractionList.length - 1, Math.floor(getInteractionCountForNavigation() / 50), @@ -106,7 +114,7 @@ const estimateP98LongestInteraction = (): Interaction => { }; /** - * Calculates the [INP](https://web.dev/responsiveness/) value for the current + * 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 * the `event` performance entries reported for that interaction. The reported * value is a `DOMHighResTimeStamp`. @@ -116,7 +124,7 @@ const estimateP98LongestInteraction = (): Interaction => { * default threshold is `40`, which means INP scores of less than 40 are * reported as 0. Note that this will not affect your 75th percentile INP value * unless that value is also less than 40 (well below the recommended - * [good](https://web.dev/inp/#what-is-a-good-inp-score) threshold). + * [good](https://web.dev/articles/inp#what_is_a_good_inp_score) threshold). * * If the `reportAllChanges` configuration option is set to `true`, the * `callback` function will be called as soon as the value is initially @@ -132,84 +140,81 @@ const estimateP98LongestInteraction = (): Interaction => { * hidden. As a result, the `callback` function might be called multiple times * during the same page load._ */ -export const onINP = (onReport: ReportCallback, opts?: ReportOpts): void => { - // Set defaults - // eslint-disable-next-line no-param-reassign - opts = opts || {}; - - // https://web.dev/inp/#what's-a-%22good%22-inp-value - // const thresholds = [200, 500]; +export const onINP = (onReport: INPReportCallback, opts: ReportOpts = {}) => { + whenActivated(() => { + // TODO(philipwalton): remove once the polyfill is no longer needed. + initInteractionCountPolyfill(); + + const metric = initMetric('INP'); + // eslint-disable-next-line prefer-const + let report: ReturnType; + + const handleEntries = (entries: INPMetric['entries']) => { + entries.forEach(entry => { + if (entry.interactionId) { + processEntry(entry); + } - // TODO(philipwalton): remove once the polyfill is no longer needed. - initInteractionCountPolyfill(); + // 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); + } + } + }); - const metric = initMetric('INP'); - // eslint-disable-next-line prefer-const - let report: ReturnType; + const inp = estimateP98LongestInteraction(); - const handleEntries = (entries: INPMetric['entries']): void => { - entries.forEach(entry => { - if (entry.interactionId) { - processEntry(entry); + if (inp && inp.latency !== metric.value) { + metric.value = inp.latency; + metric.entries = inp.entries; + report(); } - - // Entries of type `first-input` don't currently have an `interactionId`, - // so to consider them in INP we have to first check that an existing - // entry doesn't match the `duration` and `startTime`. - // Note that this logic assumes that `event` entries are dispatched - // before `first-input` entries. This is true in Chrome but it is not - // true in Firefox; however, Firefox doesn't support interactionId, so - // it's not an issue at the moment. - // TODO(philipwalton): remove once crbug.com/1325826 is fixed. - if (entry.entryType === 'first-input') { - const noMatchingEntry = !longestInteractionList.some(interaction => { - return interaction.entries.some(prevEntry => { - return entry.duration === prevEntry.duration && entry.startTime === prevEntry.startTime; - }); - }); - if (noMatchingEntry) { - processEntry(entry); - } + }; + + const po = observe('event', handleEntries, { + // Event Timing entries have their durations rounded to the nearest 8ms, + // so a duration of 40ms would be any event that spans 2.5 or more frames + // at 60Hz. This threshold is chosen to strike a balance between usefulness + // and performance. Running this callback for any interaction that spans + // just one or two frames is likely not worth the insight that could be + // gained. + durationThreshold: opts.durationThreshold != null ? opts.durationThreshold : 40, + } as PerformanceObserverInit); + + 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 + // where the first interaction is less than the `durationThreshold`. + if ('PerformanceEventTiming' in WINDOW && 'interactionId' in PerformanceEventTiming.prototype) { + po.observe({ type: 'first-input', buffered: true }); } - }); - const inp = estimateP98LongestInteraction(); + onHidden(() => { + handleEntries(po.takeRecords() as INPMetric['entries']); - if (inp && inp.latency !== metric.value) { - metric.value = inp.latency; - metric.entries = inp.entries; - report(); - } - }; - - const po = observe('event', handleEntries, { - // Event Timing entries have their durations rounded to the nearest 8ms, - // so a duration of 40ms would be any event that spans 2.5 or more frames - // at 60Hz. This threshold is chosen to strike a balance between usefulness - // and performance. Running this callback for any interaction that spans - // just one or two frames is likely not worth the insight that could be - // gained. - durationThreshold: opts.durationThreshold || 40, - } as PerformanceObserverInit); - - report = bindReporter(onReport, metric, opts.reportAllChanges); - - if (po) { - // Also observe entries of type `first-input`. This is useful in cases - // where the first interaction is less than the `durationThreshold`. - po.observe({ type: 'first-input', buffered: true }); - - onHidden(() => { - handleEntries(po.takeRecords() as INPMetric['entries']); - - // If the interaction count shows that there were interactions but - // none were captured by the PerformanceObserver, report a latency of 0. - if (metric.value < 0 && getInteractionCountForNavigation() > 0) { - metric.value = 0; - metric.entries = []; - } + // 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); - }); - } + report(true); + }); + } + }); }; diff --git a/packages/tracing-internal/src/browser/web-vitals/getLCP.ts b/packages/tracing-internal/src/browser/web-vitals/getLCP.ts index 37e37c01eebd..facda8ce7a9d 100644 --- a/packages/tracing-internal/src/browser/web-vitals/getLCP.ts +++ b/packages/tracing-internal/src/browser/web-vitals/getLCP.ts @@ -20,64 +20,75 @@ import { getVisibilityWatcher } from './lib/getVisibilityWatcher'; import { initMetric } from './lib/initMetric'; import { observe } from './lib/observe'; import { onHidden } from './lib/onHidden'; -import type { LCPMetric, ReportCallback, StopListening } from './types'; +import { runOnce } from './lib/runOnce'; +import { whenActivated } from './lib/whenActivated'; +import type { LCPMetric, LCPReportCallback, 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]; const reportedMetricIDs: Record = {}; /** - * Calculates the [LCP](https://web.dev/lcp/) value for the current page and + * Calculates the [LCP](https://web.dev/articles/lcp) value for the current page and * calls the `callback` function once the value is ready (along with the * relevant `largest-contentful-paint` performance entry used to determine the * value). The reported value is a `DOMHighResTimeStamp`. + * + * If the `reportAllChanges` configuration option is set to `true`, the + * `callback` function will be called any time a new `largest-contentful-paint` + * performance entry is dispatched, or once the final value of the metric has + * been determined. */ -export const onLCP = (onReport: ReportCallback): StopListening | undefined => { - const visibilityWatcher = getVisibilityWatcher(); - const metric = initMetric('LCP'); - let report: ReturnType; - - const handleEntries = (entries: LCPMetric['entries']): void => { - const lastEntry = entries[entries.length - 1] as LargestContentfulPaint; - if (lastEntry) { - // 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. - const value = Math.max(lastEntry.startTime - getActivationStart(), 0); - - // Only report if the page wasn't hidden prior to LCP. - if (value < visibilityWatcher.firstHiddenTime) { - metric.value = value; - metric.entries = [lastEntry]; - report(); - } - } - }; - - const po = observe('largest-contentful-paint', handleEntries); - - if (po) { - report = bindReporter(onReport, metric); +export const onLCP = (onReport: LCPReportCallback, opts: ReportOpts = {}) => { + whenActivated(() => { + const visibilityWatcher = getVisibilityWatcher(); + const metric = initMetric('LCP'); + let report: ReturnType; - const stopListening = (): void => { - if (!reportedMetricIDs[metric.id]) { - handleEntries(po.takeRecords() as LCPMetric['entries']); - po.disconnect(); - reportedMetricIDs[metric.id] = true; - report(true); + const handleEntries = (entries: LCPMetric['entries']) => { + const lastEntry = entries[entries.length - 1] as LargestContentfulPaint; + if (lastEntry) { + // Only report if the page wasn't hidden prior to LCP. + if (lastEntry.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 + // where `activationStart` occurs after the LCP, this time should be + // clamped at 0. + metric.value = Math.max(lastEntry.startTime - getActivationStart(), 0); + metric.entries = [lastEntry]; + report(); + } } }; - // Stop listening after input. Note: while scrolling is an input that - // stop LCP observation, it's unreliable since it can be programmatically - // generated. See: https://github.com/GoogleChrome/web-vitals/issues/75 - ['keydown', 'click'].forEach(type => { - addEventListener(type, stopListening, { once: true, capture: true }); - }); + const po = observe('largest-contentful-paint', handleEntries); - onHidden(stopListening, true); + if (po) { + report = bindReporter(onReport, metric, LCPThresholds, opts.reportAllChanges); - return stopListening; - } + const stopListening = runOnce(() => { + if (!reportedMetricIDs[metric.id]) { + handleEntries(po.takeRecords() as LCPMetric['entries']); + po.disconnect(); + reportedMetricIDs[metric.id] = true; + report(true); + } + }); - return; + // Stop listening after input. 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 + ['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 + addEventListener(type, () => setTimeout(stopListening, 0), true); + }); + + onHidden(stopListening); + } + }); }; diff --git a/packages/tracing-internal/src/browser/web-vitals/lib/bindReporter.ts b/packages/tracing-internal/src/browser/web-vitals/lib/bindReporter.ts index 79f3f874e2d8..43fdc8d9e541 100644 --- a/packages/tracing-internal/src/browser/web-vitals/lib/bindReporter.ts +++ b/packages/tracing-internal/src/browser/web-vitals/lib/bindReporter.ts @@ -14,13 +14,24 @@ * limitations under the License. */ -import type { Metric, ReportCallback } from '../types'; +import type { MetricRatingThresholds, MetricType } from '../types'; -export const bindReporter = ( - callback: ReportCallback, - metric: Metric, +const getRating = (value: number, thresholds: MetricRatingThresholds): MetricType['rating'] => { + if (value > thresholds[1]) { + return 'poor'; + } + if (value > thresholds[0]) { + return 'needs-improvement'; + } + return 'good'; +}; + +export const bindReporter = ( + callback: (metric: Extract) => void, + metric: Extract, + thresholds: MetricRatingThresholds, reportAllChanges?: boolean, -): ((forceReport?: boolean) => void) => { +) => { let prevValue: number; let delta: number; return (forceReport?: boolean) => { @@ -35,6 +46,7 @@ export const bindReporter = ( if (delta || prevValue === undefined) { prevValue = metric.value; metric.delta = delta; + metric.rating = getRating(metric.value, thresholds); callback(metric); } } diff --git a/packages/tracing-internal/src/browser/web-vitals/lib/generateUniqueID.ts b/packages/tracing-internal/src/browser/web-vitals/lib/generateUniqueID.ts index a7972017d51c..bdecdc6220ad 100644 --- a/packages/tracing-internal/src/browser/web-vitals/lib/generateUniqueID.ts +++ b/packages/tracing-internal/src/browser/web-vitals/lib/generateUniqueID.ts @@ -19,6 +19,6 @@ * number, the current timestamp with a 13-digit number integer. * @return {string} */ -export const generateUniqueID = (): string => { +export const generateUniqueID = () => { return `v3-${Date.now()}-${Math.floor(Math.random() * (9e12 - 1)) + 1e12}`; }; diff --git a/packages/tracing-internal/src/browser/web-vitals/lib/getNavigationEntry.ts b/packages/tracing-internal/src/browser/web-vitals/lib/getNavigationEntry.ts index 75ec564eb5de..a4b1348d798f 100644 --- a/packages/tracing-internal/src/browser/web-vitals/lib/getNavigationEntry.ts +++ b/packages/tracing-internal/src/browser/web-vitals/lib/getNavigationEntry.ts @@ -19,9 +19,9 @@ import type { NavigationTimingPolyfillEntry } from '../types'; const getNavigationEntryFromPerformanceTiming = (): NavigationTimingPolyfillEntry => { // eslint-disable-next-line deprecation/deprecation - const timing = WINDOW.performance.timing; + const timing = performance.timing; // eslint-disable-next-line deprecation/deprecation - const type = WINDOW.performance.navigation.type; + const type = performance.navigation.type; const navigationEntry: { [key: string]: number | string } = { entryType: 'navigation', diff --git a/packages/tracing-internal/src/browser/web-vitals/lib/getVisibilityWatcher.ts b/packages/tracing-internal/src/browser/web-vitals/lib/getVisibilityWatcher.ts index f47ab0d82cf3..431bb7d4e143 100644 --- a/packages/tracing-internal/src/browser/web-vitals/lib/getVisibilityWatcher.ts +++ b/packages/tracing-internal/src/browser/web-vitals/lib/getVisibilityWatcher.ts @@ -15,33 +15,65 @@ */ import { WINDOW } from '../../types'; -import { onHidden } from './onHidden'; let firstHiddenTime = -1; -const initHiddenTime = (): number => { - // If the document is hidden and not prerendering, assume it was always - // hidden and the page was loaded in the background. +const initHiddenTime = () => { + // If the document is hidden when this code runs, assume it was always + // hidden and the page was loaded in the background, with the one exception + // that visibility state is always 'hidden' during prerendering, so we have + // to ignore that case until prerendering finishes (see: `prerenderingchange` + // event logic below). return WINDOW.document.visibilityState === 'hidden' && !WINDOW.document.prerendering ? 0 : Infinity; }; -const trackChanges = (): void => { - // Update the time if/when the document becomes hidden. - onHidden(({ timeStamp }) => { - firstHiddenTime = timeStamp; - }, true); +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; + + // Remove all listeners now that a `firstHiddenTime` value has been set. + removeChangeListeners(); + } +}; + +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); +}; + +const removeChangeListeners = () => { + removeEventListener('visibilitychange', onVisibilityUpdate, true); + removeEventListener('prerenderingchange', onVisibilityUpdate, true); }; -export const getVisibilityWatcher = (): { - readonly firstHiddenTime: number; -} => { +export const getVisibilityWatcher = () => { if (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. - firstHiddenTime = initHiddenTime(); - trackChanges(); + if (WINDOW.__WEB_VITALS_POLYFILL__) { + firstHiddenTime = WINDOW.webVitals.firstHiddenTime; + if (firstHiddenTime === Infinity) { + addChangeListeners(); + } + } else { + firstHiddenTime = initHiddenTime(); + addChangeListeners(); + } } return { get firstHiddenTime() { diff --git a/packages/tracing-internal/src/browser/web-vitals/lib/initMetric.ts b/packages/tracing-internal/src/browser/web-vitals/lib/initMetric.ts index 2fa5854fd6db..9098227ae1a4 100644 --- a/packages/tracing-internal/src/browser/web-vitals/lib/initMetric.ts +++ b/packages/tracing-internal/src/browser/web-vitals/lib/initMetric.ts @@ -15,29 +15,34 @@ */ import { WINDOW } from '../../types'; -import type { Metric } from '../types'; +import type { MetricType } from '../types'; import { generateUniqueID } from './generateUniqueID'; import { getActivationStart } from './getActivationStart'; import { getNavigationEntry } from './getNavigationEntry'; -export const initMetric = (name: Metric['name'], value?: number): Metric => { +export const initMetric = (name: MetricName, value?: number) => { const navEntry = getNavigationEntry(); - let navigationType: Metric['navigationType'] = 'navigate'; + let navigationType: MetricType['navigationType'] = 'navigate'; if (navEntry) { if (WINDOW.document.prerendering || getActivationStart() > 0) { navigationType = 'prerender'; - } else { - navigationType = navEntry.type.replace(/_/g, '-') as Metric['navigationType']; + } else if (WINDOW.document.wasDiscarded) { + navigationType = 'restore'; + } else if (navEntry.type) { + navigationType = navEntry.type.replace(/_/g, '-') as MetricType['navigationType']; } } + // Use `entries` type specific for the metric. + const entries: Extract['entries'] = []; + return { name, value: typeof value === 'undefined' ? -1 : value, - rating: 'good', // Will be updated if the value changes. + rating: 'good' as const, // If needed, will be updated when reported. `const` to keep the type from widening to `string`. delta: 0, - entries: [], + entries, id: generateUniqueID(), navigationType, }; diff --git a/packages/tracing-internal/src/browser/web-vitals/lib/observe.ts b/packages/tracing-internal/src/browser/web-vitals/lib/observe.ts index 685105d5c7dc..d763b14dfdf0 100644 --- a/packages/tracing-internal/src/browser/web-vitals/lib/observe.ts +++ b/packages/tracing-internal/src/browser/web-vitals/lib/observe.ts @@ -14,11 +14,7 @@ * limitations under the License. */ -import type { FirstInputPolyfillEntry, NavigationTimingPolyfillEntry, PerformancePaintTiming } from '../types'; - -export interface PerformanceEntryHandler { - (entry: PerformanceEntry): void; -} +import type { FirstInputPolyfillEntry, NavigationTimingPolyfillEntry } from '../types'; interface PerformanceEntryMap { event: PerformanceEventTiming[]; @@ -47,7 +43,13 @@ export const observe = ( try { if (PerformanceObserver.supportedEntryTypes.includes(type)) { const po = new PerformanceObserver(list => { - callback(list.getEntries() as PerformanceEntryMap[K]); + // Delay by a microtask to workaround a bug in Safari where the + // callback is invoked immediately, rather than in a separate task. + // See: https://github.com/GoogleChrome/web-vitals/issues/277 + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Promise.resolve().then(() => { + callback(list.getEntries() as PerformanceEntryMap[K]); + }); }); po.observe( Object.assign( diff --git a/packages/tracing-internal/src/browser/web-vitals/lib/onHidden.ts b/packages/tracing-internal/src/browser/web-vitals/lib/onHidden.ts index 70152cadd16d..f9ec1dc94b90 100644 --- a/packages/tracing-internal/src/browser/web-vitals/lib/onHidden.ts +++ b/packages/tracing-internal/src/browser/web-vitals/lib/onHidden.ts @@ -20,14 +20,10 @@ export interface OnHiddenCallback { (event: Event): void; } -export const onHidden = (cb: OnHiddenCallback, once?: boolean): void => { - const onHiddenOrPageHide = (event: Event): void => { +export const onHidden = (cb: OnHiddenCallback) => { + const onHiddenOrPageHide = (event: Event) => { if (event.type === 'pagehide' || WINDOW.document.visibilityState === 'hidden') { cb(event); - if (once) { - removeEventListener('visibilitychange', onHiddenOrPageHide, true); - removeEventListener('pagehide', onHiddenOrPageHide, true); - } } }; addEventListener('visibilitychange', onHiddenOrPageHide, true); diff --git a/packages/tracing-internal/src/browser/web-vitals/lib/runOnce.ts b/packages/tracing-internal/src/browser/web-vitals/lib/runOnce.ts new file mode 100644 index 000000000000..c232fa16b487 --- /dev/null +++ b/packages/tracing-internal/src/browser/web-vitals/lib/runOnce.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface RunOnceCallback { + (arg: unknown): void; +} + +export const runOnce = (cb: RunOnceCallback) => { + let called = false; + return (arg: unknown) => { + if (!called) { + cb(arg); + called = true; + } + }; +}; diff --git a/packages/tracing-internal/src/browser/web-vitals/lib/whenActivated.ts b/packages/tracing-internal/src/browser/web-vitals/lib/whenActivated.ts new file mode 100644 index 000000000000..a04af1dd0376 --- /dev/null +++ b/packages/tracing-internal/src/browser/web-vitals/lib/whenActivated.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { WINDOW } from '../../types'; + +export const whenActivated = (callback: () => void) => { + if (WINDOW.document.prerendering) { + addEventListener('prerenderingchange', () => callback(), true); + } else { + callback(); + } +}; diff --git a/packages/tracing-internal/src/browser/web-vitals/onFCP.ts b/packages/tracing-internal/src/browser/web-vitals/onFCP.ts new file mode 100644 index 000000000000..b08973fefb40 --- /dev/null +++ b/packages/tracing-internal/src/browser/web-vitals/onFCP.ts @@ -0,0 +1,65 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { bindReporter } from './lib/bindReporter'; +import { getActivationStart } from './lib/getActivationStart'; +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'; + +/** Thresholds for FCP. See https://web.dev/articles/fcp#what_is_a_good_fcp_score */ +export const FCPThresholds: MetricRatingThresholds = [1800, 3000]; + +/** + * Calculates the [FCP](https://web.dev/articles/fcp) value for the current page and + * calls the `callback` function once the value is ready, along with the + * relevant `paint` performance entry used to determine the value. The reported + * value is a `DOMHighResTimeStamp`. + */ +export const onFCP = (onReport: FCPReportCallback, opts: ReportOpts = {}): void => { + whenActivated(() => { + const visibilityWatcher = getVisibilityWatcher(); + const metric = initMetric('FCP'); + let report: ReturnType; + + const handleEntries = (entries: FCPMetric['entries']) => { + (entries as PerformancePaintTiming[]).forEach(entry => { + if (entry.name === 'first-contentful-paint') { + po!.disconnect(); + + // Only report if the page wasn't hidden prior to the first paint. + if (entry.startTime < visibilityWatcher.firstHiddenTime) { + // 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); + metric.entries.push(entry); + report(true); + } + } + }); + }; + + const po = observe('paint', handleEntries); + + if (po) { + report = bindReporter(onReport, metric, FCPThresholds, opts!.reportAllChanges); + } + }); +}; diff --git a/packages/tracing-internal/src/browser/web-vitals/onTTFB.ts b/packages/tracing-internal/src/browser/web-vitals/onTTFB.ts index 946141107fa8..13e6c6679309 100644 --- a/packages/tracing-internal/src/browser/web-vitals/onTTFB.ts +++ b/packages/tracing-internal/src/browser/web-vitals/onTTFB.ts @@ -19,20 +19,19 @@ import { bindReporter } from './lib/bindReporter'; import { getActivationStart } from './lib/getActivationStart'; import { getNavigationEntry } from './lib/getNavigationEntry'; import { initMetric } from './lib/initMetric'; -import type { ReportCallback, ReportOpts } from './types'; -import type { TTFBMetric } from './types/ttfb'; +import { whenActivated } from './lib/whenActivated'; +import type { MetricRatingThresholds, ReportOpts, TTFBReportCallback } from './types'; + +/** Thresholds for TTFB. See https://web.dev/articles/ttfb#what_is_a_good_ttfb_score */ +export const TTFBThresholds: MetricRatingThresholds = [800, 1800]; /** * Runs in the next task after the page is done loading and/or prerendering. * @param callback */ -const whenReady = (callback: () => void): void => { - if (!WINDOW.document) { - return; - } - +const whenReady = (callback: () => void) => { if (WINDOW.document.prerendering) { - addEventListener('prerenderingchange', () => whenReady(callback), true); + whenActivated(() => whenReady(callback)); } else if (WINDOW.document.readyState !== 'complete') { addEventListener('load', () => whenReady(callback), true); } else { @@ -42,7 +41,7 @@ const whenReady = (callback: () => void): void => { }; /** - * Calculates the [TTFB](https://web.dev/time-to-first-byte/) value for the + * Calculates the [TTFB](https://web.dev/articles/ttfb) value for the * current page and calls the `callback` function once the page has loaded, * along with the relevant `navigation` performance entry used to determine the * value. The reported value is a `DOMHighResTimeStamp`. @@ -56,35 +55,31 @@ const whenReady = (callback: () => void): void => { * includes time spent on DNS lookup, connection negotiation, network latency, * and server processing time. */ -export const onTTFB = (onReport: ReportCallback, opts?: ReportOpts): void => { - // Set defaults - // eslint-disable-next-line no-param-reassign - opts = opts || {}; - - // https://web.dev/ttfb/#what-is-a-good-ttfb-score - // const thresholds = [800, 1800]; - +export const onTTFB = (onReport: TTFBReportCallback, opts: ReportOpts = {}) => { const metric = initMetric('TTFB'); - const report = bindReporter(onReport, metric, opts.reportAllChanges); + const report = bindReporter(onReport, metric, TTFBThresholds, opts.reportAllChanges); whenReady(() => { - const navEntry = getNavigationEntry() as TTFBMetric['entries'][number]; + 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; + // 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(navEntry.responseStart - getActivationStart(), 0); - - // In some cases the value reported 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 - if (metric.value < 0 || metric.value > performance.now()) return; + metric.value = Math.max(responseStart - getActivationStart(), 0); metric.entries = [navEntry]; - report(true); } }); diff --git a/packages/tracing-internal/src/browser/web-vitals/types.ts b/packages/tracing-internal/src/browser/web-vitals/types.ts index b4096b2678f6..05549fa0e0af 100644 --- a/packages/tracing-internal/src/browser/web-vitals/types.ts +++ b/packages/tracing-internal/src/browser/web-vitals/types.ts @@ -20,8 +20,11 @@ export * from './types/base'; export * from './types/polyfills'; export * from './types/cls'; +export * from './types/fcp'; export * from './types/fid'; +export * from './types/inp'; export * from './types/lcp'; +export * from './types/ttfb'; // -------------------------------------------------------------------------- // Web Vitals package globals @@ -42,15 +45,6 @@ declare global { } } -export type PerformancePaintTiming = PerformanceEntry; -export interface PerformanceEventTiming extends PerformanceEntry { - processingStart: DOMHighResTimeStamp; - processingEnd: DOMHighResTimeStamp; - duration: DOMHighResTimeStamp; - cancelable?: boolean; - target?: Element; -} - // -------------------------------------------------------------------------- // Everything below is modifications to built-in modules. // -------------------------------------------------------------------------- @@ -61,60 +55,13 @@ interface PerformanceEntryMap { paint: PerformancePaintTiming; } -export interface NavigatorNetworkInformation { - readonly connection?: NetworkInformation; -} - -// http://wicg.github.io/netinfo/#connection-types -type ConnectionType = 'bluetooth' | 'cellular' | 'ethernet' | 'mixed' | 'none' | 'other' | 'unknown' | 'wifi' | 'wimax'; - -// http://wicg.github.io/netinfo/#effectiveconnectiontype-enum -type EffectiveConnectionType = '2g' | '3g' | '4g' | 'slow-2g'; - -// http://wicg.github.io/netinfo/#dom-megabit -type Megabit = number; -// http://wicg.github.io/netinfo/#dom-millisecond -type Millisecond = number; - -// http://wicg.github.io/netinfo/#networkinformation-interface -interface NetworkInformation extends EventTarget { - // http://wicg.github.io/netinfo/#type-attribute - readonly type?: ConnectionType; - // http://wicg.github.io/netinfo/#effectivetype-attribute - readonly effectiveType?: EffectiveConnectionType; - // http://wicg.github.io/netinfo/#downlinkmax-attribute - readonly downlinkMax?: Megabit; - // http://wicg.github.io/netinfo/#downlink-attribute - readonly downlink?: Megabit; - // http://wicg.github.io/netinfo/#rtt-attribute - readonly rtt?: Millisecond; - // http://wicg.github.io/netinfo/#savedata-attribute - readonly saveData?: boolean; - // http://wicg.github.io/netinfo/#handling-changes-to-the-underlying-connection - onchange?: EventListener; -} - -// https://w3c.github.io/device-memory/#sec-device-memory-js-api -export interface NavigatorDeviceMemory { - readonly deviceMemory?: number; -} - -export type NavigationTimingPolyfillEntry = Omit< - PerformanceNavigationTiming, - | 'initiatorType' - | 'nextHopProtocol' - | 'redirectCount' - | 'transferSize' - | 'encodedBodySize' - | 'decodedBodySize' - | 'toJSON' ->; - // Update built-in types to be more accurate. declare global { - // https://wicg.github.io/nav-speculation/prerendering.html#document-prerendering interface Document { + // https://wicg.github.io/nav-speculation/prerendering.html#document-prerendering prerendering?: boolean; + // https://wicg.github.io/page-lifecycle/#sec-api + wasDiscarded?: boolean; } interface Performance { @@ -135,7 +82,6 @@ declare global { interface PerformanceEventTiming extends PerformanceEntry { duration: DOMHighResTimeStamp; interactionId?: number; - readonly target: Node | null; } // https://wicg.github.io/layout-instability/#sec-layout-shift-attribution diff --git a/packages/tracing-internal/src/browser/web-vitals/types/base.ts b/packages/tracing-internal/src/browser/web-vitals/types/base.ts index 6d748d7843b4..6279edffabd4 100644 --- a/packages/tracing-internal/src/browser/web-vitals/types/base.ts +++ b/packages/tracing-internal/src/browser/web-vitals/types/base.ts @@ -14,7 +14,13 @@ * 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'; export interface Metric { /** @@ -58,15 +64,22 @@ export interface Metric { entries: (PerformanceEntry | LayoutShift | FirstInputPolyfillEntry | NavigationTimingPolyfillEntry)[]; /** - * The type of navigation + * The type of navigation. * - * Navigation Timing API (or `undefined` if the browser doesn't - * support that API). For pages that are restored from the bfcache, this - * value will be 'back-forward-cache'. + * This will be the value returned by the Navigation Timing API (or + * `undefined` if the browser doesn't support that API), with the following + * exceptions: + * - 'back-forward-cache': for pages that are restored from the bfcache. + * - 'prerender': for pages that were prerendered. + * - 'restore': for pages that were discarded by the browser and then + * restored by the user. */ - navigationType: 'navigate' | 'reload' | 'back-forward' | 'back-forward-cache' | 'prerender'; + navigationType: 'navigate' | 'reload' | 'back-forward' | 'back-forward-cache' | 'prerender' | 'restore'; } +/** 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. */ @@ -79,8 +92,23 @@ export interface MetricWithAttribution extends Metric { attribution: { [key: string]: unknown }; } +/** + * The thresholds of metric's "good", "needs improvement", and "poor" ratings. + * + * - Metric values up to and including [0] are rated "good" + * - Metric values up to and including [1] are rated "needs improvement" + * - Metric values above [1] are "poor" + * + * | Metric value | Rating | + * | --------------- | ------------------- | + * | ≦ [0] | "good" | + * | > [0] and ≦ [1] | "needs improvement" | + * | > [1] | "poor" | + */ +export type MetricRatingThresholds = [number, number]; + export interface ReportCallback { - (metric: Metric): void; + (metric: MetricType): void; } export interface ReportOpts { @@ -104,5 +132,3 @@ export interface ReportOpts { * loading. This is equivalent to the corresponding `readyState` value. */ export type LoadState = 'loading' | 'dom-interactive' | 'dom-content-loaded' | 'complete'; - -export type StopListening = undefined | void | (() => void); diff --git a/packages/tracing-internal/src/browser/web-vitals/types/cls.ts b/packages/tracing-internal/src/browser/web-vitals/types/cls.ts index 0c97a5dde9aa..a95a8fb30770 100644 --- a/packages/tracing-internal/src/browser/web-vitals/types/cls.ts +++ b/packages/tracing-internal/src/browser/web-vitals/types/cls.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { LoadState, Metric, ReportCallback } from './base'; +import type { LoadState, Metric } from './base'; /** * A CLS-specific version of the Metric object. @@ -76,13 +76,13 @@ export interface CLSMetricWithAttribution extends CLSMetric { /** * A CLS-specific version of the ReportCallback function. */ -export interface CLSReportCallback extends ReportCallback { +export interface CLSReportCallback { (metric: CLSMetric): void; } /** * A CLS-specific version of the ReportCallback function with attribution. */ -export interface CLSReportCallbackWithAttribution extends CLSReportCallback { +export interface CLSReportCallbackWithAttribution { (metric: CLSMetricWithAttribution): void; } diff --git a/packages/tracing-internal/src/browser/web-vitals/types/fcp.ts b/packages/tracing-internal/src/browser/web-vitals/types/fcp.ts new file mode 100644 index 000000000000..1a4c7d4962a3 --- /dev/null +++ b/packages/tracing-internal/src/browser/web-vitals/types/fcp.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { LoadState, Metric } from './base'; +import type { NavigationTimingPolyfillEntry } from './polyfills'; + +/** + * An FCP-specific version of the Metric object. + */ +export interface FCPMetric extends Metric { + name: 'FCP'; + entries: PerformancePaintTiming[]; +} + +/** + * An object containing potentially-helpful debugging information that + * can be sent along with the FCP value for the current page visit in order + * to help identify issues happening to real-users in the field. + */ +export interface FCPAttribution { + /** + * The time from when the user initiates loading the page until when the + * browser receives the first byte of the response (a.k.a. TTFB). + */ + timeToFirstByte: number; + /** + * The delta between TTFB and the first contentful paint (FCP). + */ + firstByteToFCP: number; + /** + * The loading state of the document at the time when FCP `occurred (see + * `LoadState` for details). Ideally, documents can paint before they finish + * loading (e.g. the `loading` or `dom-interactive` phases). + */ + loadState: LoadState; + /** + * The `PerformancePaintTiming` entry corresponding to FCP. + */ + fcpEntry?: PerformancePaintTiming; + /** + * 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?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry; +} + +/** + * An FCP-specific version of the Metric object with attribution. + */ +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/tracing-internal/src/browser/web-vitals/types/fid.ts b/packages/tracing-internal/src/browser/web-vitals/types/fid.ts index 926f0675b90a..2001269c9b46 100644 --- a/packages/tracing-internal/src/browser/web-vitals/types/fid.ts +++ b/packages/tracing-internal/src/browser/web-vitals/types/fid.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { LoadState, Metric, ReportCallback } from './base'; +import type { LoadState, Metric } from './base'; import type { FirstInputPolyfillEntry } from './polyfills'; /** @@ -69,13 +69,13 @@ export interface FIDMetricWithAttribution extends FIDMetric { /** * An FID-specific version of the ReportCallback function. */ -export interface FIDReportCallback extends ReportCallback { +export interface FIDReportCallback { (metric: FIDMetric): void; } /** * An FID-specific version of the ReportCallback function with attribution. */ -export interface FIDReportCallbackWithAttribution extends FIDReportCallback { +export interface FIDReportCallbackWithAttribution { (metric: FIDMetricWithAttribution): void; } diff --git a/packages/tracing-internal/src/browser/web-vitals/types/inp.ts b/packages/tracing-internal/src/browser/web-vitals/types/inp.ts index 37e8333fb2d8..e83e0a0ece2a 100644 --- a/packages/tracing-internal/src/browser/web-vitals/types/inp.ts +++ b/packages/tracing-internal/src/browser/web-vitals/types/inp.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { LoadState, Metric, ReportCallback } from './base'; +import type { LoadState, Metric } from './base'; /** * An INP-specific version of the Metric object. @@ -50,7 +50,7 @@ export interface INPAttribution { */ eventEntry?: PerformanceEventTiming; /** - * The loading state of the document at the time when the even corresponding + * 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. @@ -68,13 +68,13 @@ export interface INPMetricWithAttribution extends INPMetric { /** * An INP-specific version of the ReportCallback function. */ -export interface INPReportCallback extends ReportCallback { +export interface INPReportCallback { (metric: INPMetric): void; } /** * An INP-specific version of the ReportCallback function with attribution. */ -export interface INPReportCallbackWithAttribution extends INPReportCallback { +export interface INPReportCallbackWithAttribution { (metric: INPMetricWithAttribution): void; } diff --git a/packages/tracing-internal/src/browser/web-vitals/types/lcp.ts b/packages/tracing-internal/src/browser/web-vitals/types/lcp.ts index c94573c1caaf..aaed1213508e 100644 --- a/packages/tracing-internal/src/browser/web-vitals/types/lcp.ts +++ b/packages/tracing-internal/src/browser/web-vitals/types/lcp.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Metric, ReportCallback } from './base'; +import type { Metric } from './base'; import type { NavigationTimingPolyfillEntry } from './polyfills'; /** @@ -43,30 +43,31 @@ export interface LCPAttribution { /** * The time from when the user initiates loading the page until when the * browser receives the first byte of the response (a.k.a. TTFB). See - * [Optimize LCP](https://web.dev/optimize-lcp/) for details. + * [Optimize LCP](https://web.dev/articles/optimize-lcp) for details. */ timeToFirstByte: number; /** * The delta between TTFB and when the browser starts loading the LCP * resource (if there is one, otherwise 0). See [Optimize - * LCP](https://web.dev/optimize-lcp/) for details. + * LCP](https://web.dev/articles/optimize-lcp) for details. */ resourceLoadDelay: number; /** * The total time it takes to load the LCP resource itself (if there is one, - * otherwise 0). See [Optimize LCP](https://web.dev/optimize-lcp/) for + * otherwise 0). See [Optimize LCP](https://web.dev/articles/optimize-lcp) for * details. */ resourceLoadTime: number; /** * The delta between when the LCP resource finishes loading until the LCP * element is fully rendered. See [Optimize - * LCP](https://web.dev/optimize-lcp/) for details. + * LCP](https://web.dev/articles/optimize-lcp) for details. */ elementRenderDelay: number; /** * The `navigation` entry of the current page, which is useful for diagnosing - * general page load issues. + * general page load issues. This can be used to access `serverTiming` for example: + * navigationEntry?.serverTiming */ navigationEntry?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry; /** @@ -90,13 +91,13 @@ export interface LCPMetricWithAttribution extends LCPMetric { /** * An LCP-specific version of the ReportCallback function. */ -export interface LCPReportCallback extends ReportCallback { +export interface LCPReportCallback { (metric: LCPMetric): void; } /** * An LCP-specific version of the ReportCallback function with attribution. */ -export interface LCPReportCallbackWithAttribution extends LCPReportCallback { +export interface LCPReportCallbackWithAttribution { (metric: LCPMetricWithAttribution): void; } diff --git a/packages/tracing-internal/src/browser/web-vitals/types/ttfb.ts b/packages/tracing-internal/src/browser/web-vitals/types/ttfb.ts index 86f1329ebee8..6a91394ad059 100644 --- a/packages/tracing-internal/src/browser/web-vitals/types/ttfb.ts +++ b/packages/tracing-internal/src/browser/web-vitals/types/ttfb.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Metric, ReportCallback } from './base'; +import type { Metric } from './base'; import type { NavigationTimingPolyfillEntry } from './polyfills'; /** @@ -52,8 +52,9 @@ export interface TTFBAttribution { */ requestTime: number; /** - * The `PerformanceNavigationTiming` entry used to determine TTFB (or the - * polyfill entry in browsers that don't support Navigation Timing). + * 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?: PerformanceNavigationTiming | NavigationTimingPolyfillEntry; } @@ -68,13 +69,13 @@ export interface TTFBMetricWithAttribution extends TTFBMetric { /** * A TTFB-specific version of the ReportCallback function. */ -export interface TTFBReportCallback extends ReportCallback { +export interface TTFBReportCallback { (metric: TTFBMetric): void; } /** * A TTFB-specific version of the ReportCallback function with attribution. */ -export interface TTFBReportCallbackWithAttribution extends TTFBReportCallback { +export interface TTFBReportCallbackWithAttribution { (metric: TTFBMetricWithAttribution): void; } From 4f0144673d59f0a306fba57b29b5ce0385b2a2bd Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 3 Apr 2024 16:24:35 +0200 Subject: [PATCH 2/3] remove polyfill code --- .../src/browser/web-vitals/getFID.ts | 19 +----------- .../web-vitals/lib/getNavigationEntry.ts | 31 +------------------ .../web-vitals/lib/getVisibilityWatcher.ts | 11 ++----- .../src/browser/web-vitals/types.ts | 3 -- 4 files changed, 4 insertions(+), 60 deletions(-) diff --git a/packages/tracing-internal/src/browser/web-vitals/getFID.ts b/packages/tracing-internal/src/browser/web-vitals/getFID.ts index feac578097de..f79b388c042d 100644 --- a/packages/tracing-internal/src/browser/web-vitals/getFID.ts +++ b/packages/tracing-internal/src/browser/web-vitals/getFID.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { WINDOW } from '../types'; import { bindReporter } from './lib/bindReporter'; import { getVisibilityWatcher } from './lib/getVisibilityWatcher'; import { initMetric } from './lib/initMetric'; @@ -22,13 +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, - FirstInputPolyfillCallback, - MetricRatingThresholds, - ReportOpts, -} from './types'; +import type { FIDMetric, FIDReportCallback, 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]; @@ -73,15 +66,5 @@ export const onFID = (onReport: FIDReportCallback, opts: ReportOpts = {}): void }), ); } - - if (WINDOW.__WEB_VITALS_POLYFILL__) { - // eslint-disable-next-line no-console - console.warn('The web-vitals "base+polyfill" build is deprecated. See: https://bit.ly/3aqzsGm'); - - // Prefer the native implementation if available, - if (!po) { - WINDOW.webVitals.firstInputPolyfill(handleEntry as FirstInputPolyfillCallback); - } - } }); }; diff --git a/packages/tracing-internal/src/browser/web-vitals/lib/getNavigationEntry.ts b/packages/tracing-internal/src/browser/web-vitals/lib/getNavigationEntry.ts index a4b1348d798f..2fa455e3fbba 100644 --- a/packages/tracing-internal/src/browser/web-vitals/lib/getNavigationEntry.ts +++ b/packages/tracing-internal/src/browser/web-vitals/lib/getNavigationEntry.ts @@ -17,35 +17,6 @@ import { WINDOW } from '../../types'; import type { NavigationTimingPolyfillEntry } from '../types'; -const getNavigationEntryFromPerformanceTiming = (): NavigationTimingPolyfillEntry => { - // eslint-disable-next-line deprecation/deprecation - const timing = performance.timing; - // eslint-disable-next-line deprecation/deprecation - const type = performance.navigation.type; - - const navigationEntry: { [key: string]: number | string } = { - entryType: 'navigation', - startTime: 0, - type: type == 2 ? 'back_forward' : type === 1 ? 'reload' : 'navigate', - }; - - for (const key in timing) { - if (key !== 'navigationStart' && key !== 'toJSON') { - // eslint-disable-next-line deprecation/deprecation - navigationEntry[key] = Math.max((timing[key as keyof PerformanceTiming] as number) - timing.navigationStart, 0); - } - } - return navigationEntry as unknown as NavigationTimingPolyfillEntry; -}; - export const getNavigationEntry = (): PerformanceNavigationTiming | NavigationTimingPolyfillEntry | undefined => { - if (WINDOW.__WEB_VITALS_POLYFILL__) { - return ( - WINDOW.performance && - ((performance.getEntriesByType && performance.getEntriesByType('navigation')[0]) || - getNavigationEntryFromPerformanceTiming()) - ); - } else { - return WINDOW.performance && performance.getEntriesByType && performance.getEntriesByType('navigation')[0]; - } + return WINDOW.performance && performance.getEntriesByType && performance.getEntriesByType('navigation')[0]; }; diff --git a/packages/tracing-internal/src/browser/web-vitals/lib/getVisibilityWatcher.ts b/packages/tracing-internal/src/browser/web-vitals/lib/getVisibilityWatcher.ts index 431bb7d4e143..2cff287b2ae7 100644 --- a/packages/tracing-internal/src/browser/web-vitals/lib/getVisibilityWatcher.ts +++ b/packages/tracing-internal/src/browser/web-vitals/lib/getVisibilityWatcher.ts @@ -65,15 +65,8 @@ export const getVisibilityWatcher = () => { // 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. - if (WINDOW.__WEB_VITALS_POLYFILL__) { - firstHiddenTime = WINDOW.webVitals.firstHiddenTime; - if (firstHiddenTime === Infinity) { - addChangeListeners(); - } - } else { - firstHiddenTime = initHiddenTime(); - addChangeListeners(); - } + firstHiddenTime = initHiddenTime(); + addChangeListeners(); } return { get firstHiddenTime() { diff --git a/packages/tracing-internal/src/browser/web-vitals/types.ts b/packages/tracing-internal/src/browser/web-vitals/types.ts index 05549fa0e0af..41793221311b 100644 --- a/packages/tracing-internal/src/browser/web-vitals/types.ts +++ b/packages/tracing-internal/src/browser/web-vitals/types.ts @@ -39,9 +39,6 @@ export interface WebVitalsGlobal { declare global { interface Window { webVitals: WebVitalsGlobal; - - // Build flags: - __WEB_VITALS_POLYFILL__: boolean; } } From d347293b0cf80612c7a81000e9c283a860513db5 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 4 Apr 2024 10:19:50 +0200 Subject: [PATCH 3/3] fix flaky test --- .../suites/tracing/metrics/web-vitals-ttfb/test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb/test.ts index 0a4b1e6d3da6..e135601f7ddf 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb/test.ts @@ -4,15 +4,22 @@ import type { Event } from '@sentry/types'; import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; -sentryTest('should capture TTFB vital.', async ({ getLocalTestPath, page }) => { +sentryTest('should capture TTFB vital.', async ({ getLocalTestUrl, page }) => { if (shouldSkipTracingTest()) { sentryTest.skip(); } - const url = await getLocalTestPath({ testDir: __dirname }); + const url = await getLocalTestUrl({ testDir: __dirname }); const eventData = await getFirstSentryEnvelopeRequest(page, url); expect(eventData.measurements).toBeDefined(); - expect(eventData.measurements?.ttfb?.value).toBeDefined(); + + // If responseStart === 0, ttfb is not reported + // This seems to happen somewhat randomly, so we just ignore this in that case + const responseStart = await page.evaluate("performance.getEntriesByType('navigation')[0].responseStart;"); + if (responseStart !== 0) { + expect(eventData.measurements?.ttfb?.value).toBeDefined(); + } + expect(eventData.measurements?.['ttfb.requestTime']?.value).toBeDefined(); });