diff --git a/.size-limit.js b/.size-limit.js index 80aa4c5095ea..859ce741cc3d 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -55,7 +55,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'), gzip: true, - limit: '90 KB', + limit: '91 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback, metrics)', @@ -143,7 +143,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '37 KB', + limit: '38 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay)', @@ -170,7 +170,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '110 KB', + limit: '111 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', @@ -193,7 +193,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '38.05 KB', + limit: '39 KB', }, // SvelteKit SDK (ESM) { diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/init.js new file mode 100644 index 000000000000..32fbb07fbbae --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/init.js @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 9000, + _experiments: { + enableStandaloneClsSpans: true, + }, + }), + ], + tracesSampleRate: 1, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/subject.js new file mode 100644 index 000000000000..ed1b9b790bb9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/subject.js @@ -0,0 +1,17 @@ +import { simulateCLS } from '../../../../utils/web-vitals/cls.ts'; + +// Simulate Layout shift right at the beginning of the page load, depending on the URL hash +// don't run if expected CLS is NaN +const expectedCLS = Number(location.hash.slice(1)); +if (expectedCLS && expectedCLS >= 0) { + simulateCLS(expectedCLS).then(() => window.dispatchEvent(new Event('cls-done'))); +} + +// Simulate layout shift whenever the trigger-cls event is dispatched +// Cannot trigger cia a button click because expected layout shift after +// an interaction doesn't contribute to CLS. +window.addEventListener('trigger-cls', () => { + simulateCLS(0.1).then(() => { + window.dispatchEvent(new Event('cls-done')); + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/template.html new file mode 100644 index 000000000000..487683893a7f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/template.html @@ -0,0 +1,12 @@ + + + + + + +
+

+ Some content +

