Skip to content

Commit 6fc18fe

Browse files
authored
feat(browser): Update web-vitals to 5.0.2 (#16492)
Bump vendored `web-vitals` library. Important changes from the original library: - For now until the next SDK major, we'll keep reporting FID. `web-vitals` removed the already deprecated APIs for it in v5 but we simply keep them from v4 - `web-vitals` further removed compatibility for older iOS Safari versions. Unfortunately, [we still support Safari 14.4 ](https://docs.sentry.io/platforms/javascript/troubleshooting/supported-browsers/) which is the last version that doesn't yet fully support the `visibilitychange` event. This requires us to keep the `onHidden` helper around which also listens to `pagehide` events that this Safari version supports. I adjusted our integration tests to keep one around that fails if we remove this special handling in a future upgrade (also added some context for future us). I will follow up with at least one more PR to do some more refactorings but I decided to keep them minimal in this PR to get better diffs for reviewing: - rename the `get*` files to `on*`, since this is how they're named now in the official library closes #16310
1 parent 65310d5 commit 6fc18fe

File tree

32 files changed

+563
-180
lines changed

32 files changed

+563
-180
lines changed

.size-limit.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ module.exports = [
120120
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
121121
ignore: ['react/jsx-runtime'],
122122
gzip: true,
123-
limit: '40.5 KB',
123+
limit: '41 KB',
124124
},
125125
// Vue SDK (ESM)
126126
{
@@ -215,7 +215,7 @@ module.exports = [
215215
import: createImport('init'),
216216
ignore: ['$app/stores'],
217217
gzip: true,
218-
limit: '39 KB',
218+
limit: '40 KB',
219219
},
220220
// Node SDK (ESM)
221221
{

dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { sentryTest } from '../../../../utils/fixtures';
44
import {
55
getFirstSentryEnvelopeRequest,
66
getMultipleSentryEnvelopeRequests,
7+
hidePage,
78
properFullEnvelopeRequestParser,
89
shouldSkipTracingTest,
910
} from '../../../../utils/helpers';
@@ -33,9 +34,7 @@ sentryTest('should capture an INP click event span after pageload', async ({ bro
3334
await page.waitForTimeout(500);
3435

3536
// Page hide to trigger INP
36-
await page.evaluate(() => {
37-
window.dispatchEvent(new Event('pagehide'));
38-
});
37+
await hidePage(page);
3938

4039
// Get the INP span envelope
4140
const spanEnvelope = (await spanEnvelopePromise)[0];

dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { sentryTest } from '../../../../utils/fixtures';
44
import {
55
getFirstSentryEnvelopeRequest,
66
getMultipleSentryEnvelopeRequests,
7+
hidePage,
78
properFullEnvelopeRequestParser,
89
shouldSkipTracingTest,
910
} from '../../../../utils/helpers';
@@ -35,9 +36,7 @@ sentryTest(
3536
await page.waitForTimeout(500);
3637

3738
// Page hide to trigger INP
38-
await page.evaluate(() => {
39-
window.dispatchEvent(new Event('pagehide'));
40-
});
39+
await hidePage(page);
4140

4241
// Get the INP span envelope
4342
const spanEnvelope = (await spanEnvelopePromise)[0];

dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { SpanEnvelope } from '@sentry/core';
33
import { sentryTest } from '../../../../utils/fixtures';
44
import {
55
getMultipleSentryEnvelopeRequests,
6+
hidePage,
67
properFullEnvelopeRequestParser,
78
shouldSkipTracingTest,
89
} from '../../../../utils/helpers';
@@ -33,9 +34,7 @@ sentryTest(
3334
await page.waitForTimeout(500);
3435

3536
// Page hide to trigger INP
36-
await page.evaluate(() => {
37-
window.dispatchEvent(new Event('pagehide'));
38-
});
37+
await hidePage(page);
3938

4039
// Get the INP span envelope
4140
const spanEnvelope = (await spanEnvelopePromise)[0];

dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/init.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Sentry.init({
1414
}),
1515
],
1616
tracesSampleRate: 1,
17+
debug: true,
1718
});
1819

1920
const client = Sentry.getClient();

dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { sentryTest } from '../../../../utils/fixtures';
44
import {
55
getFirstSentryEnvelopeRequest,
66
getMultipleSentryEnvelopeRequests,
7+
hidePage,
78
properFullEnvelopeRequestParser,
89
shouldSkipTracingTest,
910
} from '../../../../utils/helpers';
@@ -32,9 +33,7 @@ sentryTest('should capture an INP click event span during pageload', async ({ br
3233
await page.waitForTimeout(500);
3334

3435
// Page hide to trigger INP
35-
await page.evaluate(() => {
36-
window.dispatchEvent(new Event('pagehide'));
37-
});
36+
await hidePage(page);
3837

3938
// Get the INP span envelope
4039
const spanEnvelope = (await spanEnvelopePromise)[0];
@@ -118,6 +117,14 @@ sentryTest(
118117
});
119118

120119
// Page hide to trigger INP
120+
121+
// Important: Purposefully not using hidePage() here to test the hidden state
122+
// via the `pagehide` event. This is necessary because iOS Safari 14.4
123+
// still doesn't fully emit the `visibilitychange` events but it's the lower
124+
// bound for Safari on iOS that we support.
125+
// If this test times out or fails, it's likely because we tried updating
126+
// the web-vitals library which officially already dropped support for
127+
// this iOS version
121128
await page.evaluate(() => {
122129
window.dispatchEvent(new Event('pagehide'));
123130
});

packages/browser-utils/src/metrics/cls.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export function trackClsAsStandaloneSpan(): void {
5858
standaloneClsEntry = entry;
5959
}, true);
6060

61+
// TODO: Figure out if we can switch to using whenIdleOrHidden instead of onHidden
6162
// use pagehide event from web-vitals
6263
onHidden(() => {
6364
_collectClsOnce();

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
> A modular library for measuring the [Web Vitals](https://web.dev/vitals/) metrics on real users.
44
5-
This was vendored from: https://github.com/GoogleChrome/web-vitals: v3.5.2
5+
This was vendored from: https://github.com/GoogleChrome/web-vitals: v5.0.2
66

77
The commit SHA used is:
8-
[3d2b3dc8576cc003618952fa39902fab764a53e2](https://github.com/GoogleChrome/web-vitals/tree/3d2b3dc8576cc003618952fa39902fab764a53e2)
8+
[463abbd425cda01ed65e0b5d18be9f559fe446cb](https://github.com/GoogleChrome/web-vitals/tree/463abbd425cda01ed65e0b5d18be9f559fe446cb)
99

1010
Current vendored web vitals are:
1111

@@ -27,6 +27,12 @@ web-vitals only report once per pageload.
2727

2828
## CHANGELOG
2929

30+
https://github.com/getsentry/sentry-javascript/pull/16492
31+
32+
- Bumped from Web Vitals 4.2.5 to 5.0.2
33+
- Mainly fixes some INP, LCP and FCP edge cases
34+
- Original library removed FID; we still keep it around for now
35+
3036
https://github.com/getsentry/sentry-javascript/pull/14439
3137

3238
- 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: 5 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';
@@ -60,6 +64,7 @@ export const onFID = (onReport: (metric: FIDMetric) => void, opts: ReportOpts =
6064
report = bindReporter(onReport, metric, FIDThresholds, opts.reportAllChanges);
6165

6266
if (po) {
67+
// sentry: TODO: Figure out if we can use new whinIdleOrHidden insteard of onHidden
6368
onHidden(
6469
runOnce(() => {
6570
handleEntries(po.takeRecords() as FIDMetric['entries']);

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

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

17-
import { WINDOW } from '../../types';
1817
import { bindReporter } from './lib/bindReporter';
1918
import { initMetric } from './lib/initMetric';
20-
import { DEFAULT_DURATION_THRESHOLD, estimateP98LongestInteraction, processInteractionEntry } from './lib/interactions';
19+
import { initUnique } from './lib/initUnique';
20+
import { InteractionManager } from './lib/InteractionManager';
2121
import { observe } from './lib/observe';
2222
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,6 +116,9 @@ 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

119+
// sentry: we use onHidden instead of directly listening to visibilitychange
120+
// because some browsers we still support (Safari <14.4) don't fully support
121+
// `visibilitychange` or have known bugs w.r.t the `visibilitychange` event.
109122
onHidden(() => {
110123
handleEntries(po.takeRecords() as INPMetric['entries']);
111124
report(true);

0 commit comments

Comments
 (0)