Skip to content

Commit b72a681

Browse files
authored
fix(tracing): Ensure web vitals are correctly stopped/captured (#10323)
This was identified as a problem by @edwardgou-sentry , and was incorrectly changed in 7.75.0. I think this should work now as expected 🤔 I added some tests for LCP to hopefully very this works kind of as expected (=even after stopping it you can still get a LCP value).
1 parent 52495c0 commit b72a681

File tree

9 files changed

+140
-22
lines changed

9 files changed

+140
-22
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { addLcpInstrumentationHandler } from '@sentry-internal/tracing';
2+
3+
addLcpInstrumentationHandler(({ metric }) => {
4+
const entry = metric.entries[metric.entries.length - 1];
5+
window._LCP = entry.size;
6+
});
7+
8+
addLcpInstrumentationHandler(({ metric }) => {
9+
const entry = metric.entries[metric.entries.length - 1];
10+
window._LCP2 = entry.size;
11+
});
12+
13+
window.ADD_HANDLER = () => {
14+
addLcpInstrumentationHandler(({ metric }) => {
15+
const entry = metric.entries[metric.entries.length - 1];
16+
window._LCP3 = entry.size;
17+
});
18+
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<div id="content"></div>
8+
<img src="https://example.com/path/to/image.png" />
9+
<button type="button">Test button</button>
10+
</body>
11+
</html>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { Route } from '@playwright/test';
2+
import { expect } from '@playwright/test';
3+
import type { Event } from '@sentry/types';
4+
5+
import { sentryTest } from '../../../../utils/fixtures';
6+
import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers';
7+
8+
const bundle = process.env.PW_BUNDLE || '';
9+
10+
sentryTest(
11+
'should capture metrics for LCP instrumentation handlers',
12+
async ({ browserName, getLocalTestPath, page }) => {
13+
// This uses a utility that is not exported in CDN bundles
14+
if (shouldSkipTracingTest() || browserName !== 'chromium' || bundle.startsWith('bundle')) {
15+
sentryTest.skip();
16+
}
17+
18+
await page.route('**/path/to/image.png', (route: Route) =>
19+
route.fulfill({ path: `${__dirname}/assets/sentry-logo-600x179.png` }),
20+
);
21+
22+
const url = await getLocalTestPath({ testDir: __dirname });
23+
const [eventData] = await Promise.all([
24+
getFirstSentryEnvelopeRequest<Event>(page),
25+
page.goto(url),
26+
page.click('button'),
27+
]);
28+
29+
expect(eventData.measurements).toBeDefined();
30+
expect(eventData.measurements?.lcp?.value).toBeDefined();
31+
32+
expect(eventData.tags?.['lcp.element']).toBe('body > img');
33+
expect(eventData.tags?.['lcp.size']).toBe(107400);
34+
expect(eventData.tags?.['lcp.url']).toBe('https://example.com/path/to/image.png');
35+
36+
const lcp = await (await page.waitForFunction('window._LCP')).jsonValue();
37+
const lcp2 = await (await page.waitForFunction('window._LCP2')).jsonValue();
38+
const lcp3 = await page.evaluate('window._LCP3');
39+
40+
expect(lcp).toEqual(107400);
41+
expect(lcp2).toEqual(107400);
42+
// this has not been triggered yet
43+
expect(lcp3).toEqual(undefined);
44+
45+
// Adding a handler after LCP is completed still triggers the handler
46+
await page.evaluate('window.ADD_HANDLER()');
47+
const lcp3_2 = await (await page.waitForFunction('window._LCP3')).jsonValue();
48+
49+
expect(lcp3_2).toEqual(107400);
50+
},
51+
);

dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/fixtures/ReplayRecordingData.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,11 @@ export const ReplayRecordingData = [
215215
description: 'largest-contentful-paint',
216216
startTimestamp: expect.any(Number),
217217
endTimestamp: expect.any(Number),
218-
data: { value: expect.any(Number), size: expect.any(Number) },
218+
data: {
219+
value: expect.any(Number),
220+
size: expect.any(Number),
221+
nodeId: 16,
222+
},
219223
},
220224
},
221225
},

