Skip to content

Commit 4a1740d

Browse files
committed
feat(browser): Update web-vitals to 5.0.2
1 parent 6d61be0 commit 4a1740d

File tree

15 files changed

+523
-122
lines changed

15 files changed

+523
-122
lines changed

packages/browser-utils/src/metrics/web-vitals/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ web-vitals only report once per pageload.
2727

2828
## CHANGELOG
2929

30+
TODO PR URL
31+
32+
- Bumped from Web Vitals 4.2.5 to 5.0.2
33+
3034
https://github.com/getsentry/sentry-javascript/pull/14439
3135

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

packages/browser-utils/src/metrics/web-vitals/getCLS.ts

Lines changed: 17 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414
* limitations under the License.
1515
*/
1616

17+
import { WINDOW } from '../../types';
1718
import { bindReporter } from './lib/bindReporter';
1819
import { initMetric } from './lib/initMetric';
20+
import { initUnique } from './lib/initUnique';
21+
import { LayoutShiftManager } from './lib/LayoutShiftManager';
1922
import { observe } from './lib/observe';
20-
import { onHidden } from './lib/onHidden';
2123
import { runOnce } from './lib/runOnce';
2224
import { onFCP } from './onFCP';
2325
import type { CLSMetric, MetricRatingThresholds, ReportOpts } from './types';
@@ -54,58 +56,37 @@ export const onCLS = (onReport: (metric: CLSMetric) => void, opts: ReportOpts =
5456
const metric = initMetric('CLS', 0);
5557
let report: ReturnType<typeof bindReporter>;
5658

57-
let sessionValue = 0;
58-
let sessionEntries: LayoutShift[] = [];
59+
const layoutShiftManager = initUnique(opts, LayoutShiftManager);
5960

6061
const handleEntries = (entries: LayoutShift[]) => {
61-
entries.forEach(entry => {
62-
// Only count layout shifts without recent user input.
63-
if (!entry.hadRecentInput) {
64-
const firstSessionEntry = sessionEntries[0];
65-
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
66-
67-
// If the entry occurred less than 1 second after the previous entry
68-
// and less than 5 seconds after the first entry in the session,
69-
// include the entry in the current session. Otherwise, start a new
70-
// session.
71-
if (
72-
sessionValue &&
73-
firstSessionEntry &&
74-
lastSessionEntry &&
75-
entry.startTime - lastSessionEntry.startTime < 1000 &&
76-
entry.startTime - firstSessionEntry.startTime < 5000
77-
) {
78-
sessionValue += entry.value;
79-
sessionEntries.push(entry);
80-
} else {
81-
sessionValue = entry.value;
82-
sessionEntries = [entry];
83-
}
84-
}
85-
});
62+
for (const entry of entries) {
63+
layoutShiftManager._processEntry(entry);
64+
}
8665

8766
// If the current session value is larger than the current CLS value,
8867
// update CLS and the entries contributing to it.
89-
if (sessionValue > metric.value) {
90-
metric.value = sessionValue;
91-
metric.entries = sessionEntries;
68+
if (layoutShiftManager._sessionValue > metric.value) {
69+
metric.value = layoutShiftManager._sessionValue;
70+
metric.entries = layoutShiftManager._sessionEntries;
9271
report();
9372
}
9473
};
9574

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

100-
onHidden(() => {
101-
handleEntries(po.takeRecords() as CLSMetric['entries']);
102-
report(true);
79+
WINDOW.document?.addEventListener('visibilitychange', () => {
80+
if (WINDOW.document?.visibilityState === 'hidden') {
81+
handleEntries(po.takeRecords() as CLSMetric['entries']);
82+
report(true);
83+
}
10384
});
10485

10586
// Queue a task to report (if nothing else triggers a report first).
10687
// This allows CLS to be reported as soon as FCP fires when
10788
// `reportAllChanges` is true.
108-
setTimeout(report, 0);
89+
WINDOW?.setTimeout?.(report);
10990
}
11091
}),
11192
);

packages/browser-utils/src/metrics/web-vitals/getFID.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
15+
*
16+
* // Sentry: web-vitals removed FID reporting from v5. We're keeping it around
17+
* for the time being.
18+
* // TODO(v10): Remove FID reporting!
1519
*/
1620

1721
import { bindReporter } from './lib/bindReporter';

packages/browser-utils/src/metrics/web-vitals/getINP.ts

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,28 +17,34 @@
1717
import { WINDOW } from '../../types';
1818
import { bindReporter } from './lib/bindReporter';
1919
import { initMetric } from './lib/initMetric';
20-
import { DEFAULT_DURATION_THRESHOLD, estimateP98LongestInteraction, processInteractionEntry } from './lib/interactions';
20+
import { initUnique } from './lib/initUnique';
21+
import { InteractionManager } from './lib/InteractionManager';
2122
import { observe } from './lib/observe';
22-
import { onHidden } from './lib/onHidden';
2323
import { initInteractionCountPolyfill } from './lib/polyfills/interactionCountPolyfill';
2424
import { whenActivated } from './lib/whenActivated';
25-
import { whenIdle } from './lib/whenIdle';
26-
import type { INPMetric, MetricRatingThresholds, ReportOpts } from './types';
25+
import { whenIdleOrHidden } from './lib/whenIdleOrHidden';
26+
import type { INPMetric, INPReportOpts, MetricRatingThresholds } from './types';
2727

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

