Skip to content

feat(browser): Update web-vitals to 5.0.2 #16492

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

Open
wants to merge 7 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ module.exports = [
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
ignore: ['react/jsx-runtime'],
gzip: true,
limit: '40.5 KB',
limit: '41 KB',
},
// Vue SDK (ESM)
{
Expand Down Expand Up @@ -215,7 +215,7 @@ module.exports = [
import: createImport('init'),
ignore: ['$app/stores'],
gzip: true,
limit: '39 KB',
limit: '40 KB',
},
// Node SDK (ESM)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { sentryTest } from '../../../../utils/fixtures';
import {
getFirstSentryEnvelopeRequest,
getMultipleSentryEnvelopeRequests,
hidePage,
properFullEnvelopeRequestParser,
shouldSkipTracingTest,
} from '../../../../utils/helpers';
Expand Down Expand Up @@ -33,9 +34,7 @@ sentryTest('should capture an INP click event span after pageload', async ({ bro
await page.waitForTimeout(500);

// Page hide to trigger INP
await page.evaluate(() => {
window.dispatchEvent(new Event('pagehide'));
});
await hidePage(page);

// Get the INP span envelope
const spanEnvelope = (await spanEnvelopePromise)[0];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { sentryTest } from '../../../../utils/fixtures';
import {
getFirstSentryEnvelopeRequest,
getMultipleSentryEnvelopeRequests,
hidePage,
properFullEnvelopeRequestParser,
shouldSkipTracingTest,
} from '../../../../utils/helpers';
Expand Down Expand Up @@ -35,9 +36,7 @@ sentryTest(
await page.waitForTimeout(500);

// Page hide to trigger INP
await page.evaluate(() => {
window.dispatchEvent(new Event('pagehide'));
});
await hidePage(page);

// Get the INP span envelope
const spanEnvelope = (await spanEnvelopePromise)[0];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { SpanEnvelope } from '@sentry/core';
import { sentryTest } from '../../../../utils/fixtures';
import {
getMultipleSentryEnvelopeRequests,
hidePage,
properFullEnvelopeRequestParser,
shouldSkipTracingTest,
} from '../../../../utils/helpers';
Expand Down Expand Up @@ -33,9 +34,7 @@ sentryTest(
await page.waitForTimeout(500);

// Page hide to trigger INP
await page.evaluate(() => {
window.dispatchEvent(new Event('pagehide'));
});
await hidePage(page);

// Get the INP span envelope
const spanEnvelope = (await spanEnvelopePromise)[0];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Sentry.init({
}),
],
tracesSampleRate: 1,
debug: true,
});

const client = Sentry.getClient();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { sentryTest } from '../../../../utils/fixtures';
import {
getFirstSentryEnvelopeRequest,
getMultipleSentryEnvelopeRequests,
hidePage,
properFullEnvelopeRequestParser,
shouldSkipTracingTest,
} from '../../../../utils/helpers';
Expand Down Expand Up @@ -32,9 +33,7 @@ sentryTest('should capture an INP click event span during pageload', async ({ br
await page.waitForTimeout(500);

// Page hide to trigger INP
await page.evaluate(() => {
window.dispatchEvent(new Event('pagehide'));
});
await hidePage(page);

// Get the INP span envelope
const spanEnvelope = (await spanEnvelopePromise)[0];
Expand Down Expand Up @@ -118,6 +117,14 @@ sentryTest(
});

// Page hide to trigger INP

// Important: Purposefully not using hidePage() here to test the hidden state
// via the `pagehide` event. This is necessary because iOS Safari 14.4
// still doesn't fully emit the `visibilitychange` events but it's the lower
// bound for Safari on iOS that we support.
// If this test times out or fails, it's likely because we tried updating
// the web-vitals library which officially already dropped support for
// this iOS version
await page.evaluate(() => {
window.dispatchEvent(new Event('pagehide'));
});
Expand Down
1 change: 1 addition & 0 deletions packages/browser-utils/src/metrics/cls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export function trackClsAsStandaloneSpan(): void {
standaloneClsEntry = entry;
}, true);

// TODO: Figure out if we can switch to using whenIdleOrHidden instead of onHidden
// use pagehide event from web-vitals
onHidden(() => {
_collectClsOnce();
Expand Down
4 changes: 4 additions & 0 deletions packages/browser-utils/src/metrics/web-vitals/README.md
Copy link
Member

Choose a reason for hiding this comment

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

We need to bump the commit SHA linked in this README as well.

Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ web-vitals only report once per pageload.

## CHANGELOG

TODO PR URL

- Bumped from Web Vitals 4.2.5 to 5.0.2

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

- Bumped from Web Vitals v3.5.2 to v4.2.4
Expand Down
53 changes: 17 additions & 36 deletions packages/browser-utils/src/metrics/web-vitals/getCLS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
* limitations under the License.
*/

import { WINDOW } from '../../types';
import { bindReporter } from './lib/bindReporter';
import { initMetric } from './lib/initMetric';
import { initUnique } from './lib/initUnique';
import { LayoutShiftManager } from './lib/LayoutShiftManager';
import { observe } from './lib/observe';
import { onHidden } from './lib/onHidden';
import { runOnce } from './lib/runOnce';
import { onFCP } from './onFCP';
import type { CLSMetric, MetricRatingThresholds, ReportOpts } from './types';
Expand Down Expand Up @@ -54,58 +56,37 @@ export const onCLS = (onReport: (metric: CLSMetric) => void, opts: ReportOpts =
const metric = initMetric('CLS', 0);
let report: ReturnType<typeof bindReporter>;

let sessionValue = 0;
let sessionEntries: LayoutShift[] = [];
const layoutShiftManager = initUnique(opts, LayoutShiftManager);

const handleEntries = (entries: LayoutShift[]) => {
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 &&
firstSessionEntry &&
lastSessionEntry &&
entry.startTime - lastSessionEntry.startTime < 1000 &&
entry.startTime - firstSessionEntry.startTime < 5000
) {
sessionValue += entry.value;
sessionEntries.push(entry);
} else {
sessionValue = entry.value;
sessionEntries = [entry];
}
}
});
for (const entry of entries) {
layoutShiftManager._processEntry(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 (layoutShiftManager._sessionValue > metric.value) {
metric.value = layoutShiftManager._sessionValue;
metric.entries = layoutShiftManager._sessionEntries;
report();
}
};

const po = observe('layout-shift', handleEntries);
if (po) {
report = bindReporter(onReport, metric, CLSThresholds, opts.reportAllChanges);
report = bindReporter(onReport, metric, CLSThresholds, opts!.reportAllChanges);

onHidden(() => {
handleEntries(po.takeRecords() as CLSMetric['entries']);
report(true);
WINDOW.document?.addEventListener('visibilitychange', () => {
if (WINDOW.document?.visibilityState === 'hidden') {
handleEntries(po.takeRecords() as CLSMetric['entries']);
report(true);
}
});

// 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);
WINDOW?.setTimeout?.(report);
}
}),
);
Expand Down
5 changes: 5 additions & 0 deletions packages/browser-utils/src/metrics/web-vitals/getFID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
* 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.
*
* // Sentry: web-vitals removed FID reporting from v5. We're keeping it around
* for the time being.
* // TODO(v10): Remove FID reporting!
*/

import { bindReporter } from './lib/bindReporter';
Expand Down Expand Up @@ -60,6 +64,7 @@ export const onFID = (onReport: (metric: FIDMetric) => void, opts: ReportOpts =
report = bindReporter(onReport, metric, FIDThresholds, opts.reportAllChanges);

if (po) {
// sentry: TODO: Figure out if we can use new whinIdleOrHidden insteard of onHidden
onHidden(
runOnce(() => {
handleEntries(po.takeRecords() as FIDMetric['entries']);
Expand Down
47 changes: 30 additions & 17 deletions packages/browser-utils/src/metrics/web-vitals/getINP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,31 +14,37 @@
* limitations under the License.
*/

import { WINDOW } from '../../types';
import { bindReporter } from './lib/bindReporter';
import { initMetric } from './lib/initMetric';
import { DEFAULT_DURATION_THRESHOLD, estimateP98LongestInteraction, processInteractionEntry } from './lib/interactions';
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 { whenActivated } from './lib/whenActivated';
import { whenIdle } from './lib/whenIdle';
import type { INPMetric, MetricRatingThresholds, ReportOpts } from './types';
import { whenIdleOrHidden } from './lib/whenIdleOrHidden';
import type { INPMetric, INPReportOpts, MetricRatingThresholds } from './types';

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

// The default `durationThreshold` used across this library for observing
// `event` entries via PerformanceObserver.
const DEFAULT_DURATION_THRESHOLD = 40;

/**
* 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`.
*
* A custom `durationThreshold` configuration option can optionally be passed to
* control what `event-timing` entries are considered for INP reporting. The
* default threshold is `40`, which means INP scores of less than 40 are
* reported as 0. Note that this will not affect your 75th percentile INP value
* unless that value is also less than 40 (well below the recommended
* A custom `durationThreshold` configuration option can optionally be passed
* to control what `event-timing` entries are considered for INP reporting. The
* default threshold is `40`, which means INP scores of less than 40 will not
* be reported. To avoid reporting no interactions in these cases, the library
* will fall back to the input delay of the first interaction. 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/articles/inp#what_is_a_good_inp_score) threshold).
*
* If the `reportAllChanges` configuration option is set to `true`, the
Expand All @@ -55,9 +61,9 @@ export const INPThresholds: MetricRatingThresholds = [200, 500];
* hidden. As a result, the `callback` function might be called multiple times
* during the same page load._
*/
export const onINP = (onReport: (metric: INPMetric) => void, opts: ReportOpts = {}) => {
export const onINP = (onReport: (metric: INPMetric) => void, opts: INPReportOpts = {}) => {
// Return if the browser doesn't support all APIs needed to measure INP.
if (!('PerformanceEventTiming' in WINDOW && 'interactionId' in PerformanceEventTiming.prototype)) {
if (!(globalThis.PerformanceEventTiming && 'interactionId' in PerformanceEventTiming.prototype)) {
return;
}

Expand All @@ -69,20 +75,24 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: ReportOpts =
// eslint-disable-next-line prefer-const
let report: ReturnType<typeof bindReporter>;

const interactionManager = initUnique(opts, InteractionManager);

const handleEntries = (entries: INPMetric['entries']) => {
// 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);
whenIdleOrHidden(() => {
for (const entry of entries) {
interactionManager._processEntry(entry);
}

const inp = estimateP98LongestInteraction();
const inp = interactionManager._estimateP98LongestInteraction();

if (inp && inp.latency !== metric.value) {
metric.value = inp.latency;
if (inp && inp._latency !== metric.value) {
metric.value = inp._latency;
metric.entries = inp.entries;
report();
}
Expand All @@ -96,7 +106,7 @@ export const onINP = (onReport: (metric: INPMetric) => void, 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 : DEFAULT_DURATION_THRESHOLD,
durationThreshold: opts.durationThreshold ?? DEFAULT_DURATION_THRESHOLD,
});

report = bindReporter(onReport, metric, INPThresholds, opts.reportAllChanges);
Expand All @@ -106,6 +116,9 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: ReportOpts =
// where the first interaction is less than the `durationThreshold`.
po.observe({ type: 'first-input', buffered: 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.
onHidden(() => {
handleEntries(po.takeRecords() as INPMetric['entries']);
report(true);
Expand Down
Loading