dev-packages/e2e-tests/test-applications/react-router-6-use-routes/tests/fixtures/ReplayRecordingData.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,11 @@ export const ReplayRecordingData = [
215215
description: 'largest-contentful-paint',
216216
startTimestamp: expect.any(Number),
217217
endTimestamp: expect.any(Number),
218-
data: { value: expect.any(Number), size: expect.any(Number) },
218+
data: {
219+
value: expect.any(Number),
220+
size: expect.any(Number),
221+
nodeId: 16,
222+
},
219223
},
220224
},
221225
},

dev-packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,11 @@ export const ReplayRecordingData = [
215215
description: 'largest-contentful-paint',
216216
startTimestamp: expect.any(Number),
217217
endTimestamp: expect.any(Number),
218-
data: { value: expect.any(Number), size: expect.any(Number) },
218+
data: {
219+
value: expect.any(Number),
220+
size: expect.any(Number),
221+
nodeId: 16,
222+
},
219223
},
220224
},
221225
},

packages/tracing-internal/src/browser/instrument.ts

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ interface Metric {
7373

7474
type InstrumentHandlerType = InstrumentHandlerTypeMetric | InstrumentHandlerTypePerformanceObserver;
7575

76+
type StopListening = undefined | void | (() => void);
77+
7678
// eslint-disable-next-line @typescript-eslint/no-explicit-any
7779
type InstrumentHandlerCallback = (data: any) => void;
7880

@@ -88,17 +90,29 @@ let _previousLcp: Metric | undefined;
8890
/**
8991
* Add a callback that will be triggered when a CLS metric is available.
9092
* Returns a cleanup callback which can be called to remove the instrumentation handler.
93+
*
94+
* Pass `stopOnCallback = true` to stop listening for CLS when the cleanup callback is called.
95+
* This will lead to the CLS being finalized and frozen.
9196
*/
92-
export function addClsInstrumentationHandler(callback: (data: { metric: Metric }) => void): CleanupHandlerCallback {
93-
return addMetricObserver('cls', callback, instrumentCls, _previousCls);
97+
export function addClsInstrumentationHandler(
98+
callback: (data: { metric: Metric }) => void,
99+
stopOnCallback = false,
100+
): CleanupHandlerCallback {
101+
return addMetricObserver('cls', callback, instrumentCls, _previousCls, stopOnCallback);
94102
}
95103

96104
/**
97105
* Add a callback that will be triggered when a LCP metric is available.
98106
* Returns a cleanup callback which can be called to remove the instrumentation handler.
107+
*
108+
* Pass `stopOnCallback = true` to stop listening for LCP when the cleanup callback is called.
109+
* This will lead to the LCP being finalized and frozen.
99110
*/
100-
export function addLcpInstrumentationHandler(callback: (data: { metric: Metric }) => void): CleanupHandlerCallback {
101-
return addMetricObserver('lcp', callback, instrumentLcp, _previousLcp);
111+
export function addLcpInstrumentationHandler(
112+
callback: (data: { metric: Metric }) => void,
113+
stopOnCallback = false,
114+
): CleanupHandlerCallback {
115+
return addMetricObserver('lcp', callback, instrumentLcp, _previousLcp, stopOnCallback);
102116
}
103117

104118
/**
@@ -158,8 +172,8 @@ function triggerHandlers(type: InstrumentHandlerType, data: unknown): void {
158172
}
159173
}
160174

161-
function instrumentCls(): void {
162-
onCLS(metric => {
175+
function instrumentCls(): StopListening {
176+
return onCLS(metric => {
163177
triggerHandlers('cls', {
164178
metric,
165179
});
@@ -168,16 +182,16 @@ function instrumentCls(): void {
168182
}
169183

170184
function instrumentFid(): void {
171-
onFID(metric => {
185+
return onFID(metric => {
172186
triggerHandlers('fid', {
173187
metric,
174188
});
175189
_previousFid = metric;
176190
});
177191
}
178192

179-
function instrumentLcp(): void {
180-
onLCP(metric => {
193+
function instrumentLcp(): StopListening {
194+
return onLCP(metric => {
181195
triggerHandlers('lcp', {
182196
metric,
183197
});
@@ -188,21 +202,24 @@ function instrumentLcp(): void {
188202
function addMetricObserver(
189203
type: InstrumentHandlerTypeMetric,
190204
callback: InstrumentHandlerCallback,
191-
instrumentFn: () => void,
205+
instrumentFn: () => StopListening,
192206
previousValue: Metric | undefined,
207+
stopOnCallback = false,
193208
): CleanupHandlerCallback {
194209
addHandler(type, callback);
195210

211+
let stopListening: StopListening | undefined;
212+
196213
if (!instrumented[type]) {
197-
instrumentFn();
214+
stopListening = instrumentFn();
198215
instrumented[type] = true;
199216
}
200217

201218
if (previousValue) {
202219
callback({ metric: previousValue });
203220
}
204221

205-
return getCleanupCallback(type, callback);
222+
return getCleanupCallback(type, callback, stopOnCallback ? stopListening : undefined);
206223
}
207224

208225
function instrumentPerformanceObserver(type: InstrumentHandlerTypePerformanceObserver): void {
@@ -228,8 +245,16 @@ function addHandler(type: InstrumentHandlerType, handler: InstrumentHandlerCallb
228245
}
229246

230247
// Get a callback which can be called to remove the instrumentation handler
231-
function getCleanupCallback(type: InstrumentHandlerType, callback: InstrumentHandlerCallback): CleanupHandlerCallback {
248+
function getCleanupCallback(
249+
type: InstrumentHandlerType,
250+
callback: InstrumentHandlerCallback,
251+
stopListening: StopListening,
252+
): CleanupHandlerCallback {
232253
return () => {
254+
if (stopListening) {
255+
stopListening();
256+
}
257+
233258
const typeHandlers = handlers[type];
234259

235260
if (!typeHandlers) {

packages/tracing-internal/src/browser/metrics/index.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ let _lcpEntry: LargestContentfulPaint | undefined;
3939
let _clsEntry: LayoutShift | undefined;
4040

4141
/**
42-
* Start tracking web vitals
42+
* Start tracking web vitals.
43+
* The callback returned by this function can be used to stop tracking & ensure all measurements are final & captured.
4344
*
4445
* @returns A function that forces web vitals collection
4546
*/
@@ -129,35 +130,35 @@ export function startTrackingInteractions(): void {
129130
/** Starts tracking the Cumulative Layout Shift on the current page. */
130131
function _trackCLS(): () => void {
131132
return addClsInstrumentationHandler(({ metric }) => {
132-
const entry = metric.entries.pop();
133+
const entry = metric.entries[metric.entries.length - 1];
133134
if (!entry) {
134135
return;
135136
}
136137

137138
DEBUG_BUILD && logger.log('[Measurements] Adding CLS');
138139
_measurements['cls'] = { value: metric.value, unit: '' };
139140
_clsEntry = entry as LayoutShift;
140-
});
141+
}, true);
141142
}
142143

143144
/** Starts tracking the Largest Contentful Paint on the current page. */
144145
function _trackLCP(): () => void {
145146
return addLcpInstrumentationHandler(({ metric }) => {
146-
const entry = metric.entries.pop();
147+
const entry = metric.entries[metric.entries.length - 1];
147148
if (!entry) {
148149
return;
149150
}
150151

151152
DEBUG_BUILD && logger.log('[Measurements] Adding LCP');
152153
_measurements['lcp'] = { value: metric.value, unit: 'millisecond' };
153154
_lcpEntry = entry as LargestContentfulPaint;
154-
});
155+
}, true);
155156
}
156157

157158
/** Starts tracking the First Input Delay on the current page. */
158159
function _trackFID(): () => void {
159160
return addFidInstrumentationHandler(({ metric }) => {
160-
const entry = metric.entries.pop();
161+
const entry = metric.entries[metric.entries.length - 1];
161162
if (!entry) {
162163
return;
163164
}

0 commit comments

Comments
 (0)