31+
// The default `durationThreshold` used across this library for observing
32+
// `event` entries via PerformanceObserver.
33+
const DEFAULT_DURATION_THRESHOLD = 40;
34+
3135
/**
3236
* Calculates the [INP](https://web.dev/articles/inp) value for the current
3337
* page and calls the `callback` function once the value is ready, along with
3438
* the `event` performance entries reported for that interaction. The reported
3539
* value is a `DOMHighResTimeStamp`.
3640
*
37-
* A custom `durationThreshold` configuration option can optionally be passed to
38-
* control what `event-timing` entries are considered for INP reporting. The
39-
* default threshold is `40`, which means INP scores of less than 40 are
40-
* reported as 0. Note that this will not affect your 75th percentile INP value
41-
* unless that value is also less than 40 (well below the recommended
41+
* A custom `durationThreshold` configuration option can optionally be passed
42+
* to control what `event-timing` entries are considered for INP reporting. The
43+
* default threshold is `40`, which means INP scores of less than 40 will not
44+
* be reported. To avoid reporting no interactions in these cases, the library
45+
* will fall back to the input delay of the first interaction. Note that this
46+
* will not affect your 75th percentile INP value unless that value is also
47+
* less than 40 (well below the recommended
4248
* [good](https://web.dev/articles/inp#what_is_a_good_inp_score) threshold).
4349
*
4450
* If the `reportAllChanges` configuration option is set to `true`, the
@@ -55,9 +61,9 @@ export const INPThresholds: MetricRatingThresholds = [200, 500];
5561
* hidden. As a result, the `callback` function might be called multiple times
5662
* during the same page load._
5763
*/
58-
export const onINP = (onReport: (metric: INPMetric) => void, opts: ReportOpts = {}) => {
64+
export const onINP = (onReport: (metric: INPMetric) => void, opts: INPReportOpts = {}) => {
5965
// Return if the browser doesn't support all APIs needed to measure INP.
60-
if (!('PerformanceEventTiming' in WINDOW && 'interactionId' in PerformanceEventTiming.prototype)) {
66+
if (!(globalThis.PerformanceEventTiming && 'interactionId' in PerformanceEventTiming.prototype)) {
6167
return;
6268
}
6369

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

78+
const interactionManager = initUnique(opts, InteractionManager);
79+
7280
const handleEntries = (entries: INPMetric['entries']) => {
7381
// Queue the `handleEntries()` callback in the next idle task.
7482
// This is needed to increase the chances that all event entries that
7583
// occurred between the user interaction and the next paint
7684
// have been dispatched. Note: there is currently an experiment
7785
// running in Chrome (EventTimingKeypressAndCompositionInteractionId)
7886
// 123+ that if rolled out fully may make this no longer necessary.
79-
whenIdle(() => {
80-
entries.forEach(processInteractionEntry);
87+
whenIdleOrHidden(() => {
88+
for (const entry of entries) {
89+
interactionManager._processEntry(entry);
90+
}
8191

82-
const inp = estimateP98LongestInteraction();
92+
const inp = interactionManager._estimateP98LongestInteraction();
8393

84-
if (inp && inp.latency !== metric.value) {
85-
metric.value = inp.latency;
94+
if (inp && inp._latency !== metric.value) {
95+
metric.value = inp._latency;
8696
metric.entries = inp.entries;
8797
report();
8898
}
@@ -96,7 +106,7 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: ReportOpts =
96106
// and performance. Running this callback for any interaction that spans
97107
// just one or two frames is likely not worth the insight that could be
98108
// gained.
99-
durationThreshold: opts.durationThreshold != null ? opts.durationThreshold : DEFAULT_DURATION_THRESHOLD,
109+
durationThreshold: opts.durationThreshold ?? DEFAULT_DURATION_THRESHOLD,
100110
});
101111

102112
report = bindReporter(onReport, metric, INPThresholds, opts.reportAllChanges);
@@ -106,9 +116,11 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: ReportOpts =
106116
// where the first interaction is less than the `durationThreshold`.
107117
po.observe({ type: 'first-input', buffered: true });
108118

109-
onHidden(() => {
110-
handleEntries(po.takeRecords() as INPMetric['entries']);
111-
report(true);
119+
WINDOW.document?.addEventListener('visibilitychange', () => {
120+
if (WINDOW.document?.visibilityState === 'hidden') {
121+
handleEntries(po.takeRecords() as INPMetric['entries']);
122+
report(true);
123+
}
112124
});
113125
}
114126
});

packages/browser-utils/src/metrics/web-vitals/getLCP.ts

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,17 @@ import { bindReporter } from './lib/bindReporter';
1919
import { getActivationStart } from './lib/getActivationStart';
2020
import { getVisibilityWatcher } from './lib/getVisibilityWatcher';
2121
import { initMetric } from './lib/initMetric';
22+
import { initUnique } from './lib/initUnique';
23+
import { LCPEntryManager } from './lib/LCPEntryManager';
2224
import { observe } from './lib/observe';
23-
import { onHidden } from './lib/onHidden';
2425
import { runOnce } from './lib/runOnce';
2526
import { whenActivated } from './lib/whenActivated';
26-
import { whenIdle } from './lib/whenIdle';
27+
import { whenIdleOrHidden } from './lib/whenIdleOrHidden';
2728
import type { LCPMetric, MetricRatingThresholds, ReportOpts } from './types';
2829

2930
/** Thresholds for LCP. See https://web.dev/articles/lcp#what_is_a_good_lcp_score */
3031
export const LCPThresholds: MetricRatingThresholds = [2500, 4000];
3132

32-
const reportedMetricIDs: Record<string, boolean> = {};
33-
3433
/**
3534
* Calculates the [LCP](https://web.dev/articles/lcp) value for the current page and
3635
* calls the `callback` function once the value is ready (along with the
@@ -48,60 +47,62 @@ export const onLCP = (onReport: (metric: LCPMetric) => void, opts: ReportOpts =
4847
const metric = initMetric('LCP');
4948
let report: ReturnType<typeof bindReporter>;
5049

50+
const lcpEntryManager = initUnique(opts, LCPEntryManager);
51+
5152
const handleEntries = (entries: LCPMetric['entries']) => {
5253
// If reportAllChanges is set then call this function for each entry,
5354
// otherwise only consider the last one.
54-
if (!opts.reportAllChanges) {
55+
if (!opts!.reportAllChanges) {
5556
// eslint-disable-next-line no-param-reassign
5657
entries = entries.slice(-1);
5758
}
5859

59-
entries.forEach(entry => {
60+
for (const entry of entries) {
61+
lcpEntryManager._processEntry(entry);
62+
6063
// Only report if the page wasn't hidden prior to LCP.
6164
if (entry.startTime < visibilityWatcher.firstHiddenTime) {
6265
// The startTime attribute returns the value of the renderTime if it is
6366
// not 0, and the value of the loadTime otherwise. The activationStart
6467
// reference is used because LCP should be relative to page activation
65-
// rather than navigation start if the page was pre-rendered. But in cases
68+
// rather than navigation start if the page was prerendered. But in cases
6669
// where `activationStart` occurs after the LCP, this time should be
6770
// clamped at 0.
6871
metric.value = Math.max(entry.startTime - getActivationStart(), 0);
6972
metric.entries = [entry];
7073
report();
7174
}
72-
});
75+
}
7376
};
7477

7578
const po = observe('largest-contentful-paint', handleEntries);
7679

7780
if (po) {
7881
report = bindReporter(onReport, metric, LCPThresholds, opts.reportAllChanges);
7982

83+
// Ensure this logic only runs once, since it can be triggered from
84+
// any of three different event listeners below.
8085
const stopListening = runOnce(() => {
81-
if (!reportedMetricIDs[metric.id]) {
82-
handleEntries(po.takeRecords() as LCPMetric['entries']);
83-
po.disconnect();
84-
reportedMetricIDs[metric.id] = true;
85-
report(true);
86-
}
86+
handleEntries(po.takeRecords() as LCPMetric['entries']);
87+
po.disconnect();
88+
report(true);
8789
});
8890

89-
// Stop listening after input. Note: while scrolling is an input that
90-
// stops LCP observation, it's unreliable since it can be programmatically
91-
// generated. See: https://github.com/GoogleChrome/web-vitals/issues/75
92-
['keydown', 'click'].forEach(type => {
93-
// Wrap in a setTimeout so the callback is run in a separate task
94-
// to avoid extending the keyboard/click handler to reduce INP impact
91+
// Stop listening after input or visibilitychange.
92+
// Note: while scrolling is an input that stops LCP observation, it's
93+
// unreliable since it can be programmatically generated.
94+
// See: https://github.com/GoogleChrome/web-vitals/issues/75
95+
for (const type of ['keydown', 'click', 'visibilitychange']) {
96+
// Wrap the listener in an idle callback so it's run in a separate
97+
// task to reduce potential INP impact.
9598
// https://github.com/GoogleChrome/web-vitals/issues/383
9699
if (WINDOW.document) {
97-
addEventListener(type, () => whenIdle(stopListening as () => void), {
98-
once: true,
100+
addEventListener(type, () => whenIdleOrHidden(stopListening), {
99101
capture: true,
102+
once: true,
100103
});
101104
}
102-
});
103-
104-
onHidden(stopListening);
105+
}
105106
}
106107
});
107108
};

0 commit comments

Comments
 (0)