diff --git a/.size-limit.js b/.size-limit.js
index dc8e4f9df560..685b40b00fbe 100644
--- a/.size-limit.js
+++ b/.size-limit.js
@@ -38,7 +38,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
- limit: '40 KB',
+ limit: '40.7 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay)',
@@ -75,7 +75,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'),
gzip: true,
- limit: '82 KB',
+ limit: '83 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay, Feedback)',
@@ -135,7 +135,7 @@ module.exports = [
path: 'packages/vue/build/esm/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
- limit: '41 KB',
+ limit: '42 KB',
},
// Svelte SDK (ESM)
{
@@ -206,7 +206,7 @@ module.exports = [
import: createImport('init'),
ignore: ['next/router', 'next/constants'],
gzip: true,
- limit: '43 KB',
+ limit: '44 KB',
},
// SvelteKit SDK (ESM)
{
@@ -215,7 +215,7 @@ module.exports = [
import: createImport('init'),
ignore: ['$app/stores'],
gzip: true,
- limit: '40 KB',
+ limit: '41 KB',
},
// Node SDK (ESM)
{
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/assets/sentry-logo-600x179.png b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/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/element-timing/assets/sentry-logo-600x179.png differ
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/init.js
new file mode 100644
index 000000000000..5a4cb2dff8b7
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/init.js
@@ -0,0 +1,10 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+
+Sentry.init({
+ debug: true,
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ integrations: [Sentry.browserTracingIntegration()],
+ tracesSampleRate: 1,
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/subject.js
new file mode 100644
index 000000000000..0fff6c2a88e6
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/subject.js
@@ -0,0 +1,27 @@
+const lazyDiv = document.getElementById('content-lazy');
+const navigationButton = document.getElementById('button1');
+const navigationDiv = document.getElementById('content-navigation');
+const clickButton = document.getElementById('button2');
+const clickDiv = document.getElementById('content-click');
+
+navigationButton.addEventListener('click', () => {
+ window.history.pushState({}, '', '/some-other-path');
+ navigationDiv.innerHTML = `
+
+
This is navigation content
+ `;
+});
+
+setTimeout(() => {
+ lazyDiv.innerHTML = `
+
+ This is lazy loaded content
+ `;
+}, 1000);
+
+clickButton.addEventListener('click', () => {
+ clickDiv.innerHTML = `
+
+ This is click loaded content
+ `;
+});
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/template.html
new file mode 100644
index 000000000000..6f536f8d2aa4
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/template.html
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+ This is some text content
+ with another nested span
+ and a small text
+
+
+
+
+
Header with element timing
+

+
+
+
+
+
+
+
+
This div will be populated lazily
+
+
+
+
+
This div will be populated after a navigation
+
+
+
+
+
This div will be populated on click
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts
new file mode 100644
index 000000000000..e17cbbbda691
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts
@@ -0,0 +1,232 @@
+import type { Page, Route } from '@playwright/test';
+import { expect } from '@playwright/test';
+import { sentryTest } from '../../../../utils/fixtures';
+import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers';
+
+sentryTest(
+ 'adds element timing spans to pageload span tree for elements rendered during pageload',
+ async ({ getLocalTestUrl, page, browserName }) => {
+ if (shouldSkipTracingTest() || browserName === 'webkit') {
+ sentryTest.skip();
+ }
+
+ const pageloadEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload');
+
+ serveAssets(page);
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.goto(url);
+
+ const eventData = envelopeRequestParser(await pageloadEventPromise);
+
+ const elementTimingSpans = eventData.spans?.filter(({ op }) => op === 'ui.elementtiming');
+
+ expect(elementTimingSpans?.length).toEqual(8);
+
+ // Check image-fast span (this is served with a 100ms delay)
+ const imageFastSpan = elementTimingSpans?.find(({ description }) => description === 'element[image-fast]');
+ const imageFastRenderTime = imageFastSpan?.data['element.render_time'];
+ const imageFastLoadTime = imageFastSpan?.data['element.load_time'];
+ const duration = imageFastSpan!.timestamp! - imageFastSpan!.start_timestamp!;
+
+ expect(imageFastSpan).toBeDefined();
+ expect(imageFastSpan?.data).toEqual({
+ 'sentry.op': 'ui.elementtiming',
+ 'sentry.origin': 'auto.ui.browser.elementtiming',
+ 'sentry.source': 'component',
+ 'sentry.span_start_time_source': 'load-time',
+ 'element.id': 'image-fast-id',
+ 'element.identifier': 'image-fast',
+ 'element.type': 'img',
+ 'element.size': '600x179',
+ 'element.url': 'https://sentry-test-site.example/path/to/image-fast.png',
+ 'element.render_time': expect.any(Number),
+ 'element.load_time': expect.any(Number),
+ 'element.paint_type': 'image-paint',
+ 'sentry.transaction_name': '/index.html',
+ });
+ expect(imageFastRenderTime).toBeGreaterThan(90);
+ expect(imageFastRenderTime).toBeLessThan(400);
+ expect(imageFastLoadTime).toBeGreaterThan(90);
+ expect(imageFastLoadTime).toBeLessThan(400);
+ expect(imageFastRenderTime).toBeGreaterThan(imageFastLoadTime as number);
+ expect(duration).toBeGreaterThan(0);
+ expect(duration).toBeLessThan(20);
+
+ // Check text1 span
+ const text1Span = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'text1');
+ const text1RenderTime = text1Span?.data['element.render_time'];
+ const text1LoadTime = text1Span?.data['element.load_time'];
+ const text1Duration = text1Span!.timestamp! - text1Span!.start_timestamp!;
+ expect(text1Span).toBeDefined();
+ expect(text1Span?.data).toEqual({
+ 'sentry.op': 'ui.elementtiming',
+ 'sentry.origin': 'auto.ui.browser.elementtiming',
+ 'sentry.source': 'component',
+ 'sentry.span_start_time_source': 'render-time',
+ 'element.id': 'text1-id',
+ 'element.identifier': 'text1',
+ 'element.type': 'p',
+ 'element.render_time': expect.any(Number),
+ 'element.load_time': expect.any(Number),
+ 'element.paint_type': 'text-paint',
+ 'sentry.transaction_name': '/index.html',
+ });
+ expect(text1RenderTime).toBeGreaterThan(0);
+ expect(text1RenderTime).toBeLessThan(300);
+ expect(text1LoadTime).toBe(0);
+ expect(text1RenderTime).toBeGreaterThan(text1LoadTime as number);
+ expect(text1Duration).toBe(0);
+
+ // Check button1 span (no need for a full assertion)
+ const button1Span = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'button1');
+ expect(button1Span).toBeDefined();
+ expect(button1Span?.data).toMatchObject({
+ 'element.identifier': 'button1',
+ 'element.type': 'button',
+ 'element.paint_type': 'text-paint',
+ 'sentry.transaction_name': '/index.html',
+ });
+
+ // Check image-slow span
+ const imageSlowSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'image-slow');
+ expect(imageSlowSpan).toBeDefined();
+ expect(imageSlowSpan?.data).toEqual({
+ 'element.id': '',
+ 'element.identifier': 'image-slow',
+ 'element.type': 'img',
+ 'element.size': '600x179',
+ 'element.url': 'https://sentry-test-site.example/path/to/image-slow.png',
+ 'element.paint_type': 'image-paint',
+ 'element.render_time': expect.any(Number),
+ 'element.load_time': expect.any(Number),
+ 'sentry.op': 'ui.elementtiming',
+ 'sentry.origin': 'auto.ui.browser.elementtiming',
+ 'sentry.source': 'component',
+ 'sentry.span_start_time_source': 'load-time',
+ 'sentry.transaction_name': '/index.html',
+ });
+ const imageSlowRenderTime = imageSlowSpan?.data['element.render_time'];
+ const imageSlowLoadTime = imageSlowSpan?.data['element.load_time'];
+ const imageSlowDuration = imageSlowSpan!.timestamp! - imageSlowSpan!.start_timestamp!;
+ expect(imageSlowRenderTime).toBeGreaterThan(1400);
+ expect(imageSlowRenderTime).toBeLessThan(2000);
+ expect(imageSlowLoadTime).toBeGreaterThan(1400);
+ expect(imageSlowLoadTime).toBeLessThan(2000);
+ expect(imageSlowDuration).toBeGreaterThan(0);
+ expect(imageSlowDuration).toBeLessThan(20);
+
+ // Check lazy-image span
+ const lazyImageSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'lazy-image');
+ expect(lazyImageSpan).toBeDefined();
+ expect(lazyImageSpan?.data).toEqual({
+ 'element.id': '',
+ 'element.identifier': 'lazy-image',
+ 'element.type': 'img',
+ 'element.size': '600x179',
+ 'element.url': 'https://sentry-test-site.example/path/to/image-lazy.png',
+ 'element.paint_type': 'image-paint',
+ 'element.render_time': expect.any(Number),
+ 'element.load_time': expect.any(Number),
+ 'sentry.op': 'ui.elementtiming',
+ 'sentry.origin': 'auto.ui.browser.elementtiming',
+ 'sentry.source': 'component',
+ 'sentry.span_start_time_source': 'load-time',
+ 'sentry.transaction_name': '/index.html',
+ });
+ const lazyImageRenderTime = lazyImageSpan?.data['element.render_time'];
+ const lazyImageLoadTime = lazyImageSpan?.data['element.load_time'];
+ const lazyImageDuration = lazyImageSpan!.timestamp! - lazyImageSpan!.start_timestamp!;
+ expect(lazyImageRenderTime).toBeGreaterThan(1000);
+ expect(lazyImageRenderTime).toBeLessThan(1500);
+ expect(lazyImageLoadTime).toBeGreaterThan(1000);
+ expect(lazyImageLoadTime).toBeLessThan(1500);
+ expect(lazyImageDuration).toBeGreaterThan(0);
+ expect(lazyImageDuration).toBeLessThan(20);
+
+ // Check lazy-text span
+ const lazyTextSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'lazy-text');
+ expect(lazyTextSpan?.data).toMatchObject({
+ 'element.id': '',
+ 'element.identifier': 'lazy-text',
+ 'element.type': 'p',
+ 'sentry.transaction_name': '/index.html',
+ });
+ const lazyTextRenderTime = lazyTextSpan?.data['element.render_time'];
+ const lazyTextLoadTime = lazyTextSpan?.data['element.load_time'];
+ const lazyTextDuration = lazyTextSpan!.timestamp! - lazyTextSpan!.start_timestamp!;
+ expect(lazyTextRenderTime).toBeGreaterThan(1000);
+ expect(lazyTextRenderTime).toBeLessThan(1500);
+ expect(lazyTextLoadTime).toBe(0);
+ expect(lazyTextDuration).toBe(0);
+
+ // the div1 entry does not emit an elementTiming entry because it's neither a text nor an image
+ expect(elementTimingSpans?.find(({ description }) => description === 'element[div1]')).toBeUndefined();
+ },
+);
+
+sentryTest('emits element timing spans on navigation', async ({ getLocalTestUrl, page, browserName }) => {
+ if (shouldSkipTracingTest() || browserName === 'webkit') {
+ sentryTest.skip();
+ }
+
+ serveAssets(page);
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.goto(url);
+
+ const pageloadEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload');
+
+ const navigationEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'navigation');
+
+ await pageloadEventPromise;
+
+ await page.locator('#button1').click();
+
+ const navigationTransactionEvent = envelopeRequestParser(await navigationEventPromise);
+ const pageloadTransactionEvent = envelopeRequestParser(await pageloadEventPromise);
+
+ const navigationElementTimingSpans = navigationTransactionEvent.spans?.filter(({ op }) => op === 'ui.elementtiming');
+
+ expect(navigationElementTimingSpans?.length).toEqual(2);
+
+ const navigationStartTime = navigationTransactionEvent.start_timestamp!;
+ const pageloadStartTime = pageloadTransactionEvent.start_timestamp!;
+
+ const imageSpan = navigationElementTimingSpans?.find(
+ ({ description }) => description === 'element[navigation-image]',
+ );
+ const textSpan = navigationElementTimingSpans?.find(({ description }) => description === 'element[navigation-text]');
+
+ // Image started loading after navigation, but render-time and load-time still start from the time origin
+ // of the pageload. This is somewhat a limitation (though by design according to the ElementTiming spec)
+ expect((imageSpan!.data['element.render_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan(
+ navigationStartTime,
+ );
+ expect((imageSpan!.data['element.load_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan(
+ navigationStartTime,
+ );
+
+ expect(textSpan?.data['element.load_time']).toBe(0);
+ expect((textSpan!.data['element.render_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan(
+ navigationStartTime,
+ );
+});
+
+function serveAssets(page: Page) {
+ page.route(/image-(fast|lazy|navigation|click)\.png/, async (route: Route) => {
+ await new Promise(resolve => setTimeout(resolve, 100));
+ return route.fulfill({
+ path: `${__dirname}/assets/sentry-logo-600x179.png`,
+ });
+ });
+
+ page.route('**/image-slow.png', async (route: Route) => {
+ await new Promise(resolve => setTimeout(resolve, 1500));
+ return route.fulfill({
+ path: `${__dirname}/assets/sentry-logo-600x179.png`,
+ });
+ });
+}
diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts
index f66446ea5159..0a2d9e85ade9 100644
--- a/packages/browser-utils/src/index.ts
+++ b/packages/browser-utils/src/index.ts
@@ -17,6 +17,8 @@ export {
registerInpInteractionListener,
} from './metrics/browserMetrics';
+export { startTrackingElementTiming } from './metrics/elementTiming';
+
export { extractNetworkProtocol } from './metrics/utils';
export { addClickKeypressInstrumentationHandler } from './instrument/dom';
diff --git a/packages/browser-utils/src/metrics/elementTiming.ts b/packages/browser-utils/src/metrics/elementTiming.ts
new file mode 100644
index 000000000000..f746b16645af
--- /dev/null
+++ b/packages/browser-utils/src/metrics/elementTiming.ts
@@ -0,0 +1,121 @@
+import type { SpanAttributes } from '@sentry/core';
+import {
+ browserPerformanceTimeOrigin,
+ getActiveSpan,
+ getCurrentScope,
+ getRootSpan,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+ spanToJSON,
+ startSpan,
+ timestampInSeconds,
+} from '@sentry/core';
+import { addPerformanceInstrumentationHandler } from './instrument';
+import { getBrowserPerformanceAPI, msToSec } from './utils';
+
+// ElementTiming interface based on the W3C spec
+interface PerformanceElementTiming extends PerformanceEntry {
+ renderTime: number;
+ loadTime: number;
+ intersectionRect: DOMRectReadOnly;
+ identifier: string;
+ naturalWidth: number;
+ naturalHeight: number;
+ id: string;
+ element: Element | null;
+ url?: string;
+}
+
+/**
+ * Start tracking ElementTiming performance entries.
+ */
+export function startTrackingElementTiming(): () => void {
+ const performance = getBrowserPerformanceAPI();
+ if (performance && browserPerformanceTimeOrigin()) {
+ return addPerformanceInstrumentationHandler('element', _onElementTiming);
+ }
+
+ return () => undefined;
+}
+
+/**
+ * exported only for testing
+ */
+export const _onElementTiming = ({ entries }: { entries: PerformanceEntry[] }): void => {
+ const activeSpan = getActiveSpan();
+ const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined;
+ const transactionName = rootSpan
+ ? spanToJSON(rootSpan).description
+ : getCurrentScope().getScopeData().transactionName;
+
+ entries.forEach(entry => {
+ const elementEntry = entry as PerformanceElementTiming;
+
+ // Skip entries without identifier (elementtiming attribute)
+ if (!elementEntry.identifier) {
+ return;
+ }
+
+ // `name` contains the type of the element paint. Can be `'image-paint'` or `'text-paint'`.
+ // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming#instance_properties
+ const paintType = elementEntry.name as 'image-paint' | 'text-paint' | undefined;
+
+ const renderTime = elementEntry.renderTime;
+ const loadTime = elementEntry.loadTime;
+
+ // starting the span at:
+ // - `loadTime` if available (should be available for all "image-paint" entries, 0 otherwise)
+ // - `renderTime` if available (available for all entries, except 3rd party images, but these should be covered by `loadTime`, 0 otherwise)
+ // - `timestampInSeconds()` as a safeguard
+ // see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming/renderTime#cross-origin_image_render_time
+ const [spanStartTime, spanStartTimeSource] = loadTime
+ ? [msToSec(loadTime), 'load-time']
+ : renderTime
+ ? [msToSec(renderTime), 'render-time']
+ : [timestampInSeconds(), 'entry-emission'];
+
+ const duration =
+ paintType === 'image-paint'
+ ? // for image paints, we can acually get a duration because image-paint entries also have a `loadTime`
+ // and `renderTime`. `loadTime` is the time when the image finished loading and `renderTime` is the
+ // time when the image finished rendering.
+ msToSec(Math.max(0, (renderTime ?? 0) - (loadTime ?? 0)))
+ : // for `'text-paint'` entries, we can't get a duration because the `loadTime` is always zero.
+ 0;
+
+ const attributes: SpanAttributes = {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.browser.elementtiming',
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.elementtiming',
+ // name must be user-entered, so we can assume low cardinality
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
+ // recording the source of the span start time, as it varies depending on available data
+ 'sentry.span_start_time_source': spanStartTimeSource,
+ 'sentry.transaction_name': transactionName,
+ 'element.id': elementEntry.id,
+ 'element.type': elementEntry.element?.tagName?.toLowerCase() || 'unknown',
+ 'element.size':
+ elementEntry.naturalWidth && elementEntry.naturalHeight
+ ? `${elementEntry.naturalWidth}x${elementEntry.naturalHeight}`
+ : undefined,
+ 'element.render_time': renderTime,
+ 'element.load_time': loadTime,
+ // `url` is `0`(number) for text paints (hence we fall back to undefined)
+ 'element.url': elementEntry.url || undefined,
+ 'element.identifier': elementEntry.identifier,
+ 'element.paint_type': paintType,
+ };
+
+ startSpan(
+ {
+ name: `element[${elementEntry.identifier}]`,
+ attributes,
+ startTime: spanStartTime,
+ onlyIfParent: true,
+ },
+ span => {
+ span.end(spanStartTime + duration);
+ },
+ );
+ });
+};
diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts
index cb84908ce55b..9fbf075a7712 100644
--- a/packages/browser-utils/src/metrics/instrument.ts
+++ b/packages/browser-utils/src/metrics/instrument.ts
@@ -13,7 +13,8 @@ type InstrumentHandlerTypePerformanceObserver =
| 'navigation'
| 'paint'
| 'resource'
- | 'first-input';
+ | 'first-input'
+ | 'element';
type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid' | 'ttfb' | 'inp';
diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts b/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts
index 9af0116cd0b1..6071893dfa8e 100644
--- a/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts
+++ b/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts
@@ -28,6 +28,9 @@ interface PerformanceEntryMap {
// our `instrumentPerformanceObserver` function also observes 'longtask'
// entries.
longtask: PerformanceEntry[];
+ // Sentry-specific change:
+ // We add element as a supported entry type for ElementTiming API
+ element: PerformanceEntry[];
}
/**
diff --git a/packages/browser-utils/test/instrument/metrics/elementTiming.test.ts b/packages/browser-utils/test/instrument/metrics/elementTiming.test.ts
new file mode 100644
index 000000000000..04456ceadc44
--- /dev/null
+++ b/packages/browser-utils/test/instrument/metrics/elementTiming.test.ts
@@ -0,0 +1,369 @@
+import * as sentryCore from '@sentry/core';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { _onElementTiming, startTrackingElementTiming } from '../../../src/metrics/elementTiming';
+import * as browserMetricsInstrumentation from '../../../src/metrics/instrument';
+import * as browserMetricsUtils from '../../../src/metrics/utils';
+
+describe('_onElementTiming', () => {
+ const spanEndSpy = vi.fn();
+ const startSpanSpy = vi.spyOn(sentryCore, 'startSpan').mockImplementation((opts, cb) => {
+ // @ts-expect-error - only passing a partial span. This is fine for the test.
+ cb({
+ end: spanEndSpy,
+ });
+ });
+
+ beforeEach(() => {
+ startSpanSpy.mockClear();
+ spanEndSpy.mockClear();
+ });
+
+ it('does nothing if the ET entry has no identifier', () => {
+ const entry = {
+ name: 'image-paint',
+ entryType: 'element',
+ startTime: 0,
+ duration: 0,
+ renderTime: 100,
+ } as Partial;
+
+ // @ts-expect-error - only passing a partial entry. This is fine for the test.
+ _onElementTiming({ entries: [entry] });
+
+ expect(startSpanSpy).not.toHaveBeenCalled();
+ });
+
+ describe('span start time', () => {
+ it('uses the load time as span start time if available', () => {
+ const entry = {
+ name: 'image-paint',
+ entryType: 'element',
+ startTime: 0,
+ duration: 0,
+ renderTime: 100,
+ loadTime: 50,
+ identifier: 'test-element',
+ } as Partial;
+
+ // @ts-expect-error - only passing a partial entry. This is fine for the test.
+ _onElementTiming({ entries: [entry] });
+
+ expect(startSpanSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'element[test-element]',
+ startTime: 0.05,
+ attributes: expect.objectContaining({
+ 'sentry.op': 'ui.elementtiming',
+ 'sentry.origin': 'auto.ui.browser.elementtiming',
+ 'sentry.source': 'component',
+ 'sentry.span_start_time_source': 'load-time',
+ 'element.render_time': 100,
+ 'element.load_time': 50,
+ 'element.identifier': 'test-element',
+ 'element.paint_type': 'image-paint',
+ }),
+ }),
+ expect.any(Function),
+ );
+ });
+
+ it('uses the render time as span start time if load time is not available', () => {
+ const entry = {
+ name: 'image-paint',
+ entryType: 'element',
+ startTime: 0,
+ duration: 0,
+ renderTime: 100,
+ identifier: 'test-element',
+ } as Partial;
+
+ // @ts-expect-error - only passing a partial entry. This is fine for the test.
+ _onElementTiming({ entries: [entry] });
+
+ expect(startSpanSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'element[test-element]',
+ startTime: 0.1,
+ attributes: expect.objectContaining({
+ 'sentry.op': 'ui.elementtiming',
+ 'sentry.origin': 'auto.ui.browser.elementtiming',
+ 'sentry.source': 'component',
+ 'sentry.span_start_time_source': 'render-time',
+ 'element.render_time': 100,
+ 'element.load_time': undefined,
+ 'element.identifier': 'test-element',
+ 'element.paint_type': 'image-paint',
+ }),
+ }),
+ expect.any(Function),
+ );
+ });
+
+ it('falls back to the time of handling the entry if load and render time are not available', () => {
+ const entry = {
+ name: 'image-paint',
+ entryType: 'element',
+ startTime: 0,
+ duration: 0,
+ identifier: 'test-element',
+ } as Partial;
+
+ // @ts-expect-error - only passing a partial entry. This is fine for the test.
+ _onElementTiming({ entries: [entry] });
+
+ expect(startSpanSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'element[test-element]',
+ startTime: expect.any(Number),
+ attributes: expect.objectContaining({
+ 'sentry.op': 'ui.elementtiming',
+ 'sentry.origin': 'auto.ui.browser.elementtiming',
+ 'sentry.source': 'component',
+ 'sentry.span_start_time_source': 'entry-emission',
+ 'element.render_time': undefined,
+ 'element.load_time': undefined,
+ 'element.identifier': 'test-element',
+ 'element.paint_type': 'image-paint',
+ }),
+ }),
+ expect.any(Function),
+ );
+ });
+ });
+
+ describe('span duration', () => {
+ it('uses (render-load) time as duration for image paints', () => {
+ const entry = {
+ name: 'image-paint',
+ entryType: 'element',
+ startTime: 0,
+ duration: 0,
+ renderTime: 1505,
+ loadTime: 1500,
+ identifier: 'test-element',
+ } as Partial;
+
+ // @ts-expect-error - only passing a partial entry. This is fine for the test.
+ _onElementTiming({ entries: [entry] });
+
+ expect(startSpanSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'element[test-element]',
+ startTime: 1.5,
+ attributes: expect.objectContaining({
+ 'element.render_time': 1505,
+ 'element.load_time': 1500,
+ 'element.paint_type': 'image-paint',
+ }),
+ }),
+ expect.any(Function),
+ );
+
+ expect(spanEndSpy).toHaveBeenCalledWith(1.505);
+ });
+
+ it('uses 0 as duration for text paints', () => {
+ const entry = {
+ name: 'text-paint',
+ entryType: 'element',
+ startTime: 0,
+ duration: 0,
+ loadTime: 0,
+ renderTime: 1600,
+ identifier: 'test-element',
+ } as Partial;
+
+ // @ts-expect-error - only passing a partial entry. This is fine for the test.
+ _onElementTiming({ entries: [entry] });
+
+ expect(startSpanSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'element[test-element]',
+ startTime: 1.6,
+ attributes: expect.objectContaining({
+ 'element.paint_type': 'text-paint',
+ 'element.render_time': 1600,
+ 'element.load_time': 0,
+ }),
+ }),
+ expect.any(Function),
+ );
+
+ expect(spanEndSpy).toHaveBeenCalledWith(1.6);
+ });
+
+ // per spec, no other kinds are supported but let's make sure we're defensive
+ it('uses 0 as duration for other kinds of entries', () => {
+ const entry = {
+ name: 'somethingelse',
+ entryType: 'element',
+ startTime: 0,
+ duration: 0,
+ loadTime: 0,
+ renderTime: 1700,
+ identifier: 'test-element',
+ } as Partial;
+
+ // @ts-expect-error - only passing a partial entry. This is fine for the test.
+ _onElementTiming({ entries: [entry] });
+
+ expect(startSpanSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'element[test-element]',
+ startTime: 1.7,
+ attributes: expect.objectContaining({
+ 'element.paint_type': 'somethingelse',
+ 'element.render_time': 1700,
+ 'element.load_time': 0,
+ }),
+ }),
+ expect.any(Function),
+ );
+
+ expect(spanEndSpy).toHaveBeenCalledWith(1.7);
+ });
+ });
+
+ describe('span attributes', () => {
+ it('sets element type, identifier, paint type, load and render time', () => {
+ const entry = {
+ name: 'image-paint',
+ entryType: 'element',
+ startTime: 0,
+ duration: 0,
+ renderTime: 100,
+ identifier: 'my-image',
+ element: {
+ tagName: 'IMG',
+ },
+ } as Partial;
+
+ // @ts-expect-error - only passing a partial entry. This is fine for the test.
+ _onElementTiming({ entries: [entry] });
+
+ expect(startSpanSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ 'element.type': 'img',
+ 'element.identifier': 'my-image',
+ 'element.paint_type': 'image-paint',
+ 'element.render_time': 100,
+ 'element.load_time': undefined,
+ 'element.size': undefined,
+ 'element.url': undefined,
+ }),
+ }),
+ expect.any(Function),
+ );
+ });
+
+ it('sets element size if available', () => {
+ const entry = {
+ name: 'image-paint',
+ entryType: 'element',
+ startTime: 0,
+ duration: 0,
+ renderTime: 100,
+ naturalWidth: 512,
+ naturalHeight: 256,
+ identifier: 'my-image',
+ } as Partial;
+
+ // @ts-expect-error - only passing a partial entry. This is fine for the test.
+ _onElementTiming({ entries: [entry] });
+
+ expect(startSpanSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ 'element.size': '512x256',
+ 'element.identifier': 'my-image',
+ }),
+ }),
+ expect.any(Function),
+ );
+ });
+
+ it('sets element url if available', () => {
+ const entry = {
+ name: 'image-paint',
+ entryType: 'element',
+ startTime: 0,
+ duration: 0,
+ url: 'https://santry.com/image.png',
+ identifier: 'my-image',
+ } as Partial;
+
+ // @ts-expect-error - only passing a partial entry. This is fine for the test.
+ _onElementTiming({ entries: [entry] });
+
+ expect(startSpanSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ 'element.identifier': 'my-image',
+ 'element.url': 'https://santry.com/image.png',
+ }),
+ }),
+ expect.any(Function),
+ );
+ });
+
+ it('sets sentry attributes', () => {
+ const entry = {
+ name: 'image-paint',
+ entryType: 'element',
+ startTime: 0,
+ duration: 0,
+ renderTime: 100,
+ identifier: 'my-image',
+ } as Partial;
+
+ // @ts-expect-error - only passing a partial entry. This is fine for the test.
+ _onElementTiming({ entries: [entry] });
+
+ expect(startSpanSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ attributes: expect.objectContaining({
+ 'sentry.op': 'ui.elementtiming',
+ 'sentry.origin': 'auto.ui.browser.elementtiming',
+ 'sentry.source': 'component',
+ 'sentry.span_start_time_source': 'render-time',
+ 'sentry.transaction_name': undefined,
+ }),
+ }),
+ expect.any(Function),
+ );
+ });
+ });
+});
+
+describe('startTrackingElementTiming', () => {
+ const addInstrumentationHandlerSpy = vi.spyOn(browserMetricsInstrumentation, 'addPerformanceInstrumentationHandler');
+
+ beforeEach(() => {
+ addInstrumentationHandlerSpy.mockClear();
+ });
+
+ it('returns a function that does nothing if the browser does not support the performance API', () => {
+ vi.spyOn(browserMetricsUtils, 'getBrowserPerformanceAPI').mockReturnValue(undefined);
+ expect(typeof startTrackingElementTiming()).toBe('function');
+
+ expect(addInstrumentationHandlerSpy).not.toHaveBeenCalled();
+ });
+
+ it('adds an instrumentation handler for elementtiming entries, if the browser supports the performance API', () => {
+ vi.spyOn(browserMetricsUtils, 'getBrowserPerformanceAPI').mockReturnValue({
+ getEntriesByType: vi.fn().mockReturnValue([]),
+ } as unknown as Performance);
+
+ const addInstrumentationHandlerSpy = vi.spyOn(
+ browserMetricsInstrumentation,
+ 'addPerformanceInstrumentationHandler',
+ );
+
+ const stopTracking = startTrackingElementTiming();
+
+ expect(typeof stopTracking).toBe('function');
+
+ expect(addInstrumentationHandlerSpy).toHaveBeenCalledWith('element', expect.any(Function));
+ });
+});
diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts
index af742310c37f..e1815cc7bf39 100644
--- a/packages/browser/src/tracing/browserTracingIntegration.ts
+++ b/packages/browser/src/tracing/browserTracingIntegration.ts
@@ -26,6 +26,7 @@ import {
addHistoryInstrumentationHandler,
addPerformanceEntries,
registerInpInteractionListener,
+ startTrackingElementTiming,
startTrackingINP,
startTrackingInteractions,
startTrackingLongAnimationFrames,
@@ -115,6 +116,14 @@ export interface BrowserTracingOptions {
*/
enableInp: boolean;
+ /**
+ * If true, Sentry will capture [element timing](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming)
+ * information and add it to the corresponding transaction.
+ *
+ * Default: true
+ */
+ enableElementTiming: boolean;
+
/**
* Flag to disable patching all together for fetch requests.
*
@@ -269,6 +278,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = {
enableLongTask: true,
enableLongAnimationFrame: true,
enableInp: true,
+ enableElementTiming: true,
ignoreResourceSpans: [],
ignorePerformanceApiSpans: [],
linkPreviousTrace: 'in-memory',
@@ -300,6 +310,7 @@ export const browserTracingIntegration = ((_options: Partial