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', () => {