diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/init.js new file mode 100644 index 000000000000..0c1792f0bd3f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration({ idleTimeout: 9000 }), Sentry.spanStreamingIntegration()], + traceLifecycle: 'stream', + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/subject.js new file mode 100644 index 000000000000..9742a4a5cc29 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-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 via 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-streamed-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/template.html new file mode 100644 index 000000000000..10e2e22f7d6a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/template.html @@ -0,0 +1,10 @@ + + + + + + +
+

Some content

+ + diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts new file mode 100644 index 000000000000..cf995f7a912d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-cls-streamed-spans/test.ts @@ -0,0 +1,77 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils'; + +sentryTest.beforeEach(async ({ browserName, page }) => { + if (shouldSkipTracingTest() || testingCdnBundle() || 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 hidePage(page: Page): Promise { + return page.evaluate(() => { + window.dispatchEvent(new Event('pagehide')); + }); +} + +sentryTest('captures CLS as a streamed span with source attributes', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const clsSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.cls'); + + await page.goto(`${url}#0.15`); + await waitForLayoutShift(page); + await hidePage(page); + + const clsSpan = await clsSpanPromise; + + expect(clsSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.cls' }); + expect(clsSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.cls' }); + expect(clsSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 }); + expect(clsSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome')); + + // Check browser.web_vital.cls.source attributes + expect(clsSpan.attributes?.['browser.web_vital.cls.source.1']?.value).toEqual( + expect.stringContaining('body > div#content > p'), + ); + + // Check pageload span id is present + expect(clsSpan.attributes?.['sentry.pageload.span_id']?.value).toMatch(/[\da-f]{16}/); + + // CLS is a point-in-time metric + expect(clsSpan.start_timestamp).toEqual(clsSpan.end_timestamp); + + expect(clsSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(clsSpan.trace_id).toMatch(/^[\da-f]{32}$/); +}); + +sentryTest('CLS streamed span has web vital value attribute', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const clsSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.cls'); + + await page.goto(`${url}#0.1`); + await waitForLayoutShift(page); + await hidePage(page); + + const clsSpan = await clsSpanPromise; + + // The CLS value should be set as a browser.web_vital.cls.value attribute + expect(clsSpan.attributes?.['browser.web_vital.cls.value']?.type).toBe('double'); + // Flakey value dependent on timings -> we check for a range + const clsValue = clsSpan.attributes?.['browser.web_vital.cls.value']?.value as number; + expect(clsValue).toBeGreaterThan(0.05); + expect(clsValue).toBeLessThan(0.15); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/assets/sentry-logo-600x179.png b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/assets/sentry-logo-600x179.png new file mode 100644 index 000000000000..353b7233d6bf Binary files /dev/null and b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/assets/sentry-logo-600x179.png differ diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/init.js new file mode 100644 index 000000000000..0c1792f0bd3f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration({ idleTimeout: 9000 }), Sentry.spanStreamingIntegration()], + traceLifecycle: 'stream', + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/template.html new file mode 100644 index 000000000000..b613a556aca4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/template.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts new file mode 100644 index 000000000000..c91ebd2bbd51 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts @@ -0,0 +1,66 @@ +import type { Page, Route } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils'; + +sentryTest.beforeEach(async ({ browserName, page }) => { + if (shouldSkipTracingTest() || testingCdnBundle() || browserName !== 'chromium') { + sentryTest.skip(); + } + + await page.setViewportSize({ width: 800, height: 1200 }); +}); + +function hidePage(page: Page): Promise { + return page.evaluate(() => { + window.dispatchEvent(new Event('pagehide')); + }); +} + +sentryTest('captures LCP as a streamed span with element attributes', async ({ getLocalTestUrl, page }) => { + page.route('**', route => route.continue()); + page.route('**/my/image.png', async (route: Route) => { + return route.fulfill({ + path: `${__dirname}/assets/sentry-logo-600x179.png`, + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const lcpSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.lcp'); + + await page.goto(url); + + // Wait for LCP to be captured + await page.waitForTimeout(1000); + + await hidePage(page); + + const lcpSpan = await lcpSpanPromise; + + expect(lcpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.lcp' }); + expect(lcpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.lcp' }); + expect(lcpSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 }); + expect(lcpSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome')); + + // Check browser.web_vital.lcp.* attributes + expect(lcpSpan.attributes?.['browser.web_vital.lcp.element']?.value).toEqual(expect.stringContaining('body > img')); + expect(lcpSpan.attributes?.['browser.web_vital.lcp.url']?.value).toBe( + 'https://sentry-test-site.example/my/image.png', + ); + expect(lcpSpan.attributes?.['browser.web_vital.lcp.size']?.value).toEqual(expect.any(Number)); + + // Check web vital value attribute + expect(lcpSpan.attributes?.['browser.web_vital.lcp.value']?.type).toBe('double'); + expect(lcpSpan.attributes?.['browser.web_vital.lcp.value']?.value).toBeGreaterThan(0); + + // Check pageload span id is present + expect(lcpSpan.attributes?.['sentry.pageload.span_id']?.value).toMatch(/[\da-f]{16}/); + + // Span should have meaningful duration (navigation start -> LCP event) + expect(lcpSpan.end_timestamp).toBeGreaterThan(lcpSpan.start_timestamp); + + expect(lcpSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(lcpSpan.trace_id).toMatch(/^[\da-f]{32}$/); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/init.js new file mode 100644 index 000000000000..bd3b6ed17872 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + traceLifecycle: 'stream', + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/template.html new file mode 100644 index 000000000000..0a94c016ff92 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/template.html @@ -0,0 +1,10 @@ + + + + + + +
Hello World
+ + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/test.ts new file mode 100644 index 000000000000..30112f875ab8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-streamed-spans/test.ts @@ -0,0 +1,84 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, getSpansFromEnvelope, waitForStreamedSpanEnvelope } from '../../../../utils/spanUtils'; + +sentryTest.beforeEach(async ({ browserName }) => { + if (shouldSkipTracingTest() || testingCdnBundle() || browserName !== 'chromium') { + sentryTest.skip(); + } +}); + +sentryTest('captures FCP as a streamed span with duration from navigation start', async ({ getLocalTestUrl, page }) => { + const fcpSpanEnvelopePromise = waitForStreamedSpanEnvelope(page, env => { + const spans = getSpansFromEnvelope(env); + return spans.some(s => getSpanOp(s) === 'ui.webvital.fcp'); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const fcpEnvelope = await fcpSpanEnvelopePromise; + const fcpSpan = getSpansFromEnvelope(fcpEnvelope).find(s => getSpanOp(s) === 'ui.webvital.fcp')!; + + expect(fcpSpan).toBeDefined(); + expect(fcpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.fcp' }); + expect(fcpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.fcp' }); + expect(fcpSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 }); + expect(fcpSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome')); + expect(fcpSpan.name).toBe('FCP'); + expect(fcpSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(fcpSpan.trace_id).toMatch(/^[\da-f]{32}$/); + + // Span should have meaningful duration (navigation start -> FCP event) + expect(fcpSpan.end_timestamp).toBeGreaterThan(fcpSpan.start_timestamp); +}); + +sentryTest('captures FP as a streamed span with duration from navigation start', async ({ getLocalTestUrl, page }) => { + const fpSpanEnvelopePromise = waitForStreamedSpanEnvelope(page, env => { + const spans = getSpansFromEnvelope(env); + return spans.some(s => getSpanOp(s) === 'ui.webvital.fp'); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const fpEnvelope = await fpSpanEnvelopePromise; + const fpSpan = getSpansFromEnvelope(fpEnvelope).find(s => getSpanOp(s) === 'ui.webvital.fp')!; + + expect(fpSpan).toBeDefined(); + expect(fpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.fp' }); + expect(fpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.fp' }); + expect(fpSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 }); + expect(fpSpan.name).toBe('FP'); + expect(fpSpan.span_id).toMatch(/^[\da-f]{16}$/); + + // Span should have meaningful duration (navigation start -> FP event) + expect(fpSpan.end_timestamp).toBeGreaterThan(fpSpan.start_timestamp); +}); + +sentryTest( + 'captures TTFB as a streamed span with duration from navigation start', + async ({ getLocalTestUrl, page }) => { + const ttfbSpanEnvelopePromise = waitForStreamedSpanEnvelope(page, env => { + const spans = getSpansFromEnvelope(env); + return spans.some(s => getSpanOp(s) === 'ui.webvital.ttfb'); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const ttfbEnvelope = await ttfbSpanEnvelopePromise; + const ttfbSpan = getSpansFromEnvelope(ttfbEnvelope).find(s => getSpanOp(s) === 'ui.webvital.ttfb')!; + + expect(ttfbSpan).toBeDefined(); + expect(ttfbSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.ttfb' }); + expect(ttfbSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.ttfb' }); + expect(ttfbSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 }); + expect(ttfbSpan.name).toBe('TTFB'); + expect(ttfbSpan.span_id).toMatch(/^[\da-f]{16}$/); + + // Span should have meaningful duration (navigation start -> first byte) + expect(ttfbSpan.end_timestamp).toBeGreaterThan(ttfbSpan.start_timestamp); + }, +); diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index a4d0960b1ccb..3d200c36db84 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -4,6 +4,7 @@ export { addTtfbInstrumentationHandler, addLcpInstrumentationHandler, addInpInstrumentationHandler, + addFcpInstrumentationHandler, } from './metrics/instrument'; export { @@ -20,6 +21,15 @@ export { startTrackingElementTiming } from './metrics/elementTiming'; export { extractNetworkProtocol } from './metrics/utils'; +export { + trackClsAsSpan, + trackFcpAsSpan, + trackFpAsSpan, + trackInpAsSpan, + trackLcpAsSpan, + trackTtfbAsSpan, +} from './metrics/webVitalSpans'; + export { addClickKeypressInstrumentationHandler } from './instrument/dom'; export { addHistoryInstrumentationHandler } from './instrument/history'; diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts index 831565f07408..f6411ef8544d 100644 --- a/packages/browser-utils/src/metrics/inp.ts +++ b/packages/browser-utils/src/metrics/inp.ts @@ -54,7 +54,7 @@ export function startTrackingINP(): () => void { return () => undefined; } -const INP_ENTRY_MAP: Record = { +export const INP_ENTRY_MAP: Record = { click: 'click', pointerdown: 'click', pointerup: 'click', diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts index 4c461ec6776c..4b4be90f191b 100644 --- a/packages/browser-utils/src/metrics/instrument.ts +++ b/packages/browser-utils/src/metrics/instrument.ts @@ -4,6 +4,7 @@ import { onCLS } from './web-vitals/getCLS'; import { onINP } from './web-vitals/getINP'; import { onLCP } from './web-vitals/getLCP'; import { observe } from './web-vitals/lib/observe'; +import { onFCP } from './web-vitals/onFCP'; import { onTTFB } from './web-vitals/onTTFB'; type InstrumentHandlerTypePerformanceObserver = @@ -16,7 +17,7 @@ type InstrumentHandlerTypePerformanceObserver = // fist-input is still needed for INP | 'first-input'; -type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'ttfb' | 'inp'; +type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'ttfb' | 'inp' | 'fcp'; // We provide this here manually instead of relying on a global, as this is not available in non-browser environements // And we do not want to expose such types @@ -114,6 +115,7 @@ let _previousCls: Metric | undefined; let _previousLcp: Metric | undefined; let _previousTtfb: Metric | undefined; let _previousInp: Metric | undefined; +let _previousFcp: Metric | undefined; /** * Add a callback that will be triggered when a CLS metric is available. @@ -164,6 +166,14 @@ export function addInpInstrumentationHandler(callback: InstrumentationHandlerCal return addMetricObserver('inp', callback, instrumentInp, _previousInp); } +/** + * Add a callback that will be triggered when a FCP metric is available. + * Returns a cleanup callback which can be called to remove the instrumentation handler. + */ +export function addFcpInstrumentationHandler(callback: (data: { metric: Metric }) => void): CleanupHandlerCallback { + return addMetricObserver('fcp', callback, instrumentFcp, _previousFcp); +} + export function addPerformanceInstrumentationHandler( type: 'event', callback: (data: { entries: ((PerformanceEntry & { target?: unknown | null }) | PerformanceEventTiming)[] }) => void, @@ -259,6 +269,15 @@ function instrumentInp(): void { }); } +function instrumentFcp(): StopListening { + return onFCP(metric => { + triggerHandlers('fcp', { + metric, + }); + _previousFcp = metric; + }); +} + function addMetricObserver( type: InstrumentHandlerTypeMetric, callback: InstrumentHandlerCallback, diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts new file mode 100644 index 000000000000..e7d62874d0d5 --- /dev/null +++ b/packages/browser-utils/src/metrics/webVitalSpans.ts @@ -0,0 +1,377 @@ +import type { Client, SpanAttributes } from '@sentry/core'; +import { + browserPerformanceTimeOrigin, + debug, + getActiveSpan, + getCurrentScope, + getRootSpan, + htmlTreeAsString, + SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, + SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT, + SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + spanToJSON, + startInactiveSpan, + timestampInSeconds, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import { WINDOW } from '../types'; +import { INP_ENTRY_MAP } from './inp'; +import type { InstrumentationHandlerCallback } from './instrument'; +import { + addClsInstrumentationHandler, + addFcpInstrumentationHandler, + addInpInstrumentationHandler, + addLcpInstrumentationHandler, + addPerformanceInstrumentationHandler, + addTtfbInstrumentationHandler, +} from './instrument'; +import { listenForWebVitalReportEvents, msToSec, supportsWebVital } from './utils'; +import { getVisibilityWatcher } from './web-vitals/lib/getVisibilityWatcher'; + +interface WebVitalSpanOptions { + name: string; + op: string; + origin: string; + metricName: string; + value: number; + unit: string; + attributes?: SpanAttributes; + pageloadSpanId?: string; + startTime: number; + endTime?: number; +} + +/** + * Emits a web vital span that flows through the span streaming pipeline. + */ +export function _emitWebVitalSpan(options: WebVitalSpanOptions): void { + const { + name, + op, + origin, + metricName, + value, + unit, + attributes: passedAttributes, + pageloadSpanId, + startTime, + endTime, + } = options; + + const routeName = getCurrentScope().getScopeData().transactionName; + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, + [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0, + [`browser.web_vital.${metricName}.value`]: value, + transaction: routeName, + // Web vital score calculation relies on the user agent + 'user_agent.original': WINDOW.navigator?.userAgent, + ...passedAttributes, + }; + + if (pageloadSpanId) { + attributes['sentry.pageload.span_id'] = pageloadSpanId; + } + + const span = startInactiveSpan({ + name, + attributes, + startTime, + }); + + if (span) { + span.addEvent(metricName, { + [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: unit, + [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: value, + }); + + span.end(endTime ?? startTime); + } +} + +/** + * Tracks LCP as a streamed span. + */ +export function trackLcpAsSpan(client: Client): void { + let lcpValue = 0; + let lcpEntry: LargestContentfulPaint | undefined; + + if (!supportsWebVital('largest-contentful-paint')) { + return; + } + + const cleanupLcpHandler = addLcpInstrumentationHandler(({ metric }) => { + const entry = metric.entries[metric.entries.length - 1] as LargestContentfulPaint | undefined; + if (!entry) { + return; + } + lcpValue = metric.value; + lcpEntry = entry; + }, true); + + listenForWebVitalReportEvents(client, (_reportEvent, pageloadSpanId) => { + _sendLcpSpan(lcpValue, lcpEntry, pageloadSpanId); + cleanupLcpHandler(); + }); +} + +/** + * Exported only for testing. + */ +export function _sendLcpSpan( + lcpValue: number, + entry: LargestContentfulPaint | undefined, + pageloadSpanId: string, +): void { + DEBUG_BUILD && debug.log(`Sending LCP span (${lcpValue})`); + + const timeOrigin = msToSec(browserPerformanceTimeOrigin() || 0); + const endTime = msToSec((browserPerformanceTimeOrigin() || 0) + (entry?.startTime || 0)); + const name = entry ? htmlTreeAsString(entry.element) : 'Largest contentful paint'; + + const attributes: SpanAttributes = {}; + + if (entry) { + entry.element && (attributes['browser.web_vital.lcp.element'] = htmlTreeAsString(entry.element)); + entry.id && (attributes['browser.web_vital.lcp.id'] = entry.id); + entry.url && (attributes['browser.web_vital.lcp.url'] = entry.url); + entry.loadTime != null && (attributes['browser.web_vital.lcp.load_time'] = entry.loadTime); + entry.renderTime != null && (attributes['browser.web_vital.lcp.render_time'] = entry.renderTime); + entry.size != null && (attributes['browser.web_vital.lcp.size'] = entry.size); + } + + _emitWebVitalSpan({ + name, + op: 'ui.webvital.lcp', + origin: 'auto.http.browser.lcp', + metricName: 'lcp', + value: lcpValue, + unit: 'millisecond', + attributes, + pageloadSpanId, + startTime: timeOrigin, + endTime, + }); +} + +/** + * Tracks CLS as a streamed span. + */ +export function trackClsAsSpan(client: Client): void { + let clsValue = 0; + let clsEntry: LayoutShift | undefined; + + if (!supportsWebVital('layout-shift')) { + return; + } + + const cleanupClsHandler = addClsInstrumentationHandler(({ metric }) => { + const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined; + if (!entry) { + return; + } + clsValue = metric.value; + clsEntry = entry; + }, true); + + listenForWebVitalReportEvents(client, (_reportEvent, pageloadSpanId) => { + _sendClsSpan(clsValue, clsEntry, pageloadSpanId); + cleanupClsHandler(); + }); +} + +/** + * Exported only for testing. + */ +export function _sendClsSpan(clsValue: number, entry: LayoutShift | undefined, pageloadSpanId: string): void { + DEBUG_BUILD && debug.log(`Sending CLS span (${clsValue})`); + + const startTime = entry ? msToSec((browserPerformanceTimeOrigin() || 0) + entry.startTime) : timestampInSeconds(); + const name = entry ? htmlTreeAsString(entry.sources[0]?.node) : 'Layout shift'; + + const attributes: SpanAttributes = {}; + + if (entry?.sources) { + entry.sources.forEach((source, index) => { + attributes[`browser.web_vital.cls.source.${index + 1}`] = htmlTreeAsString(source.node); + }); + } + + _emitWebVitalSpan({ + name, + op: 'ui.webvital.cls', + origin: 'auto.http.browser.cls', + metricName: 'cls', + value: clsValue, + unit: '', + attributes, + pageloadSpanId, + startTime, + }); +} + +/** + * Tracks INP as a streamed span. + */ +export function trackInpAsSpan(_client: Client): void { + const onInp: InstrumentationHandlerCallback = ({ metric }) => { + if (metric.value == null) { + return; + } + + const entry = metric.entries.find(e => e.duration === metric.value && INP_ENTRY_MAP[e.name]); + + if (!entry) { + return; + } + + _sendInpSpan(metric.value, entry); + }; + + addInpInstrumentationHandler(onInp); +} + +/** + * Exported only for testing. + */ +export function _sendInpSpan( + inpValue: number, + entry: { name: string; startTime: number; duration: number; target?: unknown | null }, +): void { + DEBUG_BUILD && debug.log(`Sending INP span (${inpValue})`); + + const startTime = msToSec((browserPerformanceTimeOrigin() as number) + entry.startTime); + const interactionType = INP_ENTRY_MAP[entry.name]; + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + const routeName = rootSpan ? spanToJSON(rootSpan).description : getCurrentScope().getScopeData().transactionName; + const name = htmlTreeAsString(entry.target); + + _emitWebVitalSpan({ + name, + op: `ui.interaction.${interactionType}`, + origin: 'auto.http.browser.inp', + metricName: 'inp', + value: inpValue, + unit: 'millisecond', + attributes: { + [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry.duration, + transaction: routeName, + }, + startTime, + endTime: startTime + msToSec(entry.duration), + }); +} + +/** + * Tracks TTFB as a streamed span. + */ +export function trackTtfbAsSpan(client: Client): void { + addTtfbInstrumentationHandler(({ metric }) => { + _sendTtfbSpan(metric.value, client); + }); +} + +/** + * Exported only for testing. + */ +export function _sendTtfbSpan(ttfbValue: number, _client: Client): void { + DEBUG_BUILD && debug.log(`Sending TTFB span (${ttfbValue})`); + + const timeOrigin = msToSec(browserPerformanceTimeOrigin() || 0); + + const attributes: SpanAttributes = {}; + + // Try to get request_time from navigation timing + try { + const navEntry = WINDOW.performance?.getEntriesByType?.('navigation')[0] as PerformanceNavigationTiming | undefined; + if (navEntry) { + attributes['browser.web_vital.ttfb.request_time'] = navEntry.responseStart - navEntry.requestStart; + } + } catch { + // ignore + } + + _emitWebVitalSpan({ + name: 'TTFB', + op: 'ui.webvital.ttfb', + origin: 'auto.http.browser.ttfb', + metricName: 'ttfb', + value: ttfbValue, + unit: 'millisecond', + attributes, + startTime: timeOrigin, + endTime: timeOrigin + msToSec(ttfbValue), + }); +} + +/** + * Tracks FCP as a streamed span. + */ +export function trackFcpAsSpan(_client: Client): void { + addFcpInstrumentationHandler(({ metric }) => { + _sendFcpSpan(metric.value); + }); +} + +/** + * Exported only for testing. + */ +export function _sendFcpSpan(fcpValue: number): void { + DEBUG_BUILD && debug.log(`Sending FCP span (${fcpValue})`); + + const timeOrigin = msToSec(browserPerformanceTimeOrigin() || 0); + + _emitWebVitalSpan({ + name: 'FCP', + op: 'ui.webvital.fcp', + origin: 'auto.http.browser.fcp', + metricName: 'fcp', + value: fcpValue, + unit: 'millisecond', + startTime: timeOrigin, + endTime: timeOrigin + msToSec(fcpValue), + }); +} + +/** + * Tracks FP (First Paint) as a streamed span. + */ +export function trackFpAsSpan(_client: Client): void { + const visibilityWatcher = getVisibilityWatcher(); + + addPerformanceInstrumentationHandler('paint', ({ entries }) => { + for (const entry of entries) { + if (entry.name === 'first-paint') { + if (entry.startTime < visibilityWatcher.firstHiddenTime) { + _sendFpSpan(entry.startTime); + } + break; + } + } + }); +} + +/** + * Exported only for testing. + */ +export function _sendFpSpan(fpStartTime: number): void { + DEBUG_BUILD && debug.log(`Sending FP span (${fpStartTime})`); + + const timeOrigin = msToSec(browserPerformanceTimeOrigin() || 0); + + _emitWebVitalSpan({ + name: 'FP', + op: 'ui.webvital.fp', + origin: 'auto.http.browser.fp', + metricName: 'fp', + value: fpStartTime, + unit: 'millisecond', + startTime: timeOrigin, + endTime: timeOrigin + msToSec(fpStartTime), + }); +} diff --git a/packages/browser-utils/test/metrics/webVitalSpans.test.ts b/packages/browser-utils/test/metrics/webVitalSpans.test.ts new file mode 100644 index 000000000000..2e369c76b1ac --- /dev/null +++ b/packages/browser-utils/test/metrics/webVitalSpans.test.ts @@ -0,0 +1,525 @@ +import * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + _emitWebVitalSpan, + _sendClsSpan, + _sendFcpSpan, + _sendFpSpan, + _sendInpSpan, + _sendLcpSpan, + _sendTtfbSpan, +} from '../../src/metrics/webVitalSpans'; + +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(), + startInactiveSpan: vi.fn(), + getActiveSpan: vi.fn(), + getRootSpan: vi.fn(), + spanToJSON: vi.fn(), + }; +}); + +// Mock WINDOW +vi.mock('../../src/types', () => ({ + WINDOW: { + navigator: { userAgent: 'test-user-agent' }, + performance: { + getEntriesByType: vi.fn().mockReturnValue([]), + }, + }, +})); + +describe('_emitWebVitalSpan', () => { + 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.startInactiveSpan).mockReturnValue(mockSpan as any); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('creates a non-standalone span with correct attributes', () => { + _emitWebVitalSpan({ + name: 'Test Vital', + op: 'ui.webvital.test', + origin: 'auto.http.browser.test', + metricName: 'test', + value: 100, + unit: 'millisecond', + startTime: 1.5, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith({ + name: 'Test Vital', + attributes: { + 'sentry.origin': 'auto.http.browser.test', + 'sentry.op': 'ui.webvital.test', + 'sentry.exclusive_time': 0, + 'browser.web_vital.test.value': 100, + transaction: 'test-transaction', + 'user_agent.original': 'test-user-agent', + }, + startTime: 1.5, + }); + + // No standalone flag + expect(SentryCore.startInactiveSpan).not.toHaveBeenCalledWith( + expect.objectContaining({ experimental: expect.anything() }), + ); + + expect(mockSpan.addEvent).toHaveBeenCalledWith('test', { + 'sentry.measurement_unit': 'millisecond', + 'sentry.measurement_value': 100, + }); + + expect(mockSpan.end).toHaveBeenCalledWith(1.5); + }); + + it('includes pageloadSpanId when provided', () => { + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.webvital.test', + origin: 'auto.http.browser.test', + metricName: 'test', + value: 50, + unit: 'millisecond', + pageloadSpanId: 'abc123', + startTime: 1.0, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.pageload.span_id': 'abc123', + }), + }), + ); + }); + + it('merges additional attributes', () => { + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.webvital.test', + origin: 'auto.http.browser.test', + metricName: 'test', + value: 50, + unit: 'millisecond', + attributes: { 'custom.attr': 'value' }, + startTime: 1.0, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'custom.attr': 'value', + }), + }), + ); + }); + + it('handles when startInactiveSpan returns undefined', () => { + vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(undefined as any); + + expect(() => { + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.webvital.test', + origin: 'auto.http.browser.test', + metricName: 'test', + value: 50, + unit: 'millisecond', + startTime: 1.0, + }); + }).not.toThrow(); + }); +}); + +describe('_sendLcpSpan', () => { + const mockSpan = { + addEvent: vi.fn(), + end: vi.fn(), + }; + + const mockScope = { + getScopeData: vi.fn().mockReturnValue({ + transactionName: 'test-route', + }), + }; + + 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.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('sends a streamed LCP span with entry data', () => { + const mockEntry = { + element: { tagName: 'img' } as Element, + id: 'hero', + url: 'https://example.com/hero.jpg', + loadTime: 100, + renderTime: 150, + size: 50000, + startTime: 200, + } as LargestContentfulPaint; + + _sendLcpSpan(250, mockEntry, 'pageload-123'); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: '', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.http.browser.lcp', + 'sentry.op': 'ui.webvital.lcp', + 'sentry.exclusive_time': 0, + 'sentry.pageload.span_id': 'pageload-123', + 'browser.web_vital.lcp.element': '', + 'browser.web_vital.lcp.id': 'hero', + 'browser.web_vital.lcp.url': 'https://example.com/hero.jpg', + 'browser.web_vital.lcp.load_time': 100, + 'browser.web_vital.lcp.render_time': 150, + 'browser.web_vital.lcp.size': 50000, + }), + startTime: 1, // timeOrigin: 1000 / 1000 + }), + ); + + expect(mockSpan.addEvent).toHaveBeenCalledWith('lcp', { + 'sentry.measurement_unit': 'millisecond', + 'sentry.measurement_value': 250, + }); + + // endTime = timeOrigin + entry.startTime = (1000 + 200) / 1000 = 1.2 + expect(mockSpan.end).toHaveBeenCalledWith(1.2); + }); + + it('sends a streamed LCP span without entry data', () => { + _sendLcpSpan(0, undefined, 'pageload-456'); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Largest contentful paint', + startTime: 1, // timeOrigin: 1000 / 1000 + }), + ); + }); +}); + +describe('_sendClsSpan', () => { + const mockSpan = { + addEvent: vi.fn(), + end: vi.fn(), + }; + + const mockScope = { + getScopeData: vi.fn().mockReturnValue({ + transactionName: 'test-route', + }), + }; + + 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.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('sends a streamedCLS span with entry data and sources', () => { + const mockEntry: LayoutShift = { + name: 'layout-shift', + entryType: 'layout-shift', + startTime: 100, + duration: 0, + value: 0.1, + 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(), + }; + + vi.mocked(SentryCore.htmlTreeAsString) + .mockReturnValueOnce('
') // for the name + .mockReturnValueOnce('
') // for source 1 + .mockReturnValueOnce(''); // for source 2 + + _sendClsSpan(0.1, mockEntry, 'pageload-789'); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: '
', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.http.browser.cls', + 'sentry.op': 'ui.webvital.cls', + 'sentry.pageload.span_id': 'pageload-789', + 'browser.web_vital.cls.source.1': '
', + 'browser.web_vital.cls.source.2': '', + }), + }), + ); + + expect(mockSpan.addEvent).toHaveBeenCalledWith('cls', { + 'sentry.measurement_unit': '', + 'sentry.measurement_value': 0.1, + }); + }); + + it('sends a streamedCLS span without entry data', () => { + _sendClsSpan(0, undefined, 'pageload-000'); + + expect(SentryCore.timestampInSeconds).toHaveBeenCalled(); + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Layout shift', + startTime: 1.5, + }), + ); + }); +}); + +describe('_sendInpSpan', () => { + const mockSpan = { + addEvent: vi.fn(), + end: vi.fn(), + }; + + const mockScope = { + getScopeData: vi.fn().mockReturnValue({ + transactionName: 'test-route', + }), + }; + + beforeEach(() => { + vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); + vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); + vi.mocked(SentryCore.htmlTreeAsString).mockReturnValue('