diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/test.ts index b5c47fdd3ab7..24c949c63afa 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/test.ts @@ -65,6 +65,7 @@ sentryTest('captures a "GOOD" CLS vital with its source as a standalone span', a 'sentry.exclusive_time': 0, 'sentry.op': 'ui.webvital.cls', 'sentry.origin': 'auto.http.browser.cls', + 'sentry.report_event': 'pagehide', transaction: expect.stringContaining('index.html'), 'user_agent.original': expect.stringContaining('Chrome'), 'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/), @@ -134,6 +135,7 @@ sentryTest('captures a "MEH" CLS vital with its source as a standalone span', as 'sentry.exclusive_time': 0, 'sentry.op': 'ui.webvital.cls', 'sentry.origin': 'auto.http.browser.cls', + 'sentry.report_event': 'pagehide', transaction: expect.stringContaining('index.html'), 'user_agent.original': expect.stringContaining('Chrome'), 'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/), @@ -201,6 +203,7 @@ sentryTest('captures a "POOR" CLS vital with its source as a standalone span.', 'sentry.exclusive_time': 0, 'sentry.op': 'ui.webvital.cls', 'sentry.origin': 'auto.http.browser.cls', + 'sentry.report_event': 'pagehide', transaction: expect.stringContaining('index.html'), 'user_agent.original': expect.stringContaining('Chrome'), 'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/), @@ -269,6 +272,7 @@ sentryTest( 'sentry.exclusive_time': 0, 'sentry.op': 'ui.webvital.cls', 'sentry.origin': 'auto.http.browser.cls', + 'sentry.report_event': 'pagehide', transaction: expect.stringContaining('index.html'), 'user_agent.original': expect.stringContaining('Chrome'), 'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/), @@ -342,6 +346,8 @@ sentryTest( // Ensure the CLS span is connected to the pageload span and trace expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toBe(pageloadSpanId); expect(spanEnvelopeItem.trace_id).toEqual(pageloadTraceId); + + expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('pagehide'); }, ); @@ -374,6 +380,8 @@ sentryTest('sends CLS of the initial page when soft-navigating to a new page', a expect(spanEnvelopeItem.measurements?.cls?.value).toBeLessThan(0.15); expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toBe(pageloadEventData.contexts?.trace?.span_id); expect(spanEnvelopeItem.trace_id).toEqual(pageloadTraceId); + + expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('navigation'); }); sentryTest("doesn't send further CLS after the first navigation", async ({ getLocalTestUrl, page }) => { @@ -398,6 +406,7 @@ sentryTest("doesn't send further CLS after the first navigation", async ({ getLo const spanEnvelope = (await spanEnvelopePromise)[0]; const spanEnvelopeItem = spanEnvelope[1][0][1]; expect(spanEnvelopeItem.measurements?.cls?.value).toBeGreaterThan(0); + expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('navigation'); getMultipleSentryEnvelopeRequests(page, 1, { envelopeType: 'span' }, () => { throw new Error('Unexpected span - This should not happen!'); @@ -442,6 +451,7 @@ sentryTest("doesn't send further CLS after the first page hide", async ({ getLoc const spanEnvelope = (await spanEnvelopePromise)[0]; const spanEnvelopeItem = spanEnvelope[1][0][1]; expect(spanEnvelopeItem.measurements?.cls?.value).toBeGreaterThan(0); + expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('pagehide'); getMultipleSentryEnvelopeRequests(page, 1, { envelopeType: 'span' }, () => { throw new Error('Unexpected span - This should not happen!'); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/test.ts index 3310c7b95004..ad21a6240793 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/test.ts @@ -64,6 +64,7 @@ sentryTest('captures LCP vital as a standalone span', async ({ getLocalTestUrl, 'sentry.exclusive_time': 0, 'sentry.op': 'ui.webvital.lcp', 'sentry.origin': 'auto.http.browser.lcp', + 'sentry.report_event': 'pagehide', transaction: expect.stringContaining('index.html'), 'user_agent.original': expect.stringContaining('Chrome'), 'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/), @@ -181,6 +182,7 @@ sentryTest('sends LCP of the initial page when soft-navigating to a new page', a expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0); expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toBe(pageloadEventData.contexts?.trace?.span_id); + expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('navigation'); expect(spanEnvelopeItem.trace_id).toBe(pageloadEventData.contexts?.trace?.trace_id); }); @@ -194,10 +196,10 @@ sentryTest("doesn't send further LCP after the first navigation", async ({ getLo const url = await getLocalTestUrl({ testDir: __dirname }); - const eventData = await getFirstSentryEnvelopeRequest(page, url); + const pageloadEventData = await getFirstSentryEnvelopeRequest(page, url); - expect(eventData.type).toBe('transaction'); - expect(eventData.contexts?.trace?.op).toBe('pageload'); + expect(pageloadEventData.type).toBe('transaction'); + expect(pageloadEventData.contexts?.trace?.op).toBe('pageload'); const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( page, @@ -214,6 +216,8 @@ sentryTest("doesn't send further LCP after the first navigation", async ({ getLo const spanEnvelope = (await spanEnvelopePromise)[0]; const spanEnvelopeItem = spanEnvelope[1][0][1]; expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0); + expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('navigation'); + expect(spanEnvelopeItem.trace_id).toBe(pageloadEventData.contexts?.trace?.trace_id); getMultipleSentryEnvelopeRequests(page, 1, { envelopeType: 'span' }, () => { throw new Error('Unexpected span - This should not happen!'); @@ -246,10 +250,10 @@ sentryTest("doesn't send further LCP after the first page hide", async ({ getLoc const url = await getLocalTestUrl({ testDir: __dirname }); - const eventData = await getFirstSentryEnvelopeRequest(page, url); + const pageloadEventData = await getFirstSentryEnvelopeRequest(page, url); - expect(eventData.type).toBe('transaction'); - expect(eventData.contexts?.trace?.op).toBe('pageload'); + expect(pageloadEventData.type).toBe('transaction'); + expect(pageloadEventData.contexts?.trace?.op).toBe('pageload'); const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( page, @@ -266,6 +270,8 @@ sentryTest("doesn't send further LCP after the first page hide", async ({ getLoc const spanEnvelope = (await spanEnvelopePromise)[0]; const spanEnvelopeItem = spanEnvelope[1][0][1]; expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0); + expect(spanEnvelopeItem.data?.['sentry.report_event']).toBe('pagehide'); + expect(spanEnvelopeItem.trace_id).toBe(pageloadEventData.contexts?.trace?.trace_id); getMultipleSentryEnvelopeRequests(page, 1, { envelopeType: 'span' }, () => { throw new Error('Unexpected span - This should not happen!'); diff --git a/packages/browser-utils/src/metrics/cls.ts b/packages/browser-utils/src/metrics/cls.ts index b564a0187818..8839f038fb49 100644 --- a/packages/browser-utils/src/metrics/cls.ts +++ b/packages/browser-utils/src/metrics/cls.ts @@ -1,10 +1,7 @@ import type { SpanAttributes } from '@sentry/core'; import { browserPerformanceTimeOrigin, - getActiveSpan, - getClient, getCurrentScope, - getRootSpan, htmlTreeAsString, logger, SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, @@ -12,12 +9,11 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - spanToJSON, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { addClsInstrumentationHandler } from './instrument'; -import { msToSec, startStandaloneWebVitalSpan } from './utils'; -import { onHidden } from './web-vitals/lib/onHidden'; +import type { WebVitalReportEvent } from './utils'; +import { listenForWebVitalReportEvents, msToSec, startStandaloneWebVitalSpan, supportsWebVital } from './utils'; /** * Starts tracking the Cumulative Layout Shift on the current page and collects the value once @@ -31,24 +27,11 @@ import { onHidden } from './web-vitals/lib/onHidden'; export function trackClsAsStandaloneSpan(): void { let standaloneCLsValue = 0; let standaloneClsEntry: LayoutShift | undefined; - let pageloadSpanId: string | undefined; - if (!supportsLayoutShift()) { + if (!supportsWebVital('layout-shift')) { return; } - let sentSpan = false; - function _collectClsOnce() { - if (sentSpan) { - return; - } - sentSpan = true; - if (pageloadSpanId) { - sendStandaloneClsSpan(standaloneCLsValue, standaloneClsEntry, pageloadSpanId); - } - cleanupClsHandler(); - } - const cleanupClsHandler = addClsInstrumentationHandler(({ metric }) => { const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined; if (!entry) { @@ -58,40 +41,18 @@ export function trackClsAsStandaloneSpan(): void { standaloneClsEntry = entry; }, true); - onHidden(() => { - _collectClsOnce(); + listenForWebVitalReportEvents((reportEvent, pageloadSpanId) => { + sendStandaloneClsSpan(standaloneCLsValue, standaloneClsEntry, pageloadSpanId, reportEvent); + cleanupClsHandler(); }); - - // Since the call chain of this function is synchronous and evaluates before the SDK client is created, - // we need to wait with subscribing to a client hook until the client is created. Therefore, we defer - // to the next tick after the SDK setup. - setTimeout(() => { - const client = getClient(); - - if (!client) { - return; - } - - const unsubscribeStartNavigation = client.on('beforeStartNavigationSpan', (_, options) => { - // we only want to collect LCP if we actually navigate. Redirects should be ignored. - if (!options?.isRedirect) { - _collectClsOnce(); - unsubscribeStartNavigation?.(); - } - }); - - const activeSpan = getActiveSpan(); - if (activeSpan) { - const rootSpan = getRootSpan(activeSpan); - const spanJSON = spanToJSON(rootSpan); - if (spanJSON.op === 'pageload') { - pageloadSpanId = rootSpan.spanContext().spanId; - } - } - }, 0); } -function sendStandaloneClsSpan(clsValue: number, entry: LayoutShift | undefined, pageloadSpanId: string) { +function sendStandaloneClsSpan( + clsValue: number, + entry: LayoutShift | undefined, + pageloadSpanId: string, + reportEvent: WebVitalReportEvent, +) { DEBUG_BUILD && logger.log(`Sending CLS span (${clsValue})`); const startTime = msToSec((browserPerformanceTimeOrigin() || 0) + (entry?.startTime || 0)); @@ -105,6 +66,8 @@ function sendStandaloneClsSpan(clsValue: number, entry: LayoutShift | undefined, [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry?.duration || 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 + 'sentry.report_event': reportEvent, }; // Add CLS sources as span attributes to help with debugging layout shifts @@ -133,11 +96,3 @@ function sendStandaloneClsSpan(clsValue: number, entry: LayoutShift | undefined, span.end(startTime); } } - -function supportsLayoutShift(): boolean { - try { - return PerformanceObserver.supportedEntryTypes.includes('layout-shift'); - } catch { - return false; - } -} diff --git a/packages/browser-utils/src/metrics/lcp.ts b/packages/browser-utils/src/metrics/lcp.ts index e34391a4a1d7..dc98b2f8f2b1 100644 --- a/packages/browser-utils/src/metrics/lcp.ts +++ b/packages/browser-utils/src/metrics/lcp.ts @@ -1,10 +1,7 @@ import type { SpanAttributes } from '@sentry/core'; import { browserPerformanceTimeOrigin, - getActiveSpan, - getClient, getCurrentScope, - getRootSpan, htmlTreeAsString, logger, SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, @@ -12,12 +9,11 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - spanToJSON, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { addLcpInstrumentationHandler } from './instrument'; -import { msToSec, startStandaloneWebVitalSpan } from './utils'; -import { onHidden } from './web-vitals/lib/onHidden'; +import type { WebVitalReportEvent } from './utils'; +import { listenForWebVitalReportEvents, msToSec, startStandaloneWebVitalSpan, supportsWebVital } from './utils'; /** * Starts tracking the Largest Contentful Paint on the current page and collects the value once @@ -31,24 +27,11 @@ import { onHidden } from './web-vitals/lib/onHidden'; export function trackLcpAsStandaloneSpan(): void { let standaloneLcpValue = 0; let standaloneLcpEntry: LargestContentfulPaint | undefined; - let pageloadSpanId: string | undefined; - if (!supportsLargestContentfulPaint()) { + if (!supportsWebVital('largest-contentful-paint')) { return; } - let sentSpan = false; - function _collectLcpOnce() { - if (sentSpan) { - return; - } - sentSpan = true; - if (pageloadSpanId) { - _sendStandaloneLcpSpan(standaloneLcpValue, standaloneLcpEntry, pageloadSpanId); - } - cleanupLcpHandler(); - } - const cleanupLcpHandler = addLcpInstrumentationHandler(({ metric }) => { const entry = metric.entries[metric.entries.length - 1] as LargestContentfulPaint | undefined; if (!entry) { @@ -58,37 +41,10 @@ export function trackLcpAsStandaloneSpan(): void { standaloneLcpEntry = entry; }, true); - onHidden(() => { - _collectLcpOnce(); + listenForWebVitalReportEvents((reportEvent, pageloadSpanId) => { + _sendStandaloneLcpSpan(standaloneLcpValue, standaloneLcpEntry, pageloadSpanId, reportEvent); + cleanupLcpHandler(); }); - - // Since the call chain of this function is synchronous and evaluates before the SDK client is created, - // we need to wait with subscribing to a client hook until the client is created. Therefore, we defer - // to the next tick after the SDK setup. - setTimeout(() => { - const client = getClient(); - - if (!client) { - return; - } - - const unsubscribeStartNavigation = client.on('beforeStartNavigationSpan', (_, options) => { - // we only want to collect LCP if we actually navigate. Redirects should be ignored. - if (!options?.isRedirect) { - _collectLcpOnce(); - unsubscribeStartNavigation?.(); - } - }); - - const activeSpan = getActiveSpan(); - if (activeSpan) { - const rootSpan = getRootSpan(activeSpan); - const spanJSON = spanToJSON(rootSpan); - if (spanJSON.op === 'pageload') { - pageloadSpanId = rootSpan.spanContext().spanId; - } - } - }, 0); } /** @@ -98,6 +54,7 @@ export function _sendStandaloneLcpSpan( lcpValue: number, entry: LargestContentfulPaint | undefined, pageloadSpanId: string, + reportEvent: WebVitalReportEvent, ) { DEBUG_BUILD && logger.log(`Sending LCP span (${lcpValue})`); @@ -112,6 +69,8 @@ export function _sendStandaloneLcpSpan( [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0, // LCP is a point-in-time metric // attach the pageload span id to the LCP span so that we can link them in the UI 'sentry.pageload.span_id': pageloadSpanId, + // describes what triggered the web vital to be reported + 'sentry.report_event': reportEvent, }; if (entry) { @@ -149,11 +108,3 @@ export function _sendStandaloneLcpSpan( span.end(startTime); } } - -function supportsLargestContentfulPaint(): boolean { - try { - return PerformanceObserver.supportedEntryTypes.includes('largest-contentful-paint'); - } catch { - return false; - } -} diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts index aef40d4cf613..ce3da0d4f16d 100644 --- a/packages/browser-utils/src/metrics/utils.ts +++ b/packages/browser-utils/src/metrics/utils.ts @@ -1,6 +1,17 @@ import type { Integration, SentrySpan, Span, SpanAttributes, SpanTimeInput, StartSpanOptions } from '@sentry/core'; -import { getClient, getCurrentScope, spanToJSON, startInactiveSpan, withActiveSpan } from '@sentry/core'; +import { + getActiveSpan, + getClient, + getCurrentScope, + getRootSpan, + spanToJSON, + startInactiveSpan, + withActiveSpan, +} from '@sentry/core'; import { WINDOW } from '../types'; +import { onHidden } from './web-vitals/lib/onHidden'; + +export type WebVitalReportEvent = 'pagehide' | 'navigation'; /** * Checks if a given value is a valid measurement value. @@ -168,3 +179,71 @@ export function extractNetworkProtocol(nextHopProtocol: string): { name: string; } return { name, version }; } + +/** + * Generic support check for web vitals + */ +export function supportsWebVital(entryType: 'layout-shift' | 'largest-contentful-paint'): boolean { + try { + return PerformanceObserver.supportedEntryTypes.includes(entryType); + } catch { + return false; + } +} + +/** + * Listens for events on which we want to collect a previously accumulated web vital value. + * Currently, this includes: + * + * - pagehide (i.e. user minimizes browser window, hides tab, etc) + * - soft navigation (we only care about the vital of the initially loaded route) + * + * As a "side-effect", this function will also collect the span id of the pageload span. + * + * @param collectorCallback the callback to be called when the first of these events is triggered. Parameters: + * - event: the event that triggered the reporting of the web vital value. + * - pageloadSpanId: the span id of the pageload span. This is used to link the web vital span to the pageload span. + */ +export function listenForWebVitalReportEvents( + collectorCallback: (event: WebVitalReportEvent, pageloadSpanId: string) => void, +) { + let pageloadSpanId: string | undefined; + + let collected = false; + function _runCollectorCallbackOnce(event: WebVitalReportEvent) { + if (!collected && pageloadSpanId) { + collectorCallback(event, pageloadSpanId); + } + collected = true; + } + + onHidden(() => { + if (!collected) { + _runCollectorCallbackOnce('pagehide'); + } + }); + + setTimeout(() => { + const client = getClient(); + if (!client) { + return; + } + + const unsubscribeStartNavigation = client.on('beforeStartNavigationSpan', (_, options) => { + // we only want to collect LCP if we actually navigate. Redirects should be ignored. + if (!options?.isRedirect) { + _runCollectorCallbackOnce('navigation'); + unsubscribeStartNavigation?.(); + } + }); + + const activeSpan = getActiveSpan(); + if (activeSpan) { + const rootSpan = getRootSpan(activeSpan); + const spanJSON = spanToJSON(rootSpan); + if (spanJSON.op === 'pageload') { + pageloadSpanId = rootSpan.spanContext().spanId; + } + } + }, 0); +}