diff --git a/packages/browser-utils/src/metrics/cls.ts b/packages/browser-utils/src/metrics/cls.ts index 99f76c610226..d836ff315c06 100644 --- a/packages/browser-utils/src/metrics/cls.ts +++ b/packages/browser-utils/src/metrics/cls.ts @@ -9,6 +9,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + timestampInSeconds, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { addClsInstrumentationHandler } from './instrument'; @@ -42,12 +43,15 @@ export function trackClsAsStandaloneSpan(client: Client): void { }, true); listenForWebVitalReportEvents(client, (reportEvent, pageloadSpanId) => { - sendStandaloneClsSpan(standaloneCLsValue, standaloneClsEntry, pageloadSpanId, reportEvent); + _sendStandaloneClsSpan(standaloneCLsValue, standaloneClsEntry, pageloadSpanId, reportEvent); cleanupClsHandler(); }); } -function sendStandaloneClsSpan( +/** + * Exported only for testing! + */ +export function _sendStandaloneClsSpan( clsValue: number, entry: LayoutShift | undefined, pageloadSpanId: string, @@ -55,7 +59,7 @@ function sendStandaloneClsSpan( ) { DEBUG_BUILD && debug.log(`Sending CLS span (${clsValue})`); - const startTime = msToSec((browserPerformanceTimeOrigin() || 0) + (entry?.startTime || 0)); + const startTime = entry ? msToSec((browserPerformanceTimeOrigin() || 0) + entry.startTime) : timestampInSeconds(); const routeName = getCurrentScope().getScopeData().transactionName; const name = entry ? htmlTreeAsString(entry.sources[0]?.node) : 'Layout shift'; @@ -63,7 +67,7 @@ function sendStandaloneClsSpan( const attributes: SpanAttributes = { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser.cls', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.webvital.cls', - [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry?.duration || 0, + [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0, // attach the pageload span id to the CLS span so that we can link them in the UI 'sentry.pageload.span_id': pageloadSpanId, // describes what triggered the web vital to be reported diff --git a/packages/browser-utils/test/metrics/cls.test.ts b/packages/browser-utils/test/metrics/cls.test.ts new file mode 100644 index 000000000000..55550d02f546 --- /dev/null +++ b/packages/browser-utils/test/metrics/cls.test.ts @@ -0,0 +1,231 @@ +import * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { _sendStandaloneClsSpan } from '../../src/metrics/cls'; +import * as WebVitalUtils from '../../src/metrics/utils'; + +// Mock all Sentry core dependencies +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + browserPerformanceTimeOrigin: vi.fn(), + timestampInSeconds: vi.fn(), + getCurrentScope: vi.fn(), + htmlTreeAsString: vi.fn(), + }; +}); + +describe('_sendStandaloneClsSpan', () => { + const mockSpan = { + addEvent: vi.fn(), + end: vi.fn(), + }; + + const mockScope = { + getScopeData: vi.fn().mockReturnValue({ + transactionName: 'test-transaction', + }), + }; + + afterEach(() => { + vi.clearAllMocks(); + }); + + beforeEach(() => { + vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); + vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); + vi.mocked(SentryCore.timestampInSeconds).mockReturnValue(1.5); + vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); + vi.spyOn(WebVitalUtils, 'startStandaloneWebVitalSpan').mockReturnValue(mockSpan as any); + }); + + it('sends a standalone CLS span with entry data', () => { + const clsValue = 0.1; + const mockEntry: LayoutShift = { + name: 'layout-shift', + entryType: 'layout-shift', + startTime: 100, + duration: 0, + value: clsValue, + hadRecentInput: false, + sources: [ + // @ts-expect-error - other properties are irrelevant + { + node: { tagName: 'div' } as Element, + }, + ], + toJSON: vi.fn(), + }; + const pageloadSpanId = '123'; + const reportEvent = 'navigation'; + + _sendStandaloneClsSpan(clsValue, mockEntry, pageloadSpanId, reportEvent); + + expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith({ + name: '
', + transaction: 'test-transaction', + attributes: { + 'sentry.origin': 'auto.http.browser.cls', + 'sentry.op': 'ui.webvital.cls', + 'sentry.exclusive_time': 0, + 'sentry.pageload.span_id': '123', + 'sentry.report_event': 'navigation', + 'cls.source.1': '
', + }, + startTime: 1.1, // (1000 + 100) / 1000 + }); + + expect(mockSpan.addEvent).toHaveBeenCalledWith('cls', { + 'sentry.measurement_unit': '', + 'sentry.measurement_value': 0.1, + }); + + expect(mockSpan.end).toHaveBeenCalledWith(1.1); + }); + + it('sends a standalone CLS span without entry data', () => { + const clsValue = 0; + const pageloadSpanId = '456'; + const reportEvent = 'pagehide'; + + _sendStandaloneClsSpan(clsValue, undefined, pageloadSpanId, reportEvent); + + expect(SentryCore.timestampInSeconds).toHaveBeenCalled(); + expect(SentryCore.browserPerformanceTimeOrigin).not.toHaveBeenCalled(); + + expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith({ + name: 'Layout shift', + transaction: 'test-transaction', + attributes: { + 'sentry.origin': 'auto.http.browser.cls', + 'sentry.op': 'ui.webvital.cls', + 'sentry.exclusive_time': 0, + 'sentry.pageload.span_id': pageloadSpanId, + 'sentry.report_event': 'pagehide', + }, + startTime: 1.5, + }); + + expect(mockSpan.end).toHaveBeenCalledWith(1.5); + expect(mockSpan.addEvent).toHaveBeenCalledWith('cls', { + 'sentry.measurement_unit': '', + 'sentry.measurement_value': 0, + }); + }); + + it('handles entry with multiple sources', () => { + const clsValue = 0.15; + const mockEntry: LayoutShift = { + name: 'layout-shift', + entryType: 'layout-shift', + startTime: 200, + duration: 0, + value: clsValue, + hadRecentInput: false, + sources: [ + // @ts-expect-error - other properties are irrelevant + { + node: { tagName: 'div' } as Element, + }, + // @ts-expect-error - other properties are irrelevant + { + node: { tagName: 'span' } as Element, + }, + ], + toJSON: vi.fn(), + }; + const pageloadSpanId = '789'; + + vi.mocked(SentryCore.htmlTreeAsString) + .mockReturnValueOnce('
') // for the name + .mockReturnValueOnce('
') // for source 1 + .mockReturnValueOnce(''); // for source 2 + + _sendStandaloneClsSpan(clsValue, mockEntry, pageloadSpanId, 'navigation'); + + expect(SentryCore.htmlTreeAsString).toHaveBeenCalledTimes(3); + expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith({ + name: '
', + transaction: 'test-transaction', + attributes: { + 'sentry.origin': 'auto.http.browser.cls', + 'sentry.op': 'ui.webvital.cls', + 'sentry.exclusive_time': 0, + 'sentry.pageload.span_id': '789', + 'sentry.report_event': 'navigation', + 'cls.source.1': '
', + 'cls.source.2': '', + }, + startTime: 1.2, // (1000 + 200) / 1000 + }); + }); + + it('handles entry without sources', () => { + const clsValue = 0.05; + const mockEntry: LayoutShift = { + name: 'layout-shift', + entryType: 'layout-shift', + startTime: 50, + duration: 0, + value: clsValue, + hadRecentInput: false, + sources: [], + toJSON: vi.fn(), + }; + const pageloadSpanId = '101'; + + _sendStandaloneClsSpan(clsValue, mockEntry, pageloadSpanId, 'navigation'); + + expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith({ + name: '
', + transaction: 'test-transaction', + attributes: { + 'sentry.origin': 'auto.http.browser.cls', + 'sentry.op': 'ui.webvital.cls', + 'sentry.exclusive_time': 0, + 'sentry.pageload.span_id': '101', + 'sentry.report_event': 'navigation', + }, + startTime: 1.05, // (1000 + 50) / 1000 + }); + }); + + it('handles when startStandaloneWebVitalSpan returns undefined', () => { + vi.spyOn(WebVitalUtils, 'startStandaloneWebVitalSpan').mockReturnValue(undefined); + + const clsValue = 0.1; + const pageloadSpanId = '123'; + + expect(() => { + _sendStandaloneClsSpan(clsValue, undefined, pageloadSpanId, 'navigation'); + }).not.toThrow(); + + expect(mockSpan.addEvent).not.toHaveBeenCalled(); + expect(mockSpan.end).not.toHaveBeenCalled(); + }); + + it('handles when browserPerformanceTimeOrigin returns null', () => { + vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(undefined); + + const clsValue = 0.1; + const mockEntry: LayoutShift = { + name: 'layout-shift', + entryType: 'layout-shift', + startTime: 200, + duration: 0, + value: clsValue, + hadRecentInput: false, + sources: [], + toJSON: vi.fn(), + }; + const pageloadSpanId = '123'; + + _sendStandaloneClsSpan(clsValue, mockEntry, pageloadSpanId, 'navigation'); + + expect(WebVitalUtils.startStandaloneWebVitalSpan).toHaveBeenCalledWith( + expect.objectContaining({ + startTime: 0.2, + }), + ); + }); +});