From 122e2bb4fa677484c08310641d5a3e32b78f5eab Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 15 Apr 2026 16:44:32 -0400 Subject: [PATCH] fix(browser): filter implausible lcp values Co-Authored-By: GPT-5 --- .../src/metrics/browserMetrics.ts | 4 +- packages/browser-utils/src/metrics/lcp.ts | 15 ++- .../browser-utils/test/metrics/lcp.test.ts | 104 ++++++++++++++++++ 3 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 packages/browser-utils/test/metrics/lcp.test.ts diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 28d1f2bfaec8..c6e7623e536a 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -21,7 +21,7 @@ import { addTtfbInstrumentationHandler, type PerformanceLongAnimationFrameTiming, } from './instrument'; -import { trackLcpAsStandaloneSpan } from './lcp'; +import { isValidLcpMetric, trackLcpAsStandaloneSpan } from './lcp'; import { resourceTimingToSpanAttributes } from './resourceTiming'; import { getBrowserPerformanceAPI, isMeasurementValue, msToSec, startAndEndSpan } from './utils'; import { getActivationStart } from './web-vitals/lib/getActivationStart'; @@ -260,7 +260,7 @@ function _trackCLS(): () => void { function _trackLCP(): () => void { return addLcpInstrumentationHandler(({ metric }) => { const entry = metric.entries[metric.entries.length - 1]; - if (!entry) { + if (!entry || !isValidLcpMetric(metric.value)) { return; } diff --git a/packages/browser-utils/src/metrics/lcp.ts b/packages/browser-utils/src/metrics/lcp.ts index a6410ac08580..cd2f7555ff0a 100644 --- a/packages/browser-utils/src/metrics/lcp.ts +++ b/packages/browser-utils/src/metrics/lcp.ts @@ -15,6 +15,15 @@ import { addLcpInstrumentationHandler } from './instrument'; import type { WebVitalReportEvent } from './utils'; import { listenForWebVitalReportEvents, msToSec, startStandaloneWebVitalSpan, supportsWebVital } from './utils'; +/** + * 60 seconds is the maximum for a plausible LCP value. + */ +export const MAX_PLAUSIBLE_LCP_DURATION = 60_000; + +export function isValidLcpMetric(lcpValue: number | undefined): lcpValue is number { + return lcpValue != null && lcpValue >= 0 && lcpValue <= MAX_PLAUSIBLE_LCP_DURATION; +} + /** * Starts tracking the Largest Contentful Paint on the current page and collects the value once * @@ -34,7 +43,7 @@ export function trackLcpAsStandaloneSpan(client: Client): void { const cleanupLcpHandler = addLcpInstrumentationHandler(({ metric }) => { const entry = metric.entries[metric.entries.length - 1] as LargestContentfulPaint | undefined; - if (!entry) { + if (!entry || !isValidLcpMetric(metric.value)) { return; } standaloneLcpValue = metric.value; @@ -56,6 +65,10 @@ export function _sendStandaloneLcpSpan( pageloadSpanId: string, reportEvent: WebVitalReportEvent, ) { + if (!isValidLcpMetric(lcpValue)) { + return; + } + DEBUG_BUILD && debug.log(`Sending LCP span (${lcpValue})`); const startTime = msToSec((browserPerformanceTimeOrigin() || 0) + (entry?.startTime || 0)); diff --git a/packages/browser-utils/test/metrics/lcp.test.ts b/packages/browser-utils/test/metrics/lcp.test.ts new file mode 100644 index 000000000000..b1868fc6c0f0 --- /dev/null +++ b/packages/browser-utils/test/metrics/lcp.test.ts @@ -0,0 +1,104 @@ +import * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { _sendStandaloneLcpSpan, isValidLcpMetric, MAX_PLAUSIBLE_LCP_DURATION } from '../../src/metrics/lcp'; +import * as WebVitalUtils from '../../src/metrics/utils'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + browserPerformanceTimeOrigin: vi.fn(), + getCurrentScope: vi.fn(), + htmlTreeAsString: vi.fn(), + }; +}); + +describe('isValidLcpMetric', () => { + it('returns true for plausible lcp values', () => { + expect(isValidLcpMetric(0)).toBe(true); + expect(isValidLcpMetric(2_500)).toBe(true); + expect(isValidLcpMetric(MAX_PLAUSIBLE_LCP_DURATION)).toBe(true); + }); + + it('returns false for implausible lcp values', () => { + expect(isValidLcpMetric(undefined)).toBe(false); + expect(isValidLcpMetric(-1)).toBe(false); + expect(isValidLcpMetric(MAX_PLAUSIBLE_LCP_DURATION + 1)).toBe(false); + }); +}); + +describe('_sendStandaloneLcpSpan', () => { + const mockSpan = { + addEvent: vi.fn(), + end: vi.fn(), + }; + + const mockScope = { + getScopeData: vi.fn().mockReturnValue({ + transactionName: 'test-transaction', + }), + }; + + beforeEach(() => { + vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); + vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); + vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); + vi.spyOn(WebVitalUtils, 'startStandaloneWebVitalSpan').mockReturnValue(mockSpan as any); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('sends a standalone lcp span with entry data', () => { + const lcpValue = 1_234; + const mockEntry: LargestContentfulPaint = { + name: 'largest-contentful-paint', + entryType: 'largest-contentful-paint', + startTime: 100, + duration: 0, + id: 'image', + url: 'https://example.com/image.png', + size: 1234, + loadTime: 95, + renderTime: 100, + element: { tagName: 'img' } as Element, + toJSON: vi.fn(), + }; + + _sendStandaloneLcpSpan(lcpValue, mockEntry, '123', 'navigation'); + + expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith({ + name: '', + transaction: 'test-transaction', + attributes: { + 'sentry.origin': 'auto.http.browser.lcp', + 'sentry.op': 'ui.webvital.lcp', + 'sentry.exclusive_time': 0, + 'sentry.pageload.span_id': '123', + 'sentry.report_event': 'navigation', + 'lcp.element': '', + 'lcp.id': 'image', + 'lcp.url': 'https://example.com/image.png', + 'lcp.loadTime': 95, + 'lcp.renderTime': 100, + 'lcp.size': 1234, + }, + startTime: 1.1, + }); + + expect(mockSpan.addEvent).toHaveBeenCalledWith('lcp', { + 'sentry.measurement_unit': 'millisecond', + 'sentry.measurement_value': lcpValue, + }); + expect(mockSpan.end).toHaveBeenCalledWith(1.1); + }); + + it('does not send a standalone lcp span for implausibly large values', () => { + _sendStandaloneLcpSpan(MAX_PLAUSIBLE_LCP_DURATION + 1, undefined, '123', 'pagehide'); + + expect(WebVitalUtils.startStandaloneWebVitalSpan).not.toHaveBeenCalled(); + expect(mockSpan.addEvent).not.toHaveBeenCalled(); + expect(mockSpan.end).not.toHaveBeenCalled(); + }); +});