diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 9a00ab322e16..76e853eef5d3 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -22,7 +22,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'; @@ -283,7 +283,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..c11f6bd63cbc 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/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts index b342b653df97..6f6d8de3901e 100644 --- a/packages/browser-utils/src/metrics/webVitalSpans.ts +++ b/packages/browser-utils/src/metrics/webVitalSpans.ts @@ -18,6 +18,7 @@ import { WINDOW } from '../types'; import { getCachedInteractionContext, INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION } from './inp'; import type { InstrumentationHandlerCallback } from './instrument'; import { addClsInstrumentationHandler, addInpInstrumentationHandler, addLcpInstrumentationHandler } from './instrument'; +import { isValidLcpMetric } from './lcp'; import type { WebVitalReportEvent } from './utils'; import { getBrowserPerformanceAPI, listenForWebVitalReportEvents, msToSec, supportsWebVital } from './utils'; import type { PerformanceEventTiming } from './instrument'; @@ -121,7 +122,7 @@ export function trackLcpAsSpan(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; } lcpValue = metric.value; @@ -143,6 +144,10 @@ export function _sendLcpSpan( pageloadSpan?: Span, reportEvent?: WebVitalReportEvent, ): void { + if (!isValidLcpMetric(lcpValue)) { + return; + } + DEBUG_BUILD && debug.log(`Sending LCP span (${lcpValue})`); const performanceTimeOrigin = browserPerformanceTimeOrigin() || 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..634b9652f816 --- /dev/null +++ b/packages/browser-utils/test/metrics/lcp.test.ts @@ -0,0 +1,105 @@ +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(1)).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(0)).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(); + }); +}); diff --git a/packages/browser-utils/test/metrics/webVitalSpans.test.ts b/packages/browser-utils/test/metrics/webVitalSpans.test.ts index 733891370fda..8b9325895e85 100644 --- a/packages/browser-utils/test/metrics/webVitalSpans.test.ts +++ b/packages/browser-utils/test/metrics/webVitalSpans.test.ts @@ -1,6 +1,7 @@ import * as SentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as inpModule from '../../src/metrics/inp'; +import { MAX_PLAUSIBLE_LCP_DURATION } from '../../src/metrics/lcp'; import { _emitWebVitalSpan, _sendClsSpan, _sendInpSpan, _sendLcpSpan } from '../../src/metrics/webVitalSpans'; vi.mock('@sentry/core', async () => { @@ -262,7 +263,7 @@ describe('_sendLcpSpan', () => { }); it('sends a streamed LCP span without entry data', () => { - _sendLcpSpan(0, undefined); + _sendLcpSpan(250, undefined); expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ @@ -271,6 +272,13 @@ describe('_sendLcpSpan', () => { }), ); }); + + it('drops implausible LCP values', () => { + _sendLcpSpan(0, undefined); + _sendLcpSpan(MAX_PLAUSIBLE_LCP_DURATION + 1, undefined); + + expect(SentryCore.startInactiveSpan).not.toHaveBeenCalled(); + }); }); describe('_sendClsSpan', () => {