Skip to content

Commit 104b64e

Browse files
logaretmclaude
andauthored
fix(browser): filter implausible LCP values (#20338)
## Summary - add an LCP plausibility guard to drop implausible browser-reported values above 60 seconds - apply the guard to both pageload LCP measurements and standalone LCP spans - add focused unit tests for valid and invalid LCP values --------- Co-authored-by: GPT-5 <noreply@anthropic.com>
1 parent e818fcb commit 104b64e

File tree

5 files changed

+136
-5
lines changed

5 files changed

+136
-5
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
addTtfbInstrumentationHandler,
2323
type PerformanceLongAnimationFrameTiming,
2424
} from './instrument';
25-
import { trackLcpAsStandaloneSpan } from './lcp';
25+
import { isValidLcpMetric, trackLcpAsStandaloneSpan } from './lcp';
2626
import { resourceTimingToSpanAttributes } from './resourceTiming';
2727
import { getBrowserPerformanceAPI, isMeasurementValue, msToSec, startAndEndSpan } from './utils';
2828
import { getActivationStart } from './web-vitals/lib/getActivationStart';
@@ -283,7 +283,7 @@ function _trackCLS(): () => void {
283283
function _trackLCP(): () => void {
284284
return addLcpInstrumentationHandler(({ metric }) => {
285285
const entry = metric.entries[metric.entries.length - 1];
286-
if (!entry) {
286+
if (!entry || !isValidLcpMetric(metric.value)) {
287287
return;
288288
}
289289

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ import { addLcpInstrumentationHandler } from './instrument';
1515
import type { WebVitalReportEvent } from './utils';
1616
import { listenForWebVitalReportEvents, msToSec, startStandaloneWebVitalSpan, supportsWebVital } from './utils';
1717

18+
/**
19+
* 60 seconds is the maximum for a plausible LCP value.
20+
*/
21+
export const MAX_PLAUSIBLE_LCP_DURATION = 60_000;
22+
23+
export function isValidLcpMetric(lcpValue: number | undefined): lcpValue is number {
24+
return lcpValue != null && lcpValue > 0 && lcpValue <= MAX_PLAUSIBLE_LCP_DURATION;
25+
}
26+
1827
/**
1928
* Starts tracking the Largest Contentful Paint on the current page and collects the value once
2029
*
@@ -34,7 +43,7 @@ export function trackLcpAsStandaloneSpan(client: Client): void {
3443

3544
const cleanupLcpHandler = addLcpInstrumentationHandler(({ metric }) => {
3645
const entry = metric.entries[metric.entries.length - 1] as LargestContentfulPaint | undefined;
37-
if (!entry) {
46+
if (!entry || !isValidLcpMetric(metric.value)) {
3847
return;
3948
}
4049
standaloneLcpValue = metric.value;
@@ -56,6 +65,10 @@ export function _sendStandaloneLcpSpan(
5665
pageloadSpanId: string,
5766
reportEvent: WebVitalReportEvent,
5867
) {
68+
if (!isValidLcpMetric(lcpValue)) {
69+
return;
70+
}
71+
5972
DEBUG_BUILD && debug.log(`Sending LCP span (${lcpValue})`);
6073

6174
const startTime = msToSec((browserPerformanceTimeOrigin() || 0) + (entry?.startTime || 0));

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { WINDOW } from '../types';
1818
import { getCachedInteractionContext, INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION } from './inp';
1919
import type { InstrumentationHandlerCallback } from './instrument';
2020
import { addClsInstrumentationHandler, addInpInstrumentationHandler, addLcpInstrumentationHandler } from './instrument';
21+
import { isValidLcpMetric } from './lcp';
2122
import type { WebVitalReportEvent } from './utils';
2223
import { getBrowserPerformanceAPI, listenForWebVitalReportEvents, msToSec, supportsWebVital } from './utils';
2324
import type { PerformanceEventTiming } from './instrument';
@@ -121,7 +122,7 @@ export function trackLcpAsSpan(client: Client): void {
121122

122123
const cleanupLcpHandler = addLcpInstrumentationHandler(({ metric }) => {
123124
const entry = metric.entries[metric.entries.length - 1] as LargestContentfulPaint | undefined;
124-
if (!entry) {
125+
if (!entry || !isValidLcpMetric(metric.value)) {
125126
return;
126127
}
127128
lcpValue = metric.value;
@@ -143,6 +144,10 @@ export function _sendLcpSpan(
143144
pageloadSpan?: Span,
144145
reportEvent?: WebVitalReportEvent,
145146
): void {
147+
if (!isValidLcpMetric(lcpValue)) {
148+
return;
149+
}
150+
146151
DEBUG_BUILD && debug.log(`Sending LCP span (${lcpValue})`);
147152

148153
const performanceTimeOrigin = browserPerformanceTimeOrigin() || 0;
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import * as SentryCore from '@sentry/core';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { _sendStandaloneLcpSpan, isValidLcpMetric, MAX_PLAUSIBLE_LCP_DURATION } from '../../src/metrics/lcp';
4+
import * as WebVitalUtils from '../../src/metrics/utils';
5+
6+
vi.mock('@sentry/core', async () => {
7+
const actual = await vi.importActual('@sentry/core');
8+
return {
9+
...actual,
10+
browserPerformanceTimeOrigin: vi.fn(),
11+
getCurrentScope: vi.fn(),
12+
htmlTreeAsString: vi.fn(),
13+
};
14+
});
15+
16+
describe('isValidLcpMetric', () => {
17+
it('returns true for plausible lcp values', () => {
18+
expect(isValidLcpMetric(1)).toBe(true);
19+
expect(isValidLcpMetric(2_500)).toBe(true);
20+
expect(isValidLcpMetric(MAX_PLAUSIBLE_LCP_DURATION)).toBe(true);
21+
});
22+
23+
it('returns false for implausible lcp values', () => {
24+
expect(isValidLcpMetric(undefined)).toBe(false);
25+
expect(isValidLcpMetric(0)).toBe(false);
26+
expect(isValidLcpMetric(-1)).toBe(false);
27+
expect(isValidLcpMetric(MAX_PLAUSIBLE_LCP_DURATION + 1)).toBe(false);
28+
});
29+
});
30+
31+
describe('_sendStandaloneLcpSpan', () => {
32+
const mockSpan = {
33+
addEvent: vi.fn(),
34+
end: vi.fn(),
35+
};
36+
37+
const mockScope = {
38+
getScopeData: vi.fn().mockReturnValue({
39+
transactionName: 'test-transaction',
40+
}),
41+
};
42+
43+
beforeEach(() => {
44+
vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any);
45+
vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000);
46+
vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`);
47+
vi.spyOn(WebVitalUtils, 'startStandaloneWebVitalSpan').mockReturnValue(mockSpan as any);
48+
});
49+
50+
afterEach(() => {
51+
vi.clearAllMocks();
52+
});
53+
54+
it('sends a standalone lcp span with entry data', () => {
55+
const lcpValue = 1_234;
56+
const mockEntry: LargestContentfulPaint = {
57+
name: 'largest-contentful-paint',
58+
entryType: 'largest-contentful-paint',
59+
startTime: 100,
60+
duration: 0,
61+
id: 'image',
62+
url: 'https://example.com/image.png',
63+
size: 1234,
64+
loadTime: 95,
65+
renderTime: 100,
66+
element: { tagName: 'img' } as Element,
67+
toJSON: vi.fn(),
68+
};
69+
70+
_sendStandaloneLcpSpan(lcpValue, mockEntry, '123', 'navigation');
71+
72+
expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith({
73+
name: '<img>',
74+
transaction: 'test-transaction',
75+
attributes: {
76+
'sentry.origin': 'auto.http.browser.lcp',
77+
'sentry.op': 'ui.webvital.lcp',
78+
'sentry.exclusive_time': 0,
79+
'sentry.pageload.span_id': '123',
80+
'sentry.report_event': 'navigation',
81+
'lcp.element': '<img>',
82+
'lcp.id': 'image',
83+
'lcp.url': 'https://example.com/image.png',
84+
'lcp.loadTime': 95,
85+
'lcp.renderTime': 100,
86+
'lcp.size': 1234,
87+
},
88+
startTime: 1.1,
89+
});
90+
91+
expect(mockSpan.addEvent).toHaveBeenCalledWith('lcp', {
92+
'sentry.measurement_unit': 'millisecond',
93+
'sentry.measurement_value': lcpValue,
94+
});
95+
expect(mockSpan.end).toHaveBeenCalledWith(1.1);
96+
});
97+
98+
it('does not send a standalone lcp span for implausibly large values', () => {
99+
_sendStandaloneLcpSpan(MAX_PLAUSIBLE_LCP_DURATION + 1, undefined, '123', 'pagehide');
100+
101+
expect(WebVitalUtils.startStandaloneWebVitalSpan).not.toHaveBeenCalled();
102+
expect(mockSpan.addEvent).not.toHaveBeenCalled();
103+
expect(mockSpan.end).not.toHaveBeenCalled();
104+
});
105+
});

packages/browser-utils/test/metrics/webVitalSpans.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as SentryCore from '@sentry/core';
22
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
33
import * as inpModule from '../../src/metrics/inp';
4+
import { MAX_PLAUSIBLE_LCP_DURATION } from '../../src/metrics/lcp';
45
import { _emitWebVitalSpan, _sendClsSpan, _sendInpSpan, _sendLcpSpan } from '../../src/metrics/webVitalSpans';
56

67
vi.mock('@sentry/core', async () => {
@@ -262,7 +263,7 @@ describe('_sendLcpSpan', () => {
262263
});
263264

264265
it('sends a streamed LCP span without entry data', () => {
265-
_sendLcpSpan(0, undefined);
266+
_sendLcpSpan(250, undefined);
266267

267268
expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith(
268269
expect.objectContaining({
@@ -271,6 +272,13 @@ describe('_sendLcpSpan', () => {
271272
}),
272273
);
273274
});
275+
276+
it('drops implausible LCP values', () => {
277+
_sendLcpSpan(0, undefined);
278+
_sendLcpSpan(MAX_PLAUSIBLE_LCP_DURATION + 1, undefined);
279+
280+
expect(SentryCore.startInactiveSpan).not.toHaveBeenCalled();
281+
});
274282
});
275283

276284
describe('_sendClsSpan', () => {

0 commit comments

Comments
 (0)