diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/assets/sentry-logo-600x179.png b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-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-standalone-spans/assets/sentry-logo-600x179.png differ diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/init.js new file mode 100644 index 000000000000..8da426e106b8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-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: { + enableStandaloneLcpSpans: true, + }, + }), + ], + tracesSampleRate: 1, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/template.html new file mode 100644 index 000000000000..ef5d3bac0018 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/template.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + 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 new file mode 100644 index 000000000000..d0fa133f9567 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-standalone-spans/test.ts @@ -0,0 +1,356 @@ +import type { Page, Route } from '@playwright/test'; +import { expect } from '@playwright/test'; +import type { Event as SentryEvent, EventEnvelope, SpanEnvelope } from '@sentry/core'; +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 hidePage(page: Page): Promise { + return page.evaluate(() => { + window.dispatchEvent(new Event('pagehide')); + }); +} + +sentryTest('captures LCP vital as a standalone span', async ({ getLocalTestUrl, page }) => { + const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'span' }, + properFullEnvelopeRequestParser, + ); + + 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 }); + await page.goto(url); + + // Wait for LCP to be captured + 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.lcp', + 'sentry.origin': 'auto.http.browser.lcp', + transaction: expect.stringContaining('index.html'), + 'user_agent.original': expect.stringContaining('Chrome'), + 'sentry.pageload.span_id': expect.stringMatching(/[a-f0-9]{16}/), + 'lcp.element': 'body > img', + 'lcp.id': '', + 'lcp.loadTime': expect.any(Number), + 'lcp.renderTime': expect.any(Number), + 'lcp.size': expect.any(Number), + 'lcp.url': 'https://sentry-test-site.example/my/image.png', + }, + description: expect.stringContaining('body > img'), + exclusive_time: 0, + measurements: { + lcp: { + unit: 'millisecond', + value: expect.any(Number), + }, + }, + op: 'ui.webvital.lcp', + origin: 'auto.http.browser.lcp', + 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, // LCP is a point-in-time metric + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + // LCP value should be greater than 0 + expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0); + + expect(spanEnvelopeHeaders).toEqual({ + sent_at: expect.any(String), + trace: { + environment: 'production', + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: spanEnvelopeItem.trace_id, + sample_rand: expect.any(String), + // no transaction, because span source is URL + }, + }); +}); + +sentryTest('LCP span is linked to pageload transaction', 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 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, + ); + + // Wait for LCP to be captured + await page.waitForTimeout(1000); + + await hidePage(page); + + const spanEnvelope = (await spanEnvelopePromise)[0]; + const spanEnvelopeItem = spanEnvelope[1][0][1]; + + // Ensure the LCP 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.measurements?.lcp?.value).toBeGreaterThan(0); +}); + +sentryTest('sends LCP of the initial page when soft-navigating to a new page', 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 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, + ); + + // Wait for LCP to be captured + await page.waitForTimeout(1000); + + await page.goto(`${url}#soft-navigation`); + + const spanEnvelope = (await spanEnvelopePromise)[0]; + const spanEnvelopeItem = spanEnvelope[1][0][1]; + + expect(spanEnvelopeItem.measurements?.lcp?.value).toBeGreaterThan(0); + expect(spanEnvelopeItem.data?.['sentry.pageload.span_id']).toMatch(/[a-f0-9]{16}/); +}); + +sentryTest("doesn't send further LCP after the first navigation", 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 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, + ); + + // Wait for LCP to be captured + await page.waitForTimeout(1000); + + await page.goto(`${url}#soft-navigation`); + + const spanEnvelope = (await spanEnvelopePromise)[0]; + const spanEnvelopeItem = spanEnvelope[1][0][1]; + expect(spanEnvelopeItem.measurements?.lcp?.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 LCP emission triggers: + await page.goto(`${url}#soft-navigation-2`); + await hidePage(page); + + // assumption: If we would send another LCP 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 LCP after the first page hide", 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 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, + ); + + // Wait for LCP to be captured + await page.waitForTimeout(1000); + + await hidePage(page); + + const spanEnvelope = (await spanEnvelopePromise)[0]; + const spanEnvelopeItem = spanEnvelope[1][0][1]; + expect(spanEnvelopeItem.measurements?.lcp?.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 LCP emission triggers: + await page.goto(`${url}#soft-navigation-2`); + await hidePage(page); + + // assumption: If we would send another LCP 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('LCP span timestamps are set correctly', 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 eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.type).toBe('transaction'); + expect(eventData.contexts?.trace?.op).toBe('pageload'); + expect(eventData.timestamp).toBeDefined(); + + const pageloadEndTimestamp = eventData.timestamp!; + + const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'span' }, + properFullEnvelopeRequestParser, + ); + + // Wait for LCP to be captured + await page.waitForTimeout(1000); + + await hidePage(page); + + const spanEnvelope = (await spanEnvelopePromise)[0]; + const spanEnvelopeItem = spanEnvelope[1][0][1]; + + expect(spanEnvelopeItem.start_timestamp).toBeDefined(); + expect(spanEnvelopeItem.timestamp).toBeDefined(); + + const lcpSpanStartTimestamp = spanEnvelopeItem.start_timestamp!; + const lcpSpanEndTimestamp = spanEnvelopeItem.timestamp!; + + // LCP is a point-in-time metric ==> start and end timestamp should be the same + expect(lcpSpanStartTimestamp).toEqual(lcpSpanEndTimestamp); + + // We don't really care that they are very close together but rather about the order of magnitude + // Previously, we had a bug where the timestamps would be significantly off (by multiple hours) + // so we only ensure that this bug is fixed. 60 seconds should be more than enough. + expect(lcpSpanStartTimestamp - pageloadEndTimestamp).toBeLessThan(60); +}); + +sentryTest( + 'pageload transaction does not contain LCP measurement when standalone spans are enabled', + 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 eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.type).toBe('transaction'); + expect(eventData.contexts?.trace?.op).toBe('pageload'); + + // LCP measurement should NOT be present on the pageload transaction when standalone spans are enabled + expect(eventData.measurements?.lcp).toBeUndefined(); + + // LCP attributes should also NOT be present on the pageload transaction when standalone spans are enabled + // because the LCP data is sent as a standalone span instead + expect(eventData.contexts?.trace?.data?.['lcp.element']).toBeUndefined(); + expect(eventData.contexts?.trace?.data?.['lcp.size']).toBeUndefined(); + }, +); diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 4c5e78899b29..e573ca441b03 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -22,6 +22,7 @@ import { addPerformanceInstrumentationHandler, addTtfbInstrumentationHandler, } from './instrument'; +import { trackLcpAsStandaloneSpan } from './lcp'; import { extractNetworkProtocol, getBrowserPerformanceAPI, @@ -81,6 +82,7 @@ let _clsEntry: LayoutShift | undefined; interface StartTrackingWebVitalsOptions { recordClsStandaloneSpans: boolean; + recordLcpStandaloneSpans: boolean; } /** @@ -89,7 +91,10 @@ interface StartTrackingWebVitalsOptions { * * @returns A function that forces web vitals collection */ -export function startTrackingWebVitals({ recordClsStandaloneSpans }: StartTrackingWebVitalsOptions): () => void { +export function startTrackingWebVitals({ + recordClsStandaloneSpans, + recordLcpStandaloneSpans, +}: 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 @@ -97,13 +102,13 @@ export function startTrackingWebVitals({ recordClsStandaloneSpans }: StartTracki WINDOW.performance.mark('sentry-tracing-init'); } const fidCleanupCallback = _trackFID(); - const lcpCleanupCallback = _trackLCP(); + const lcpCleanupCallback = recordLcpStandaloneSpans ? trackLcpAsStandaloneSpan() : _trackLCP(); const ttfbCleanupCallback = _trackTtfb(); const clsCleanupCallback = recordClsStandaloneSpans ? trackClsAsStandaloneSpan() : _trackCLS(); return (): void => { fidCleanupCallback(); - lcpCleanupCallback(); + lcpCleanupCallback?.(); ttfbCleanupCallback(); clsCleanupCallback?.(); }; @@ -298,11 +303,23 @@ function _trackTtfb(): () => void { interface AddPerformanceEntriesOptions { /** - * Flag to determine if CLS should be recorded as a measurement on the span or + * Flag to determine if CLS should be recorded as a measurement on the pageload span or * sent as a standalone span instead. + * Sending it as a standalone span will yield more accurate LCP values. + * + * Default: `false` for backwards compatibility. */ recordClsOnPageloadSpan: boolean; + /** + * Flag to determine if LCP should be recorded as a measurement on the pageload span or + * sent as a standalone span instead. + * Sending it as a standalone span will yield more accurate LCP values. + * + * Default: `false` for backwards compatibility. + */ + recordLcpOnPageloadSpan: boolean; + /** * Resource spans with `op`s matching strings in the array will not be emitted. * @@ -418,6 +435,11 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries delete _measurements.cls; } + // If LCP standalone spans are enabled, don't record LCP as a measurement + if (!options.recordLcpOnPageloadSpan) { + delete _measurements.lcp; + } + Object.entries(_measurements).forEach(([measurementName, measurement]) => { setMeasurement(measurementName, measurement.value, measurement.unit); }); @@ -433,7 +455,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries // the `activationStart` attribute of the "navigation" PerformanceEntry. span.setAttribute('performance.activationStart', getActivationStart()); - _setWebVitalAttributes(span); + _setWebVitalAttributes(span, options); } _lcpEntry = undefined; @@ -742,8 +764,9 @@ function _trackNavigator(span: Span): void { } /** Add LCP / CLS data to span to allow debugging */ -function _setWebVitalAttributes(span: Span): void { - if (_lcpEntry) { +function _setWebVitalAttributes(span: Span, options: AddPerformanceEntriesOptions): void { + // Only add LCP attributes if LCP is being recorded on the pageload span + if (_lcpEntry && options.recordLcpOnPageloadSpan) { // Capture Properties of the LCP element that contributes to the LCP. if (_lcpEntry.element) { @@ -774,8 +797,8 @@ function _setWebVitalAttributes(span: Span): void { span.setAttribute('lcp.size', _lcpEntry.size); } - // See: https://developer.mozilla.org/en-US/docs/Web/API/LayoutShift - if (_clsEntry?.sources) { + // Only add CLS attributes if CLS is being recorded on the pageload span + if (_clsEntry?.sources && options.recordClsOnPageloadSpan) { _clsEntry.sources.forEach((source, index) => span.setAttribute(`cls.source.${index + 1}`, htmlTreeAsString(source.node)), ); diff --git a/packages/browser-utils/src/metrics/cls.ts b/packages/browser-utils/src/metrics/cls.ts index 1d35ff53853f..d607931711c5 100644 --- a/packages/browser-utils/src/metrics/cls.ts +++ b/packages/browser-utils/src/metrics/cls.ts @@ -58,8 +58,6 @@ export function trackClsAsStandaloneSpan(): void { standaloneClsEntry = entry; }, true); - // TODO: Figure out if we can switch to using whenIdleOrHidden instead of onHidden - // use pagehide event from web-vitals onHidden(() => { _collectClsOnce(); }); diff --git a/packages/browser-utils/src/metrics/lcp.ts b/packages/browser-utils/src/metrics/lcp.ts new file mode 100644 index 000000000000..4d4fbda8f979 --- /dev/null +++ b/packages/browser-utils/src/metrics/lcp.ts @@ -0,0 +1,140 @@ +import type { SpanAttributes } from '@sentry/core'; +import { + browserPerformanceTimeOrigin, + getActiveSpan, + getClient, + getCurrentScope, + getRootSpan, + htmlTreeAsString, + logger, + SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, + SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT, + 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'; + +/** + * Starts tracking the Largest Contentful Paint on the current page and collects the value once + * + * - the page visibility is hidden + * - a navigation span is started (to stop LCP measurement for SPA soft navigations) + * + * Once either of these events triggers, the LCP value is sent as a standalone span and we stop + * measuring LCP for subsequent routes. + */ +export function trackLcpAsStandaloneSpan(): void { + let standaloneLcpValue = 0; + let standaloneLcpEntry: LargestContentfulPaint | undefined; + let pageloadSpanId: string | undefined; + + if (!supportsLargestContentfulPaint()) { + 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) { + return; + } + standaloneLcpValue = metric.value; + standaloneLcpEntry = entry; + }, true); + + onHidden(() => { + _collectLcpOnce(); + }); + + // 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('startNavigationSpan', () => { + _collectLcpOnce(); + unsubscribeStartNavigation?.(); + }); + + const activeSpan = getActiveSpan(); + if (activeSpan) { + const rootSpan = getRootSpan(activeSpan); + const spanJSON = spanToJSON(rootSpan); + if (spanJSON.op === 'pageload') { + pageloadSpanId = rootSpan.spanContext().spanId; + } + } + }, 0); +} + +function sendStandaloneLcpSpan(lcpValue: number, entry: LargestContentfulPaint | undefined, pageloadSpanId: string) { + DEBUG_BUILD && logger.log(`Sending LCP span (${lcpValue})`); + + const startTime = msToSec((browserPerformanceTimeOrigin() || 0) + (entry?.startTime || 0)); + const routeName = getCurrentScope().getScopeData().transactionName; + + const name = entry ? htmlTreeAsString(entry.element) : 'Largest contentful paint'; + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser.lcp', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.webvital.lcp', + [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, + }; + + if (entry) { + attributes['lcp.element'] = htmlTreeAsString(entry.element); + attributes['lcp.id'] = entry.id; + attributes['lcp.url'] = entry.url; + attributes['lcp.loadTime'] = entry.loadTime; + attributes['lcp.renderTime'] = entry.renderTime; + attributes['lcp.size'] = entry.size; + } + + const span = startStandaloneWebVitalSpan({ + name, + transaction: routeName, + attributes, + startTime, + }); + + if (span) { + span.addEvent('lcp', { + [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: 'millisecond', + [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: lcpValue, + }); + + // LCP is a point-in-time metric, so we end the span immediately + span.end(startTime); + } +} + +function supportsLargestContentfulPaint(): boolean { + try { + return PerformanceObserver.supportedEntryTypes.includes('largest-contentful-paint'); + } catch { + return false; + } +} diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 1ba733ac4ca8..af742310c37f 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -236,6 +236,7 @@ export interface BrowserTracingOptions { _experiments: Partial<{ enableInteractions: boolean; enableStandaloneClsSpans: boolean; + enableStandaloneLcpSpans: boolean; }>; /** @@ -301,7 +302,7 @@ export const browserTracingIntegration = ((_options: Partial