Skip to content

Commit 1063983

Browse files
logaretmclaude
andcommitted
fix(browser): filter implausible lcp values
Co-Authored-By: GPT-5 <noreply@anthropic.com>
1 parent 5f72df5 commit 1063983

3 files changed

Lines changed: 120 additions & 3 deletions

File tree

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

0 commit comments

Comments
 (0)