+ + 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 new file mode 100644 index 000000000000..cdf1e6837ef4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-standalone-spans/test.ts @@ -0,0 +1,455 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; +import type { Event as SentryEvent, EventEnvelope, SpanEnvelope } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { + getFirstSentryEnvelopeRequest, + getMultipleSentryEnvelopeRequests, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, +} from '../../../../utils/helpers'; + +sentryTest.beforeEach(async ({ browserName, page }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + await page.setViewportSize({ width: 800, height: 1200 }); +}); + +function waitForLayoutShift(page: Page): Promise { + return page.evaluate(() => { + return new Promise(resolve => { + window.addEventListener('cls-done', () => resolve()); + }); + }); +} + +function triggerAndWaitForLayoutShift(page: Page): Promise { + return page.evaluate(() => { + window.dispatchEvent(new CustomEvent('trigger-cls')); + return new Promise(resolve => { + window.addEventListener('cls-done', () => resolve()); + }); + }); +} + +function hidePage(page: Page): Promise { + return page.evaluate(() => { + window.dispatchEvent(new Event('pagehide')); + }); +} + +sentryTest('captures a "GOOD" CLS vital with its source as a standalone span', async ({ getLocalTestPath, page }) => { + const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'span' }, + properFullEnvelopeRequestParser, + ); + + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(`${url}#0.05`); + + await waitForLayoutShift(page); + + await hidePage(page); + + const spanEnvelope = (await spanEnvelopePromise)[0]; + + const spanEnvelopeHeaders = spanEnvelope[0]; + const spanEnvelopeItem = spanEnvelope[1][0][1]; + + expect(spanEnvelopeItem).toEqual({ + data: { + 'sentry.exclusive_time': 0, + 'sentry.op': 'ui.webvital.cls', + 'sentry.origin': 'auto.http.browser.cls', + transaction: expect.stringContaining('index.html'), + 'user_agent.original': expect.stringContaining('Chrome'), + 'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/), + }, + description: expect.stringContaining('body > div#content > p'), + exclusive_time: 0, + measurements: { + cls: { + unit: '', + value: expect.any(Number), // better check below, + }, + }, + op: 'ui.webvital.cls', + origin: 'auto.http.browser.cls', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + segment_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: spanEnvelopeItem.start_timestamp, + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + // Flakey value dependent on timings -> we check for a range + expect(spanEnvelopeItem.measurements?.cls?.value).toBeGreaterThan(0.03); + expect(spanEnvelopeItem.measurements?.cls?.value).toBeLessThan(0.07); + + expect(spanEnvelopeHeaders).toEqual({ + sent_at: expect.any(String), + trace: { + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: spanEnvelopeItem.trace_id, + // no transaction, because span source is URL + }, + }); +}); + +sentryTest('captures a "MEH" CLS vital with its source as a standalone span', async ({ getLocalTestPath, page }) => { + const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'span' }, + properFullEnvelopeRequestParser, + ); + + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(`${url}#0.21`); + + await waitForLayoutShift(page); + + // Page hide to trigger CLS emission + await page.evaluate(() => { + window.dispatchEvent(new Event('pagehide')); + }); + + const spanEnvelope = (await spanEnvelopePromise)[0]; + + const spanEnvelopeHeaders = spanEnvelope[0]; + const spanEnvelopeItem = spanEnvelope[1][0][1]; + + expect(spanEnvelopeItem).toEqual({ + data: { + 'sentry.exclusive_time': 0, + 'sentry.op': 'ui.webvital.cls', + 'sentry.origin': 'auto.http.browser.cls', + transaction: expect.stringContaining('index.html'), + 'user_agent.original': expect.stringContaining('Chrome'), + 'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/), + }, + description: expect.stringContaining('body > div#content > p'), + exclusive_time: 0, + measurements: { + cls: { + unit: '', + value: expect.any(Number), // better check below, + }, + }, + op: 'ui.webvital.cls', + origin: 'auto.http.browser.cls', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + segment_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: spanEnvelopeItem.start_timestamp, + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + // Flakey value dependent on timings -> we check for a range + expect(spanEnvelopeItem.measurements?.cls?.value).toBeGreaterThan(0.18); + expect(spanEnvelopeItem.measurements?.cls?.value).toBeLessThan(0.23); + + expect(spanEnvelopeHeaders).toEqual({ + sent_at: expect.any(String), + trace: { + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: spanEnvelopeItem.trace_id, + // no transaction, because span source is URL + }, + }); +}); + +sentryTest('captures a "POOR" CLS vital with its source as a standalone span.', async ({ getLocalTestPath, page }) => { + const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'span' }, + properFullEnvelopeRequestParser, + ); + + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(`${url}#0.35`); + + await waitForLayoutShift(page); + + // Page hide to trigger CLS emission + await hidePage(page); + + const spanEnvelope = (await spanEnvelopePromise)[0]; + + const spanEnvelopeHeaders = spanEnvelope[0]; + const spanEnvelopeItem = spanEnvelope[1][0][1]; + + expect(spanEnvelopeItem).toEqual({ + data: { + 'sentry.exclusive_time': 0, + 'sentry.op': 'ui.webvital.cls', + 'sentry.origin': 'auto.http.browser.cls', + transaction: expect.stringContaining('index.html'), + 'user_agent.original': expect.stringContaining('Chrome'), + 'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/), + }, + description: expect.stringContaining('body > div#content > p'), + exclusive_time: 0, + measurements: { + cls: { + unit: '', + value: expect.any(Number), // better check below, + }, + }, + op: 'ui.webvital.cls', + origin: 'auto.http.browser.cls', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + segment_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: spanEnvelopeItem.start_timestamp, + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + // Flakey value dependent on timings -> we check for a range + expect(spanEnvelopeItem.measurements?.cls?.value).toBeGreaterThan(0.33); + expect(spanEnvelopeItem.measurements?.cls?.value).toBeLessThan(0.38); + + expect(spanEnvelopeHeaders).toEqual({ + sent_at: expect.any(String), + trace: { + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: spanEnvelopeItem.trace_id, + // no transaction, because span source is URL + }, + }); +}); + +sentryTest( + 'captures a 0 CLS vital as a standalone span if no layout shift occurred', + async ({ getLocalTestPath, page }) => { + const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'span' }, + properFullEnvelopeRequestParser, + ); + + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); + + await page.waitForTimeout(1000); + + await hidePage(page); + + const spanEnvelope = (await spanEnvelopePromise)[0]; + + const spanEnvelopeHeaders = spanEnvelope[0]; + const spanEnvelopeItem = spanEnvelope[1][0][1]; + + expect(spanEnvelopeItem).toEqual({ + data: { + 'sentry.exclusive_time': 0, + 'sentry.op': 'ui.webvital.cls', + 'sentry.origin': 'auto.http.browser.cls', + transaction: expect.stringContaining('index.html'), + 'user_agent.original': expect.stringContaining('Chrome'), + 'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/), + }, + description: 'Layout shift', + exclusive_time: 0, + measurements: { + cls: { + unit: '', + value: 0, + }, + }, + op: 'ui.webvital.cls', + origin: 'auto.http.browser.cls', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + segment_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: spanEnvelopeItem.start_timestamp, + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(spanEnvelopeHeaders).toEqual({ + sent_at: expect.any(String), + trace: { + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: spanEnvelopeItem.trace_id, + // no transaction, because span source is URL + }, + }); + }, +); + +sentryTest( + 'captures CLS increases after the pageload span ended, when page is hidden', + async ({ getLocalTestPath, page }) => { + const url = await getLocalTestPath({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.type).toBe('transaction'); + expect(eventData.contexts?.trace?.op).toBe('pageload'); + + const pageloadSpanId = eventData.contexts?.trace?.span_id; + const pageloadTraceId = eventData.contexts?.trace?.trace_id; + + expect(pageloadSpanId).toMatch(/[a-f0-9]{16}/); + expect(pageloadTraceId).toMatch(/[a-f0-9]{32}/); + + const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'span' }, + properFullEnvelopeRequestParser, + ); + + await triggerAndWaitForLayoutShift(page); + + await hidePage(page); + + const spanEnvelope = (await spanEnvelopePromise)[0]; + const spanEnvelopeItem = spanEnvelope[1][0][1]; + // Flakey value dependent on timings -> we check for a range + expect(spanEnvelopeItem.measurements?.cls?.value).toBeGreaterThan(0.05); + expect(spanEnvelopeItem.measurements?.cls?.value).toBeLessThan(0.15); + + // 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); + }, +); + +sentryTest('sends CLS of the initial page when soft-navigating to a new page', async ({ getLocalTestPath, page }) => { + const url = await getLocalTestPath({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.type).toBe('transaction'); + expect(eventData.contexts?.trace?.op).toBe('pageload'); + + const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'span' }, + properFullEnvelopeRequestParser, + ); + + await triggerAndWaitForLayoutShift(page); + + await page.goto(`${url}#soft-navigation`); + + const spanEnvelope = (await spanEnvelopePromise)[0]; + const spanEnvelopeItem = spanEnvelope[1][0][1]; + // Flakey value dependent on timings -> we check for a range + expect(spanEnvelopeItem.measurements?.cls?.value).toBeGreaterThan(0.05); + expect(spanEnvelopeItem.measurements?.cls?.value).toBeLessThan(0.15); + expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toMatch(/[a-f0-9]{16}/); +}); + +sentryTest("doesn't send further CLS after the first navigation", async ({ getLocalTestPath, page }) => { + const url = await getLocalTestPath({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.type).toBe('transaction'); + expect(eventData.contexts?.trace?.op).toBe('pageload'); + + const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'span' }, + properFullEnvelopeRequestParser, + ); + + await triggerAndWaitForLayoutShift(page); + + await page.goto(`${url}#soft-navigation`); + + const spanEnvelope = (await spanEnvelopePromise)[0]; + const spanEnvelopeItem = spanEnvelope[1][0][1]; + expect(spanEnvelopeItem.measurements?.cls?.value).toBeGreaterThan(0); + + getMultipleSentryEnvelopeRequests(page, 1, { envelopeType: 'span' }, () => { + throw new Error('Unexpected span - This should not happen!'); + }); + + const navigationTxnPromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'transaction' }, + properFullEnvelopeRequestParser, + ); + + // activate both CLS emission triggers: + await page.goto(`${url}#soft-navigation-2`); + await hidePage(page); + + // assumption: If we would send another CLS span on the 2nd navigation, it would be sent before the navigation + // transaction ends. This isn't 100% safe to ensure we don't send something but otherwise we'd need to wait for + // a timeout or something similar. + await navigationTxnPromise; +}); + +sentryTest("doesn't send further CLS after the first page hide", async ({ getLocalTestPath, page }) => { + const url = await getLocalTestPath({ testDir: __dirname }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.type).toBe('transaction'); + expect(eventData.contexts?.trace?.op).toBe('pageload'); + + const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'span' }, + properFullEnvelopeRequestParser, + ); + + await triggerAndWaitForLayoutShift(page); + + await hidePage(page); + + const spanEnvelope = (await spanEnvelopePromise)[0]; + const spanEnvelopeItem = spanEnvelope[1][0][1]; + expect(spanEnvelopeItem.measurements?.cls?.value).toBeGreaterThan(0); + + getMultipleSentryEnvelopeRequests(page, 1, { envelopeType: 'span' }, () => { + throw new Error('Unexpected span - This should not happen!'); + }); + + const navigationTxnPromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'transaction' }, + properFullEnvelopeRequestParser, + ); + + // activate both CLS emission triggers: + await page.goto(`${url}#soft-navigation-2`); + await hidePage(page); + + // assumption: If we would send another CLS span on the 2nd navigation, it would be sent before the navigation + // transaction ends. This isn't 100% safe to ensure we don't send something but otherwise we'd need to wait for + // a timeout or something similar. + await navigationTxnPromise; +}); diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 43ea45dd4a08..b71f80df1ff2 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -7,6 +7,7 @@ import { browserPerformanceTimeOrigin, getComponentName, htmlTreeAsString, logge import { spanToJSON } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../types'; +import { trackClsAsStandaloneSpan } from './cls'; import { type PerformanceLongAnimationFrameTiming, addClsInstrumentationHandler, @@ -65,29 +66,33 @@ let _measurements: Measurements = {}; let _lcpEntry: LargestContentfulPaint | undefined; let _clsEntry: LayoutShift | undefined; +interface StartTrackingWebVitalsOptions { + recordClsStandaloneSpans: boolean; +} + /** * Start tracking web vitals. * The callback returned by this function can be used to stop tracking & ensure all measurements are final & captured. * * @returns A function that forces web vitals collection */ -export function startTrackingWebVitals(): () => void { +export function startTrackingWebVitals({ recordClsStandaloneSpans }: StartTrackingWebVitalsOptions): () => void { const performance = getBrowserPerformanceAPI(); if (performance && browserPerformanceTimeOrigin) { // @ts-expect-error we want to make sure all of these are available, even if TS is sure they are if (performance.mark) { WINDOW.performance.mark('sentry-tracing-init'); } - const fidCallback = _trackFID(); - const clsCallback = _trackCLS(); - const lcpCallback = _trackLCP(); - const ttfbCallback = _trackTtfb(); + const fidCleanupCallback = _trackFID(); + const lcpCleanupCallback = _trackLCP(); + const ttfbCleanupCallback = _trackTtfb(); + const clsCleanupCallback = recordClsStandaloneSpans ? trackClsAsStandaloneSpan() : _trackCLS(); return (): void => { - fidCallback(); - clsCallback(); - lcpCallback(); - ttfbCallback(); + fidCleanupCallback(); + lcpCleanupCallback(); + ttfbCleanupCallback(); + clsCleanupCallback && clsCleanupCallback(); }; } @@ -211,17 +216,19 @@ export function startTrackingInteractions(): void { export { startTrackingINP, registerInpInteractionListener } from './inp'; -/** Starts tracking the Cumulative Layout Shift on the current page. */ +/** + * Starts tracking the Cumulative Layout Shift on the current page and collects the value and last entry + * to the `_measurements` object which ultimately is applied to the pageload span's measurements. + */ function _trackCLS(): () => void { return addClsInstrumentationHandler(({ metric }) => { - const entry = metric.entries[metric.entries.length - 1]; + const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined; if (!entry) { return; } - - DEBUG_BUILD && logger.log('[Measurements] Adding CLS'); + DEBUG_BUILD && logger.log(`[Measurements] Adding CLS ${metric.value}`); _measurements['cls'] = { value: metric.value, unit: '' }; - _clsEntry = entry as LayoutShift; + _clsEntry = entry; }, true); } @@ -267,8 +274,16 @@ function _trackTtfb(): () => void { }); } +interface AddPerformanceEntriesOptions { + /** + * Flag to determine if CLS should be recorded as a measurement on the span or + * sent as a standalone span instead. + */ + recordClsOnPageloadSpan: boolean; +} + /** Add performance related spans to a transaction */ -export function addPerformanceEntries(span: Span): void { +export function addPerformanceEntries(span: Span, options: AddPerformanceEntriesOptions): void { const performance = getBrowserPerformanceAPI(); if (!performance || !WINDOW.performance.getEntries || !browserPerformanceTimeOrigin) { // Gatekeeper if performance API not available @@ -286,7 +301,7 @@ export function addPerformanceEntries(span: Span): void { performanceEntries.slice(_performanceCursor).forEach((entry: Record) => { const startTime = msToSec(entry.startTime); const duration = msToSec( - // Inexplicibly, Chrome sometimes emits a negative duration. We need to work around this. + // Inexplicably, Chrome sometimes emits a negative duration. We need to work around this. // There is a SO post attempting to explain this, but it leaves one with open questions: https://stackoverflow.com/questions/23191918/peformance-getentries-and-negative-duration-display // The way we clamp the value is probably not accurate, since we have observed this happen for things that may take a while to load, like for example the replay worker. // TODO: Investigate why this happens and how to properly mitigate. For now, this is a workaround to prevent transactions being dropped due to negative duration spans. @@ -375,7 +390,8 @@ export function addPerformanceEntries(span: Span): void { // If FCP is not recorded we should not record the cls value // according to the new definition of CLS. - if (!('fcp' in _measurements)) { + // TODO: Check if the first condition is still necessary: `onCLS` already only fires once `onFCP` was called. + if (!('fcp' in _measurements) || !options.recordClsOnPageloadSpan) { delete _measurements.cls; } diff --git a/packages/browser-utils/src/metrics/cls.ts b/packages/browser-utils/src/metrics/cls.ts new file mode 100644 index 000000000000..aa25a54754a1 --- /dev/null +++ b/packages/browser-utils/src/metrics/cls.ts @@ -0,0 +1,122 @@ +import { + SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, + SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT, + SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + getActiveSpan, + getClient, + getCurrentScope, + getRootSpan, + spanToJSON, +} from '@sentry/core'; +import type { SpanAttributes } from '@sentry/types'; +import { browserPerformanceTimeOrigin, dropUndefinedKeys, htmlTreeAsString, logger } from '@sentry/utils'; +import { DEBUG_BUILD } from '../debug-build'; +import { addClsInstrumentationHandler } from './instrument'; +import { msToSec, startStandaloneWebVitalSpan } from './utils'; +import { onHidden } from './web-vitals/lib/onHidden'; + +/** + * Starts tracking the Cumulative Layout Shift on the current page and collects the value once + * + * - the page visibility is hidden + * - a navigation span is started (to stop CLS measurement for SPA soft navigations) + * + * Once either of these events triggers, the CLS value is sent as a standalone span and we stop + * measuring CLS. + */ +export function trackClsAsStandaloneSpan(): void { + let standaloneCLsValue = 0; + let standaloneClsEntry: LayoutShift | undefined; + let pageloadSpanId: string | undefined; + + if (!supportsLayoutShift()) { + 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) { + return; + } + standaloneCLsValue = metric.value; + standaloneClsEntry = entry; + }, true); + + // use pagehide event from web-vitals + onHidden(() => { + _collectClsOnce(); + }); + + // 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(); + + const unsubscribeStartNavigation = client?.on('startNavigationSpan', () => { + _collectClsOnce(); + unsubscribeStartNavigation && unsubscribeStartNavigation(); + }); + + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan && getRootSpan(activeSpan); + const spanJSON = rootSpan && spanToJSON(rootSpan); + if (spanJSON && spanJSON.op === 'pageload') { + pageloadSpanId = rootSpan.spanContext().spanId; + } + }, 0); +} + +function sendStandaloneClsSpan(clsValue: number, entry: LayoutShift | undefined, pageloadSpanId: string) { + DEBUG_BUILD && logger.log(`Sending CLS span (${clsValue})`); + + const startTime = msToSec(browserPerformanceTimeOrigin as number) + (entry?.startTime || 0); + const duration = msToSec(entry?.duration || 0); + const routeName = getCurrentScope().getScopeData().transactionName; + + const name = entry ? htmlTreeAsString(entry.sources[0]?.node) : 'Layout shift'; + + const attributes: SpanAttributes = dropUndefinedKeys({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser.cls', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.webvital.cls', + [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, + }); + + const span = startStandaloneWebVitalSpan({ + name, + transaction: routeName, + attributes, + startTime, + }); + + span?.addEvent('cls', { + [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: '', + [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: clsValue, + }); + + span?.end(startTime + duration); +} + +function supportsLayoutShift(): boolean { + try { + return PerformanceObserver.supportedEntryTypes?.includes('layout-shift'); + } catch { + return false; + } +} diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts index c4186a20f17e..5814b139bd2d 100644 --- a/packages/browser-utils/src/metrics/inp.ts +++ b/packages/browser-utils/src/metrics/inp.ts @@ -2,23 +2,21 @@ import { SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT, SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, + SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, getActiveSpan, - getClient, getCurrentScope, getRootSpan, spanToJSON, - startInactiveSpan, } from '@sentry/core'; -import type { Integration, Span, SpanAttributes } from '@sentry/types'; +import type { Span, SpanAttributes } from '@sentry/types'; import { browserPerformanceTimeOrigin, dropUndefinedKeys, htmlTreeAsString } from '@sentry/utils'; -import { WINDOW } from '../types'; import { addInpInstrumentationHandler, addPerformanceInstrumentationHandler, isPerformanceEventTiming, } from './instrument'; -import { getBrowserPerformanceAPI, msToSec } from './utils'; +import { getBrowserPerformanceAPI, msToSec, startStandaloneWebVitalSpan } from './utils'; const LAST_INTERACTIONS: number[] = []; const INTERACTIONS_SPAN_MAP = new Map(); @@ -71,8 +69,7 @@ const INP_ENTRY_MAP: Record = { /** Starts tracking the Interaction to Next Paint on the current page. */ function _trackINP(): () => void { return addInpInstrumentationHandler(({ metric }) => { - const client = getClient(); - if (!client || metric.value == undefined) { + if (metric.value == undefined) { return; } @@ -85,11 +82,9 @@ function _trackINP(): () => void { const { interactionId } = entry; const interactionType = INP_ENTRY_MAP[entry.name]; - const options = client.getOptions(); /** Build the INP span, create an envelope from the span, and then send the envelope */ const startTime = msToSec((browserPerformanceTimeOrigin as number) + entry.startTime); const duration = msToSec(metric.value); - const scope = getCurrentScope(); const activeSpan = getActiveSpan(); const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; @@ -101,56 +96,28 @@ function _trackINP(): () => void { // Else, we try to use the active span. // Finally, we fall back to look at the transactionName on the scope - const routeName = spanToUse ? spanToJSON(spanToUse).description : scope.getScopeData().transactionName; - - const user = scope.getUser(); - - // We need to get the replay, user, and activeTransaction from the current scope - // so that we can associate replay id, profile id, and a user display to the span - const replay = client.getIntegrationByName string }>('Replay'); - - const replayId = replay && replay.getReplayId(); - - const userDisplay = user !== undefined ? user.email || user.id || user.ip_address : undefined; - let profileId: string | undefined = undefined; - try { - // @ts-expect-error skip optional chaining to save bundle size with try catch - profileId = scope.getScopeData().contexts.profile.profile_id; - } catch { - // do nothing - } + const routeName = spanToUse ? spanToJSON(spanToUse).description : getCurrentScope().getScopeData().transactionName; const name = htmlTreeAsString(entry.target); const attributes: SpanAttributes = dropUndefinedKeys({ - release: options.release, - environment: options.environment, - transaction: routeName, - [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: metric.value, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser.inp', - user: userDisplay || undefined, - profile_id: profileId || undefined, - replay_id: replayId || undefined, - // INP score calculation in the sentry backend relies on the user agent - // to account for different INP values being reported from different browsers - 'user_agent.original': WINDOW.navigator && WINDOW.navigator.userAgent, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `ui.interaction.${interactionType}`, + [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry.duration, }); - const span = startInactiveSpan({ + const span = startStandaloneWebVitalSpan({ name, - op: `ui.interaction.${interactionType}`, + transaction: routeName, attributes, - startTime: startTime, - experimental: { - standalone: true, - }, + startTime, }); - span.addEvent('inp', { + span?.addEvent('inp', { [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: 'millisecond', [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: metric.value, }); - span.end(startTime + duration); + span?.end(startTime + duration); }); } diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts index d46cb2cfe35c..5f9d0de4d4ab 100644 --- a/packages/browser-utils/src/metrics/utils.ts +++ b/packages/browser-utils/src/metrics/utils.ts @@ -1,6 +1,6 @@ import type { SentrySpan } from '@sentry/core'; -import { spanToJSON, startInactiveSpan, withActiveSpan } from '@sentry/core'; -import type { Span, SpanTimeInput, StartSpanOptions } from '@sentry/types'; +import { getClient, getCurrentScope, spanToJSON, startInactiveSpan, withActiveSpan } from '@sentry/core'; +import type { Integration, Span, SpanAttributes, SpanTimeInput, StartSpanOptions } from '@sentry/types'; import { WINDOW } from '../types'; /** @@ -44,6 +44,84 @@ export function startAndEndSpan( }); } +interface StandaloneWebVitalSpanOptions { + name: string; + transaction?: string; + attributes: SpanAttributes; + startTime: number; +} + +/** + * Starts an inactive, standalone span used to send web vital values to Sentry. + * DO NOT use this for arbitrary spans, as these spans require special handling + * during ingestion to extract metrics. + * + * This function adds a bunch of attributes and data to the span that's shared + * by all web vital standalone spans. However, you need to take care of adding + * the actual web vital value as an event to the span. Also, you need to assign + * a transaction name and some other values that are specific to the web vital. + * + * Ultimately, you also need to take care of ending the span to send it off. + * + * @param options + * + * @returns an inactive, standalone and NOT YET ended span + */ +export function startStandaloneWebVitalSpan(options: StandaloneWebVitalSpanOptions): Span | undefined { + const client = getClient(); + if (!client) { + return; + } + + const { name, transaction, attributes: passedAttributes, startTime } = options; + + const { release, environment } = client.getOptions(); + // We need to get the replay, user, and activeTransaction from the current scope + // so that we can associate replay id, profile id, and a user display to the span + const replay = client.getIntegrationByName string }>('Replay'); + const replayId = replay && replay.getReplayId(); + + const scope = getCurrentScope(); + + const user = scope.getUser(); + const userDisplay = user !== undefined ? user.email || user.id || user.ip_address : undefined; + + let profileId: string | undefined = undefined; + try { + // @ts-expect-error skip optional chaining to save bundle size with try catch + profileId = scope.getScopeData().contexts.profile.profile_id; + } catch { + // do nothing + } + + const attributes: SpanAttributes = { + release, + environment, + + user: userDisplay || undefined, + profile_id: profileId || undefined, + replay_id: replayId || undefined, + + transaction, + + // Web vital score calculation relies on the user agent to account for different + // browsers setting different thresholds for what is considered a good/meh/bad value. + // For example: Chrome vs. Chrome Mobile + 'user_agent.original': WINDOW.navigator && WINDOW.navigator.userAgent, + + ...passedAttributes, + }; + + return startInactiveSpan({ + name, + attributes, + startTime, + experimental: { + standalone: true, + }, + }); +} + /** Get the browser performance API. */ export function getBrowserPerformanceAPI(): Performance | undefined { // @ts-expect-error we want to make sure all of these are available, even if TS is sure they are diff --git a/packages/browser-utils/src/metrics/web-vitals/README.md b/packages/browser-utils/src/metrics/web-vitals/README.md index 653ee22c7ff1..d779969dbe5d 100644 --- a/packages/browser-utils/src/metrics/web-vitals/README.md +++ b/packages/browser-utils/src/metrics/web-vitals/README.md @@ -17,9 +17,9 @@ Current vendored web vitals are: ## Notable Changes from web-vitals library -This vendored web-vitals library is meant to be used in conjunction with the `@sentry/tracing` `BrowserTracing` -integration. As such, logic around `BFCache` and multiple reports were removed from the library as our web-vitals only -report once per pageload. +This vendored web-vitals library is meant to be used in conjunction with the `@sentry/browser` +`browserTracingIntegration`. As such, logic around `BFCache` and multiple reports were removed from the library as our +web-vitals only report once per pageload. ## License diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 491c7aaae88d..ff5201878cff 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -146,6 +146,7 @@ export interface BrowserTracingOptions { */ _experiments: Partial<{ enableInteractions: boolean; + enableStandaloneClsSpans: boolean; }>; /** @@ -191,7 +192,7 @@ export const browserTracingIntegration = ((_options: Partial { _collectWebVitals(); - addPerformanceEntries(span); + addPerformanceEntries(span, { recordClsOnPageloadSpan: !enableStandaloneClsSpans }); }, }); @@ -298,6 +299,7 @@ export const browserTracingIntegration = ((_options: Partial