Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(browser-utils): Update web-vitals to v4.2.4 #14439

Merged
merged 11 commits into from
Dec 2, 2024
4 changes: 2 additions & 2 deletions packages/browser-utils/src/metrics/browserMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ export function _addMeasureSpans(
duration: number,
timeOrigin: number,
): number {
const navEntry = getNavigationEntry();
const navEntry = getNavigationEntry(false);
const requestTime = msToSec(navEntry ? navEntry.requestStart : 0);
// Because performance.measure accepts arbitrary timestamps it can produce
// spans that happen before the browser even makes a request for the page.
Expand Down Expand Up @@ -666,7 +666,7 @@ function setResourceEntrySizeData(
* ttfb information is added via vendored web vitals library.
*/
function _addTtfbRequestTimeToMeasurements(_measurements: Measurements): void {
const navEntry = getNavigationEntry();
const navEntry = getNavigationEntry(false);
if (!navEntry) {
return;
}
Expand Down
10 changes: 9 additions & 1 deletion packages/browser-utils/src/metrics/web-vitals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
This was vendored from: https://github.com/GoogleChrome/web-vitals: v3.5.2

The commit SHA used is:
[7b44bea0d5ba6629c5fd34c3a09cc683077871d0](https://github.com/GoogleChrome/web-vitals/tree/7b44bea0d5ba6629c5fd34c3a09cc683077871d0)
[3d2b3dc8576cc003618952fa39902fab764a53e2](https://github.com/GoogleChrome/web-vitals/tree/3d2b3dc8576cc003618952fa39902fab764a53e2)

Current vendored web vitals are:

Expand All @@ -27,6 +27,14 @@ web-vitals only report once per pageload.

## CHANGELOG

https://github.com/getsentry/sentry-javascript/pull/14439

- Bumped from Web Vitals v3.5.2 to v4.2.4

https://github.com/getsentry/sentry-javascript/pull/11391

- Bumped from Web Vitals v3.0.4 to v3.5.2

https://github.com/getsentry/sentry-javascript/pull/5987

- Bumped from Web Vitals v2.1.0 to v3.0.4
Expand Down
6 changes: 3 additions & 3 deletions packages/browser-utils/src/metrics/web-vitals/getCLS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { observe } from './lib/observe';
import { onHidden } from './lib/onHidden';
import { runOnce } from './lib/runOnce';
import { onFCP } from './onFCP';
import type { CLSMetric, CLSReportCallback, MetricRatingThresholds, ReportOpts } from './types';
import type { CLSMetric, MetricRatingThresholds, ReportOpts } from './types';

/** Thresholds for CLS. See https://web.dev/articles/cls#what_is_a_good_cls_score */
export const CLSThresholds: MetricRatingThresholds = [0.1, 0.25];
Expand All @@ -46,7 +46,7 @@ export const CLSThresholds: MetricRatingThresholds = [0.1, 0.25];
* hidden. As a result, the `callback` function might be called multiple times
* during the same page load._
*/
export const onCLS = (onReport: CLSReportCallback, opts: ReportOpts = {}): void => {
export const onCLS = (onReport: (metric: CLSMetric) => void, opts: ReportOpts = {}) => {
// Start monitoring FCP so we can only report CLS if FCP is also reported.
// Note: this is done to match the current behavior of CrUX.
onFCP(
Expand All @@ -57,7 +57,7 @@ export const onCLS = (onReport: CLSReportCallback, opts: ReportOpts = {}): void
let sessionValue = 0;
let sessionEntries: LayoutShift[] = [];

const handleEntries = (entries: LayoutShift[]): void => {
const handleEntries = (entries: LayoutShift[]) => {
entries.forEach(entry => {
// Only count layout shifts without recent user input.
if (!entry.hadRecentInput) {
Expand Down
5 changes: 3 additions & 2 deletions packages/browser-utils/src/metrics/web-vitals/getFID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { observe } from './lib/observe';
import { onHidden } from './lib/onHidden';
import { runOnce } from './lib/runOnce';
import { whenActivated } from './lib/whenActivated';
import type { FIDMetric, FIDReportCallback, MetricRatingThresholds, ReportOpts } from './types';
import type { FIDMetric, MetricRatingThresholds, ReportOpts } from './types';

/** Thresholds for FID. See https://web.dev/articles/fid#what_is_a_good_fid_score */
export const FIDThresholds: MetricRatingThresholds = [100, 300];
Expand All @@ -35,7 +35,7 @@ export const FIDThresholds: MetricRatingThresholds = [100, 300];
* _**Important:** since FID is only reported after the user interacts with the
* page, it's possible that it will not be reported for some page loads._
*/
export const onFID = (onReport: FIDReportCallback, opts: ReportOpts = {}) => {
export const onFID = (onReport: (metric: FIDMetric) => void, opts: ReportOpts = {}) => {
whenActivated(() => {
const visibilityWatcher = getVisibilityWatcher();
const metric = initMetric('FID');
Expand All @@ -56,6 +56,7 @@ export const onFID = (onReport: FIDReportCallback, opts: ReportOpts = {}) => {
};

const po = observe('first-input', handleEntries);

report = bindReporter(onReport, metric, FIDThresholds, opts.reportAllChanges);

if (po) {
Expand Down
160 changes: 29 additions & 131 deletions packages/browser-utils/src/metrics/web-vitals/getINP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,100 +17,18 @@
import { WINDOW } from '../../types';
import { bindReporter } from './lib/bindReporter';
import { initMetric } from './lib/initMetric';
import { DEFAULT_DURATION_THRESHOLD, estimateP98LongestInteraction, processInteractionEntry } from './lib/interactions';
import { observe } from './lib/observe';
import { onHidden } from './lib/onHidden';
import { getInteractionCount, initInteractionCountPolyfill } from './lib/polyfills/interactionCountPolyfill';
import { initInteractionCountPolyfill } from './lib/polyfills/interactionCountPolyfill';
import { whenActivated } from './lib/whenActivated';
import type { INPMetric, INPReportCallback, MetricRatingThresholds, ReportOpts } from './types';
import { whenIdle } from './lib/whenIdle';

interface Interaction {
id: number;
latency: number;
entries: PerformanceEventTiming[];
}
import type { INPMetric, MetricRatingThresholds, ReportOpts } from './types';

/** Thresholds for INP. See https://web.dev/articles/inp#what_is_a_good_inp_score */
export const INPThresholds: MetricRatingThresholds = [200, 500];

// Used to store the interaction count after a bfcache restore, since p98
// interaction latencies should only consider the current navigation.
const prevInteractionCount = 0;
Comment on lines -35 to -37
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was extracted to the /lib/interaction.ts file


/**
* Returns the interaction count since the last bfcache restore (or for the
* full page lifecycle if there were no bfcache restores).
*/
const getInteractionCountForNavigation = () => {
return getInteractionCount() - prevInteractionCount;
};

// To prevent unnecessary memory usage on pages with lots of interactions,
// store at most 10 of the longest interactions to consider as INP candidates.
const MAX_INTERACTIONS_TO_CONSIDER = 10;

// A list of longest interactions on the page (by latency) sorted so the
// longest one is first. The list is as most MAX_INTERACTIONS_TO_CONSIDER long.
const longestInteractionList: Interaction[] = [];

// A mapping of longest interactions by their interaction ID.
// This is used for faster lookup.
const longestInteractionMap: { [interactionId: string]: Interaction } = {};

/**
* Takes a performance entry and adds it to the list of worst interactions
* if its duration is long enough to make it among the worst. If the
* entry is part of an existing interaction, it is merged and the latency
* and entries list is updated as needed.
*/
const processEntry = (entry: PerformanceEventTiming) => {
// The least-long of the 10 longest interactions.
const minLongestInteraction = longestInteractionList[longestInteractionList.length - 1];

const existingInteraction = longestInteractionMap[entry.interactionId!];

// Only process the entry if it's possibly one of the ten longest,
// or if it's part of an existing interaction.
if (
existingInteraction ||
longestInteractionList.length < MAX_INTERACTIONS_TO_CONSIDER ||
(minLongestInteraction && entry.duration > minLongestInteraction.latency)
) {
// If the interaction already exists, update it. Otherwise create one.
if (existingInteraction) {
existingInteraction.entries.push(entry);
existingInteraction.latency = Math.max(existingInteraction.latency, entry.duration);
} else {
const interaction = {
id: entry.interactionId!,
latency: entry.duration,
entries: [entry],
};
longestInteractionMap[interaction.id] = interaction;
longestInteractionList.push(interaction);
}

// Sort the entries by latency (descending) and keep only the top ten.
longestInteractionList.sort((a, b) => b.latency - a.latency);
longestInteractionList.splice(MAX_INTERACTIONS_TO_CONSIDER).forEach(i => {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete longestInteractionMap[i.id];
});
}
};

/**
* Returns the estimated p98 longest interaction based on the stored
* interaction candidates and the interaction count for the current page.
*/
const estimateP98LongestInteraction = () => {
const candidateInteractionIndex = Math.min(
longestInteractionList.length - 1,
Math.floor(getInteractionCountForNavigation() / 50),
);

return longestInteractionList[candidateInteractionIndex];
};

/**
* Calculates the [INP](https://web.dev/articles/inp) value for the current
* page and calls the `callback` function once the value is ready, along with
Expand Down Expand Up @@ -138,7 +56,12 @@ const estimateP98LongestInteraction = () => {
* hidden. As a result, the `callback` function might be called multiple times
* during the same page load._
*/
export const onINP = (onReport: INPReportCallback, opts: ReportOpts = {}) => {
export const onINP = (onReport: (metric: INPMetric) => void, opts: ReportOpts = {}) => {
// Return if the browser doesn't support all APIs needed to measure INP.
if (!('PerformanceEventTiming' in WINDOW && 'interactionId' in PerformanceEventTiming.prototype)) {
return;
}

whenActivated(() => {
// TODO(philipwalton): remove once the polyfill is no longer needed.
initInteractionCountPolyfill();
Expand All @@ -148,37 +71,23 @@ export const onINP = (onReport: INPReportCallback, opts: ReportOpts = {}) => {
let report: ReturnType<typeof bindReporter>;

const handleEntries = (entries: INPMetric['entries']) => {
entries.forEach(entry => {
if (entry.interactionId) {
processEntry(entry);
}

// Entries of type `first-input` don't currently have an `interactionId`,
// so to consider them in INP we have to first check that an existing
// entry doesn't match the `duration` and `startTime`.
// Note that this logic assumes that `event` entries are dispatched
// before `first-input` entries. This is true in Chrome (the only browser
// that currently supports INP).
// TODO(philipwalton): remove once crbug.com/1325826 is fixed.
if (entry.entryType === 'first-input') {
const noMatchingEntry = !longestInteractionList.some(interaction => {
return interaction.entries.some(prevEntry => {
return entry.duration === prevEntry.duration && entry.startTime === prevEntry.startTime;
});
});
if (noMatchingEntry) {
processEntry(entry);
}
// Queue the `handleEntries()` callback in the next idle task.
// This is needed to increase the chances that all event entries that
// occurred between the user interaction and the next paint
// have been dispatched. Note: there is currently an experiment
// running in Chrome (EventTimingKeypressAndCompositionInteractionId)
// 123+ that if rolled out fully may make this no longer necessary.
whenIdle(() => {
entries.forEach(processInteractionEntry);

const inp = estimateP98LongestInteraction();

if (inp && inp.latency !== metric.value) {
metric.value = inp.latency;
metric.entries = inp.entries;
report();
}
});

const inp = estimateP98LongestInteraction();

if (inp && inp.latency !== metric.value) {
metric.value = inp.latency;
metric.entries = inp.entries;
report();
}
};

const po = observe('event', handleEntries, {
Expand All @@ -188,29 +97,18 @@ export const onINP = (onReport: INPReportCallback, opts: ReportOpts = {}) => {
// and performance. Running this callback for any interaction that spans
// just one or two frames is likely not worth the insight that could be
// gained.
durationThreshold: opts.durationThreshold != null ? opts.durationThreshold : 40,
} as PerformanceObserverInit);
durationThreshold: opts.durationThreshold != null ? opts.durationThreshold : DEFAULT_DURATION_THRESHOLD,
});

report = bindReporter(onReport, metric, INPThresholds, opts.reportAllChanges);

if (po) {
// If browser supports interactionId (and so supports INP), also
// observe entries of type `first-input`. This is useful in cases
// Also observe entries of type `first-input`. This is useful in cases
// where the first interaction is less than the `durationThreshold`.
if ('PerformanceEventTiming' in WINDOW && 'interactionId' in PerformanceEventTiming.prototype) {
po.observe({ type: 'first-input', buffered: true });
}
po.observe({ type: 'first-input', buffered: true });

onHidden(() => {
handleEntries(po.takeRecords() as INPMetric['entries']);

// If the interaction count shows that there were interactions but
// none were captured by the PerformanceObserver, report a latency of 0.
if (metric.value < 0 && getInteractionCountForNavigation() > 0) {
metric.value = 0;
metric.entries = [];
}

report(true);
});
}
Expand Down
36 changes: 23 additions & 13 deletions packages/browser-utils/src/metrics/web-vitals/getLCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import { observe } from './lib/observe';
import { onHidden } from './lib/onHidden';
import { runOnce } from './lib/runOnce';
import { whenActivated } from './lib/whenActivated';
import type { LCPMetric, LCPReportCallback, MetricRatingThresholds, ReportOpts } from './types';
import { whenIdle } from './lib/whenIdle';
import type { LCPMetric, MetricRatingThresholds, ReportOpts } from './types';

/** Thresholds for LCP. See https://web.dev/articles/lcp#what_is_a_good_lcp_score */
export const LCPThresholds: MetricRatingThresholds = [2500, 4000];
Expand All @@ -41,28 +42,34 @@ const reportedMetricIDs: Record<string, boolean> = {};
* performance entry is dispatched, or once the final value of the metric has
* been determined.
*/
export const onLCP = (onReport: LCPReportCallback, opts: ReportOpts = {}) => {
export const onLCP = (onReport: (metric: LCPMetric) => void, opts: ReportOpts = {}) => {
whenActivated(() => {
const visibilityWatcher = getVisibilityWatcher();
const metric = initMetric('LCP');
let report: ReturnType<typeof bindReporter>;

const handleEntries = (entries: LCPMetric['entries']) => {
const lastEntry = entries[entries.length - 1] as LargestContentfulPaint;
if (lastEntry) {
// If reportAllChanges is set then call this function for each entry,
// otherwise only consider the last one.
if (!opts.reportAllChanges) {
// eslint-disable-next-line no-param-reassign
entries = entries.slice(-1);
}

entries.forEach(entry => {
// Only report if the page wasn't hidden prior to LCP.
if (lastEntry.startTime < visibilityWatcher.firstHiddenTime) {
if (entry.startTime < visibilityWatcher.firstHiddenTime) {
// The startTime attribute returns the value of the renderTime if it is
// not 0, and the value of the loadTime otherwise. The activationStart
// reference is used because LCP should be relative to page activation
// rather than navigation start if the page was prerendered. But in cases
// rather than navigation start if the page was pre-rendered. But in cases
// where `activationStart` occurs after the LCP, this time should be
// clamped at 0.
metric.value = Math.max(lastEntry.startTime - getActivationStart(), 0);
metric.entries = [lastEntry];
metric.value = Math.max(entry.startTime - getActivationStart(), 0);
metric.entries = [entry];
report();
}
}
});
};

const po = observe('largest-contentful-paint', handleEntries);
Expand All @@ -83,11 +90,14 @@ export const onLCP = (onReport: LCPReportCallback, opts: ReportOpts = {}) => {
// stops LCP observation, it's unreliable since it can be programmatically
// generated. See: https://github.com/GoogleChrome/web-vitals/issues/75
['keydown', 'click'].forEach(type => {
// Wrap in a setTimeout so the callback is run in a separate task
// to avoid extending the keyboard/click handler to reduce INP impact
// https://github.com/GoogleChrome/web-vitals/issues/383
if (WINDOW.document) {
// Wrap in a setTimeout so the callback is run in a separate task
// to avoid extending the keyboard/click handler to reduce INP impact
// https://github.com/GoogleChrome/web-vitals/issues/383
addEventListener(type, () => setTimeout(stopListening, 0), true);
addEventListener(type, () => whenIdle(stopListening as () => void), {
once: true,
capture: true,
});
}
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@
* @return {string}
*/
export const generateUniqueID = () => {
return `v3-${Date.now()}-${Math.floor(Math.random() * (9e12 - 1)) + 1e12}`;
return `v4-${Date.now()}-${Math.floor(Math.random() * (9e12 - 1)) + 1e12}`;
};
Loading
Loading