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