Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/browser-utils/src/metrics/browserMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down
15 changes: 14 additions & 1 deletion packages/browser-utils/src/metrics/lcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
logaretm marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* Starts tracking the Largest Contentful Paint on the current page and collects the value once
*
Expand All @@ -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;
Comment thread
logaretm marked this conversation as resolved.
Expand All @@ -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));
Expand Down
104 changes: 104 additions & 0 deletions packages/browser-utils/test/metrics/lcp.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<img>',
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': '<img>',
'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();
});
});
Loading