From aee600b7144b10ff6885f7b0ca66463994fe52d8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 19 Mar 2026 02:07:33 -0400 Subject: [PATCH 01/20] feat(browser): Replace element timing spans with metrics via standalone integration Remove element timing span creation from browserTracingIntegration and introduce a new elementTimingIntegration that emits Element Timing API data as Sentry distribution metrics instead of spans. Element timing values (renderTime, loadTime) are point-in-time timestamps, not durations, making metrics a better fit than spans. The new integration emits `element_timing.render_time` and `element_timing.load_time` metrics with `element.identifier` and `element.paint_type` attributes. refs JS-1678 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser-utils/src/index.ts | 2 +- .../src/metrics/elementTiming.ts | 161 +++---- .../test/metrics/elementTiming.test.ts | 452 +++++------------- packages/browser/src/index.ts | 1 + .../src/tracing/browserTracingIntegration.ts | 12 +- 5 files changed, 185 insertions(+), 443 deletions(-) diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index a4d0960b1ccb..2b2d4b7f9397 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -16,7 +16,7 @@ export { registerInpInteractionListener, } from './metrics/browserMetrics'; -export { startTrackingElementTiming } from './metrics/elementTiming'; +export { elementTimingIntegration, startTrackingElementTiming } from './metrics/elementTiming'; export { extractNetworkProtocol } from './metrics/utils'; diff --git a/packages/browser-utils/src/metrics/elementTiming.ts b/packages/browser-utils/src/metrics/elementTiming.ts index f746b16645af..b7d51e9fa783 100644 --- a/packages/browser-utils/src/metrics/elementTiming.ts +++ b/packages/browser-utils/src/metrics/elementTiming.ts @@ -1,18 +1,7 @@ -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 type { IntegrationFn } from '@sentry/core'; +import { browserPerformanceTimeOrigin, defineIntegration, metrics } from '@sentry/core'; import { addPerformanceInstrumentationHandler } from './instrument'; -import { getBrowserPerformanceAPI, msToSec } from './utils'; +import { getBrowserPerformanceAPI } from './utils'; // ElementTiming interface based on the W3C spec interface PerformanceElementTiming extends PerformanceEntry { @@ -27,95 +16,75 @@ interface PerformanceElementTiming extends PerformanceEntry { url?: string; } -/** - * Start tracking ElementTiming performance entries. - */ -export function startTrackingElementTiming(): () => void { - const performance = getBrowserPerformanceAPI(); - if (performance && browserPerformanceTimeOrigin()) { - return addPerformanceInstrumentationHandler('element', _onElementTiming); - } +const INTEGRATION_NAME = 'ElementTiming'; - return () => undefined; -} +const _elementTimingIntegration = (() => { + return { + name: INTEGRATION_NAME, + setup() { + const performance = getBrowserPerformanceAPI(); + if (!performance || !browserPerformanceTimeOrigin()) { + return; + } -/** - * 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; + addPerformanceInstrumentationHandler('element', ({ entries }) => { + for (const entry of entries) { + const elementEntry = entry as PerformanceElementTiming; - entries.forEach(entry => { - const elementEntry = entry as PerformanceElementTiming; + if (!elementEntry.identifier) { + continue; + } - // Skip entries without identifier (elementtiming attribute) - if (!elementEntry.identifier) { - return; - } + const identifier = elementEntry.identifier; + const paintType = elementEntry.name as 'image-paint' | 'text-paint' | undefined; + const renderTime = elementEntry.renderTime; + const loadTime = elementEntry.loadTime; - // `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 metricAttributes: Record = { + 'element.identifier': identifier, + }; - const renderTime = elementEntry.renderTime; - const loadTime = elementEntry.loadTime; + if (paintType) { + metricAttributes['element.paint_type'] = paintType; + } - // 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']; + if (renderTime) { + metrics.distribution(`element_timing.render_time`, renderTime, { + unit: 'millisecond', + attributes: metricAttributes, + }); + } - 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; + if (loadTime) { + metrics.distribution(`element_timing.load_time`, loadTime, { + unit: 'millisecond', + attributes: metricAttributes, + }); + } + } + }); + }, + }; +}) satisfies IntegrationFn; - 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, - }; +/** + * Captures [Element Timing API](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming) + * data as Sentry metrics. + * + * To mark an element for tracking, add the `elementtiming` HTML attribute: + * ```html + * + *

Welcome!

+ * ``` + * + * This emits `element_timing.render_time` and `element_timing.load_time` (for images) + * as distribution metrics, tagged with the element's identifier and paint type. + */ +export const elementTimingIntegration = defineIntegration(_elementTimingIntegration); - startSpan( - { - name: `element[${elementEntry.identifier}]`, - attributes, - startTime: spanStartTime, - onlyIfParent: true, - }, - span => { - span.end(spanStartTime + duration); - }, - ); - }); -}; +/** + * @deprecated Use `elementTimingIntegration` instead. This function is a no-op and will be removed in a future version. + */ +export function startTrackingElementTiming(): () => void { + return () => undefined; +} diff --git a/packages/browser-utils/test/metrics/elementTiming.test.ts b/packages/browser-utils/test/metrics/elementTiming.test.ts index 14431415873b..0613508a7b32 100644 --- a/packages/browser-utils/test/metrics/elementTiming.test.ts +++ b/packages/browser-utils/test/metrics/elementTiming.test.ts @@ -1,369 +1,149 @@ import * as sentryCore from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { _onElementTiming, startTrackingElementTiming } from '../../src/metrics/elementTiming'; +import { elementTimingIntegration, 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, - }); - }); +describe('elementTimingIntegration', () => { + const distributionSpy = vi.spyOn(sentryCore.metrics, 'distribution'); - beforeEach(() => { - startSpanSpy.mockClear(); - spanEndSpy.mockClear(); - }); + let elementHandler: (data: { entries: PerformanceEntry[] }) => void; - 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; + beforeEach(() => { + distributionSpy.mockClear(); - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); + vi.spyOn(browserMetricsUtils, 'getBrowserPerformanceAPI').mockReturnValue({ + getEntriesByType: vi.fn().mockReturnValue([]), + } as unknown as Performance); - expect(startSpanSpy).not.toHaveBeenCalled(); + vi.spyOn(browserMetricsInstrumentation, 'addPerformanceInstrumentationHandler').mockImplementation( + (type, handler) => { + if (type === 'element') { + elementHandler = handler; + } + return () => undefined; + }, + ); }); - 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), - ); + function setupIntegration(): void { + const integration = elementTimingIntegration(); + integration.setup({} as sentryCore.Client); + } + + it('skips entries without an identifier', () => { + setupIntegration(); + + elementHandler({ + entries: [ + { + name: 'image-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 100, + } as unknown as PerformanceEntry, + ], }); - 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(distributionSpy).not.toHaveBeenCalled(); + }); - 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('emits render_time metric for text-paint entries', () => { + setupIntegration(); + + elementHandler({ + entries: [ + { + name: 'text-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 150, + loadTime: 0, + identifier: 'hero-text', + } as unknown as PerformanceEntry, + ], }); - 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), - ); + expect(distributionSpy).toHaveBeenCalledTimes(1); + expect(distributionSpy).toHaveBeenCalledWith('element_timing.render_time', 150, { + unit: 'millisecond', + attributes: { + 'element.identifier': 'hero-text', + 'element.paint_type': 'text-paint', + }, }); }); - 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('emits both render_time and load_time metrics for image-paint entries', () => { + setupIntegration(); + + elementHandler({ + entries: [ + { + name: 'image-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 200, + loadTime: 150, + identifier: 'hero-image', + } as unknown as PerformanceEntry, + ], }); - 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); + expect(distributionSpy).toHaveBeenCalledTimes(2); + expect(distributionSpy).toHaveBeenCalledWith('element_timing.render_time', 200, { + unit: 'millisecond', + attributes: { + 'element.identifier': 'hero-image', + 'element.paint_type': 'image-paint', + }, }); - - // 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); + expect(distributionSpy).toHaveBeenCalledWith('element_timing.load_time', 150, { + unit: 'millisecond', + attributes: { + 'element.identifier': 'hero-image', + 'element.paint_type': 'image-paint', + }, }); }); - 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('handles multiple entries in a single batch', () => { + setupIntegration(); + + elementHandler({ + entries: [ + { + name: 'text-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 100, + loadTime: 0, + identifier: 'heading', + } as unknown as PerformanceEntry, + { + name: 'image-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 300, + loadTime: 250, + identifier: 'banner', + } as unknown as PerformanceEntry, + ], }); - 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), - ); - }); + // heading: 1 render_time, banner: 1 render_time + 1 load_time + expect(distributionSpy).toHaveBeenCalledTimes(3); }); }); 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)); + it('is a deprecated no-op that returns a cleanup function', () => { + const cleanup = startTrackingElementTiming(); + expect(typeof cleanup).toBe('function'); }); }); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 70a6595d07d9..dbf39482e3e2 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -39,6 +39,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index b6dc8b2e92b8..8963794e1a3b 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -39,7 +39,6 @@ import { addHistoryInstrumentationHandler, addPerformanceEntries, registerInpInteractionListener, - startTrackingElementTiming, startTrackingINP, startTrackingInteractions, startTrackingLongAnimationFrames, @@ -146,10 +145,8 @@ 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 + * @deprecated This option is no longer used. Element timing is now tracked via the standalone + * `elementTimingIntegration`. Add it to your `integrations` array to collect element timing metrics. */ enableElementTiming: boolean; @@ -371,7 +368,6 @@ export const browserTracingIntegration = ((options: Partial Date: Thu, 19 Mar 2026 02:08:35 -0400 Subject: [PATCH 02/20] fix: test type error --- packages/browser-utils/test/metrics/elementTiming.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser-utils/test/metrics/elementTiming.test.ts b/packages/browser-utils/test/metrics/elementTiming.test.ts index 0613508a7b32..bf224a45573d 100644 --- a/packages/browser-utils/test/metrics/elementTiming.test.ts +++ b/packages/browser-utils/test/metrics/elementTiming.test.ts @@ -28,7 +28,7 @@ describe('elementTimingIntegration', () => { function setupIntegration(): void { const integration = elementTimingIntegration(); - integration.setup({} as sentryCore.Client); + integration?.setup?.({} as sentryCore.Client); } it('skips entries without an identifier', () => { From 74b76489f7abafa18aca407f7cdd062b7365e79f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 19 Mar 2026 10:42:14 -0400 Subject: [PATCH 03/20] fix: Update browser integration tests for element timing metrics Rewrite Playwright integration tests to expect Sentry distribution metrics instead of spans, matching the new elementTimingIntegration. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tracing/metrics/element-timing/init.js | 2 +- .../tracing/metrics/element-timing/test.ts | 318 ++++++++---------- 2 files changed, 136 insertions(+), 184 deletions(-) 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 index 5a4cb2dff8b7..40253c296af1 100644 --- 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 @@ -5,6 +5,6 @@ window.Sentry = Sentry; Sentry.init({ debug: true, dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration()], + integrations: [Sentry.browserTracingIntegration(), Sentry.elementTimingIntegration()], tracesSampleRate: 1, }); 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 index d5dabb5d0ca5..dd776ee535ea 100644 --- 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 @@ -1,219 +1,171 @@ import type { Page, Route } from '@playwright/test'; import { expect } from '@playwright/test'; +import type { Envelope, EnvelopeItem } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; +import { + properFullEnvelopeRequestParser, + shouldSkipMetricsTest, + shouldSkipTracingTest, +} from '../../../../utils/helpers'; sentryTest( - 'adds element timing spans to pageload span tree for elements rendered during pageload', + 'emits element timing metrics for elements rendered during pageload', async ({ getLocalTestUrl, page, browserName }) => { - if (shouldSkipTracingTest() || browserName === 'webkit') { + if (shouldSkipTracingTest() || shouldSkipMetricsTest() || browserName === 'webkit') { sentryTest.skip(); } - const pageloadEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); - serveAssets(page); const url = await getLocalTestUrl({ testDir: __dirname }); + const metricItems: EnvelopeItem[] = []; + + // Collect all metric envelope items + page.on('request', request => { + if (!request.url().includes('/api/1337/envelope/')) return; + try { + const envelope = properFullEnvelopeRequestParser(request); + const items = envelope[1]; + for (const item of items) { + const [header] = item; + if (header.type === 'trace_metric') { + metricItems.push(item); + } + } + } catch { + // ignore parse errors + } + }); + 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', - }); + // Wait for slow image (1500ms) + lazy content (1000ms) + some buffer + await page.waitForTimeout(3000); - // 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', + // Flatten all metric items into individual metrics + const allMetrics = metricItems.flatMap(item => { + const payload = item[1] as { items?: Array> }; + return payload.items || []; }); - 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 elementTimingMetrics = allMetrics.filter( + m => + (m.name as string)?.startsWith('element_timing.'), + ); + + // We expect render_time for all elements and load_time for images + const renderTimeMetrics = elementTimingMetrics.filter(m => m.name === 'element_timing.render_time'); + const loadTimeMetrics = elementTimingMetrics.filter(m => m.name === 'element_timing.load_time'); + + // Check that we have render_time for known identifiers + const renderIdentifiers = renderTimeMetrics.map( + m => (m.attributes as Record)['element.identifier']?.value, + ); + + expect(renderIdentifiers).toContain('image-fast'); + expect(renderIdentifiers).toContain('text1'); + expect(renderIdentifiers).toContain('button1'); + expect(renderIdentifiers).toContain('image-slow'); + expect(renderIdentifiers).toContain('lazy-image'); + expect(renderIdentifiers).toContain('lazy-text'); + + // Check that image elements also have load_time + const loadIdentifiers = loadTimeMetrics.map( + m => (m.attributes as Record)['element.identifier']?.value, + ); + + expect(loadIdentifiers).toContain('image-fast'); + expect(loadIdentifiers).toContain('image-slow'); + expect(loadIdentifiers).toContain('lazy-image'); + + // Text elements should NOT have load_time (loadTime is 0 for text-paint) + expect(loadIdentifiers).not.toContain('text1'); + expect(loadIdentifiers).not.toContain('button1'); + expect(loadIdentifiers).not.toContain('lazy-text'); + + // Validate metric structure for image-fast + const imageFastRender = renderTimeMetrics.find( + m => (m.attributes as Record)['element.identifier']?.value === 'image-fast', + ); + expect(imageFastRender).toMatchObject({ + name: 'element_timing.render_time', + type: 'distribution', + unit: 'millisecond', + value: expect.any(Number), }); - 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(); + expect( + (imageFastRender!.attributes as Record)['element.paint_type']?.value, + ).toBe('image-paint'); + + // Validate text-paint metric + const text1Render = renderTimeMetrics.find( + m => (m.attributes as Record)['element.identifier']?.value === 'text1', + ); + expect( + (text1Render!.attributes as Record)['element.paint_type']?.value, + ).toBe('text-paint'); }, ); -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 }); +sentryTest( + 'emits element timing metrics after navigation', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipTracingTest() || shouldSkipMetricsTest() || browserName === 'webkit') { + sentryTest.skip(); + } - await page.goto(url); + serveAssets(page); - const pageloadEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); + const url = await getLocalTestUrl({ testDir: __dirname }); - const navigationEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'navigation'); + const metricItems: EnvelopeItem[] = []; + + page.on('request', request => { + if (!request.url().includes('/api/1337/envelope/')) return; + try { + const envelope = properFullEnvelopeRequestParser(request); + const items = envelope[1]; + for (const item of items) { + const [header] = item; + if (header.type === 'trace_metric') { + metricItems.push(item); + } + } + } catch { + // ignore parse errors + } + }); - await pageloadEventPromise; + await page.goto(url); - await page.locator('#button1').click(); + // Wait for pageload to complete + await page.waitForTimeout(2500); - const navigationTransactionEvent = envelopeRequestParser(await navigationEventPromise); - const pageloadTransactionEvent = envelopeRequestParser(await pageloadEventPromise); + // Clear collected metrics from pageload + metricItems.length = 0; - const navigationElementTimingSpans = navigationTransactionEvent.spans?.filter(({ op }) => op === 'ui.elementtiming'); + // Trigger navigation + await page.locator('#button1').click(); - expect(navigationElementTimingSpans?.length).toEqual(2); + // Wait for navigation elements to render + await page.waitForTimeout(1500); - const navigationStartTime = navigationTransactionEvent.start_timestamp!; - const pageloadStartTime = pageloadTransactionEvent.start_timestamp!; + const allMetrics = metricItems.flatMap(item => { + const payload = item[1] as { items?: Array> }; + return payload.items || []; + }); - const imageSpan = navigationElementTimingSpans?.find( - ({ description }) => description === 'element[navigation-image]', - ); - const textSpan = navigationElementTimingSpans?.find(({ description }) => description === 'element[navigation-text]'); + const renderTimeMetrics = allMetrics.filter(m => m.name === 'element_timing.render_time'); - // 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, - ); + const renderIdentifiers = renderTimeMetrics.map( + m => (m.attributes as Record)['element.identifier']?.value, + ); - expect(textSpan?.data['element.load_time']).toBe(0); - expect((textSpan!.data['element.render_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan( - navigationStartTime, - ); -}); + expect(renderIdentifiers).toContain('navigation-image'); + expect(renderIdentifiers).toContain('navigation-text'); + }, +); function serveAssets(page: Page) { page.route(/image-(fast|lazy|navigation|click)\.png/, async (route: Route) => { From a9790bf722846eaf77143a1f6e5882f9df723e22 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 19 Mar 2026 12:17:44 -0400 Subject: [PATCH 04/20] fix: fix test formating --- .../tracing/metrics/element-timing/test.ts | 100 ++++++++---------- 1 file changed, 47 insertions(+), 53 deletions(-) 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 index dd776ee535ea..3b5d90eb3fe9 100644 --- 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 @@ -49,10 +49,7 @@ sentryTest( return payload.items || []; }); - const elementTimingMetrics = allMetrics.filter( - m => - (m.name as string)?.startsWith('element_timing.'), - ); + const elementTimingMetrics = allMetrics.filter(m => (m.name as string)?.startsWith('element_timing.')); // We expect render_time for all elements and load_time for images const renderTimeMetrics = elementTimingMetrics.filter(m => m.name === 'element_timing.render_time'); @@ -94,78 +91,75 @@ sentryTest( unit: 'millisecond', value: expect.any(Number), }); - expect( - (imageFastRender!.attributes as Record)['element.paint_type']?.value, - ).toBe('image-paint'); + expect((imageFastRender!.attributes as Record)['element.paint_type']?.value).toBe( + 'image-paint', + ); // Validate text-paint metric const text1Render = renderTimeMetrics.find( m => (m.attributes as Record)['element.identifier']?.value === 'text1', ); - expect( - (text1Render!.attributes as Record)['element.paint_type']?.value, - ).toBe('text-paint'); + expect((text1Render!.attributes as Record)['element.paint_type']?.value).toBe( + 'text-paint', + ); }, ); -sentryTest( - 'emits element timing metrics after navigation', - async ({ getLocalTestUrl, page, browserName }) => { - if (shouldSkipTracingTest() || shouldSkipMetricsTest() || browserName === 'webkit') { - sentryTest.skip(); - } +sentryTest('emits element timing metrics after navigation', async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipTracingTest() || shouldSkipMetricsTest() || browserName === 'webkit') { + sentryTest.skip(); + } - serveAssets(page); + serveAssets(page); - const url = await getLocalTestUrl({ testDir: __dirname }); + const url = await getLocalTestUrl({ testDir: __dirname }); - const metricItems: EnvelopeItem[] = []; + const metricItems: EnvelopeItem[] = []; - page.on('request', request => { - if (!request.url().includes('/api/1337/envelope/')) return; - try { - const envelope = properFullEnvelopeRequestParser(request); - const items = envelope[1]; - for (const item of items) { - const [header] = item; - if (header.type === 'trace_metric') { - metricItems.push(item); - } + page.on('request', request => { + if (!request.url().includes('/api/1337/envelope/')) return; + try { + const envelope = properFullEnvelopeRequestParser(request); + const items = envelope[1]; + for (const item of items) { + const [header] = item; + if (header.type === 'trace_metric') { + metricItems.push(item); } - } catch { - // ignore parse errors } - }); + } catch { + // ignore parse errors + } + }); - await page.goto(url); + await page.goto(url); - // Wait for pageload to complete - await page.waitForTimeout(2500); + // Wait for pageload to complete + await page.waitForTimeout(2500); - // Clear collected metrics from pageload - metricItems.length = 0; + // Clear collected metrics from pageload + metricItems.length = 0; - // Trigger navigation - await page.locator('#button1').click(); + // Trigger navigation + await page.locator('#button1').click(); - // Wait for navigation elements to render - await page.waitForTimeout(1500); + // Wait for navigation elements to render + await page.waitForTimeout(1500); - const allMetrics = metricItems.flatMap(item => { - const payload = item[1] as { items?: Array> }; - return payload.items || []; - }); + const allMetrics = metricItems.flatMap(item => { + const payload = item[1] as { items?: Array> }; + return payload.items || []; + }); - const renderTimeMetrics = allMetrics.filter(m => m.name === 'element_timing.render_time'); + const renderTimeMetrics = allMetrics.filter(m => m.name === 'element_timing.render_time'); - const renderIdentifiers = renderTimeMetrics.map( - m => (m.attributes as Record)['element.identifier']?.value, - ); + const renderIdentifiers = renderTimeMetrics.map( + m => (m.attributes as Record)['element.identifier']?.value, + ); - expect(renderIdentifiers).toContain('navigation-image'); - expect(renderIdentifiers).toContain('navigation-text'); - }, -); + expect(renderIdentifiers).toContain('navigation-image'); + expect(renderIdentifiers).toContain('navigation-text'); +}); function serveAssets(page: Page) { page.route(/image-(fast|lazy|navigation|click)\.png/, async (route: Route) => { From 7bdf807ecf1c17cc1bde6bf804b94ac18f25bd9b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 19 Mar 2026 13:05:59 -0400 Subject: [PATCH 05/20] fix: Fix element timing integration tests to wait for metric flush Metrics are buffered and flushed after 5 seconds. The previous test used a 3 second timeout which wasn't enough. Now properly waits for metric envelopes with adequate timeouts. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tracing/metrics/element-timing/test.ts | 158 +++++++++--------- 1 file changed, 78 insertions(+), 80 deletions(-) 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 index 3b5d90eb3fe9..7edd8e39a477 100644 --- 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 @@ -1,6 +1,6 @@ -import type { Page, Route } from '@playwright/test'; +import type { Page, Request, Route } from '@playwright/test'; import { expect } from '@playwright/test'; -import type { Envelope, EnvelopeItem } from '@sentry/core'; +import type { Envelope } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { properFullEnvelopeRequestParser, @@ -8,6 +8,44 @@ import { shouldSkipTracingTest, } from '../../../../utils/helpers'; +type MetricItem = Record & { + name: string; + type: string; + value: number; + unit?: string; + attributes: Record; +}; + +function extractMetricsFromRequest(req: Request): MetricItem[] { + try { + const envelope = properFullEnvelopeRequestParser(req); + const items = envelope[1]; + const metrics: MetricItem[] = []; + for (const item of items) { + const [header] = item; + if (header.type === 'trace_metric') { + const payload = item[1] as { items?: MetricItem[] }; + if (payload.items) { + metrics.push(...payload.items); + } + } + } + return metrics; + } catch { + return []; + } +} + +function isElementTimingMetricRequest(req: Request): boolean { + if (!req.url().includes('/api/1337/envelope/')) return false; + const metrics = extractMetricsFromRequest(req); + return metrics.some(m => m.name.startsWith('element_timing.')); +} + +function waitForElementTimingMetrics(page: Page): Promise { + return page.waitForRequest(req => isElementTimingMetricRequest(req), { timeout: 15_000 }); +} + sentryTest( 'emits element timing metrics for elements rendered during pageload', async ({ getLocalTestUrl, page, browserName }) => { @@ -19,47 +57,36 @@ sentryTest( const url = await getLocalTestUrl({ testDir: __dirname }); - const metricItems: EnvelopeItem[] = []; - - // Collect all metric envelope items - page.on('request', request => { - if (!request.url().includes('/api/1337/envelope/')) return; - try { - const envelope = properFullEnvelopeRequestParser(request); - const items = envelope[1]; - for (const item of items) { - const [header] = item; - if (header.type === 'trace_metric') { - metricItems.push(item); - } + // Collect all metric requests + const allMetricRequests: Request[] = []; + page.on('request', req => { + if (req.url().includes('/api/1337/envelope/')) { + const metrics = extractMetricsFromRequest(req); + if (metrics.some(m => m.name.startsWith('element_timing.'))) { + allMetricRequests.push(req); } - } catch { - // ignore parse errors } }); await page.goto(url); - // Wait for slow image (1500ms) + lazy content (1000ms) + some buffer - await page.waitForTimeout(3000); + // Wait for at least one element timing metric envelope to arrive + await waitForElementTimingMetrics(page); - // Flatten all metric items into individual metrics - const allMetrics = metricItems.flatMap(item => { - const payload = item[1] as { items?: Array> }; - return payload.items || []; - }); + // Wait a bit more for slow images and lazy content + flush interval + await page.waitForTimeout(8000); - const elementTimingMetrics = allMetrics.filter(m => (m.name as string)?.startsWith('element_timing.')); + // Extract all element timing metrics from all collected requests + const allMetrics = allMetricRequests.flatMap(req => extractMetricsFromRequest(req)); + const elementTimingMetrics = allMetrics.filter(m => m.name.startsWith('element_timing.')); - // We expect render_time for all elements and load_time for images const renderTimeMetrics = elementTimingMetrics.filter(m => m.name === 'element_timing.render_time'); const loadTimeMetrics = elementTimingMetrics.filter(m => m.name === 'element_timing.load_time'); - // Check that we have render_time for known identifiers - const renderIdentifiers = renderTimeMetrics.map( - m => (m.attributes as Record)['element.identifier']?.value, - ); + const renderIdentifiers = renderTimeMetrics.map(m => m.attributes['element.identifier']?.value); + const loadIdentifiers = loadTimeMetrics.map(m => m.attributes['element.identifier']?.value); + // All text and image elements should have render_time expect(renderIdentifiers).toContain('image-fast'); expect(renderIdentifiers).toContain('text1'); expect(renderIdentifiers).toContain('button1'); @@ -67,11 +94,7 @@ sentryTest( expect(renderIdentifiers).toContain('lazy-image'); expect(renderIdentifiers).toContain('lazy-text'); - // Check that image elements also have load_time - const loadIdentifiers = loadTimeMetrics.map( - m => (m.attributes as Record)['element.identifier']?.value, - ); - + // Image elements should also have load_time expect(loadIdentifiers).toContain('image-fast'); expect(loadIdentifiers).toContain('image-slow'); expect(loadIdentifiers).toContain('lazy-image'); @@ -82,26 +105,18 @@ sentryTest( expect(loadIdentifiers).not.toContain('lazy-text'); // Validate metric structure for image-fast - const imageFastRender = renderTimeMetrics.find( - m => (m.attributes as Record)['element.identifier']?.value === 'image-fast', - ); + const imageFastRender = renderTimeMetrics.find(m => m.attributes['element.identifier']?.value === 'image-fast'); expect(imageFastRender).toMatchObject({ name: 'element_timing.render_time', type: 'distribution', unit: 'millisecond', value: expect.any(Number), }); - expect((imageFastRender!.attributes as Record)['element.paint_type']?.value).toBe( - 'image-paint', - ); + expect(imageFastRender!.attributes['element.paint_type']?.value).toBe('image-paint'); // Validate text-paint metric - const text1Render = renderTimeMetrics.find( - m => (m.attributes as Record)['element.identifier']?.value === 'text1', - ); - expect((text1Render!.attributes as Record)['element.paint_type']?.value).toBe( - 'text-paint', - ); + const text1Render = renderTimeMetrics.find(m => m.attributes['element.identifier']?.value === 'text1'); + expect(text1Render!.attributes['element.paint_type']?.value).toBe('text-paint'); }, ); @@ -114,48 +129,31 @@ sentryTest('emits element timing metrics after navigation', async ({ getLocalTes const url = await getLocalTestUrl({ testDir: __dirname }); - const metricItems: EnvelopeItem[] = []; - - page.on('request', request => { - if (!request.url().includes('/api/1337/envelope/')) return; - try { - const envelope = properFullEnvelopeRequestParser(request); - const items = envelope[1]; - for (const item of items) { - const [header] = item; - if (header.type === 'trace_metric') { - metricItems.push(item); - } - } - } catch { - // ignore parse errors - } - }); - await page.goto(url); - // Wait for pageload to complete - await page.waitForTimeout(2500); + // Wait for pageload content to settle and flush + await page.waitForTimeout(8000); - // Clear collected metrics from pageload - metricItems.length = 0; + // Now collect only post-navigation metrics + const postNavMetricRequests: Request[] = []; + page.on('request', req => { + if (req.url().includes('/api/1337/envelope/')) { + const metrics = extractMetricsFromRequest(req); + if (metrics.some(m => m.name.startsWith('element_timing.'))) { + postNavMetricRequests.push(req); + } + } + }); // Trigger navigation await page.locator('#button1').click(); - // Wait for navigation elements to render - await page.waitForTimeout(1500); - - const allMetrics = metricItems.flatMap(item => { - const payload = item[1] as { items?: Array> }; - return payload.items || []; - }); + // Wait for navigation elements to render + flush interval + await page.waitForTimeout(8000); + const allMetrics = postNavMetricRequests.flatMap(req => extractMetricsFromRequest(req)); const renderTimeMetrics = allMetrics.filter(m => m.name === 'element_timing.render_time'); - - const renderIdentifiers = renderTimeMetrics.map( - m => (m.attributes as Record)['element.identifier']?.value, - ); + const renderIdentifiers = renderTimeMetrics.map(m => m.attributes['element.identifier']?.value); expect(renderIdentifiers).toContain('navigation-image'); expect(renderIdentifiers).toContain('navigation-text'); From 21d21eba6a0cf3230ff0d6550822dbdfb864689f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 19 Mar 2026 13:43:29 -0400 Subject: [PATCH 06/20] fix: Export elementTimingIntegration from all tracing CDN bundles The integration was only exported from the npm package entry point but not from the CDN bundle entry points, causing Sentry.elementTimingIntegration to be undefined in bundle_tracing_* test configurations. Verified locally: - PW_BUNDLE=bundle_tracing_logs_metrics: 2 passed - PW_BUNDLE=bundle_tracing_replay_feedback_logs_metrics_min: 2 passed - browser-utils unit tests: 132 passed - browser unit tests: 545 passed Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser/src/index.bundle.tracing.logs.metrics.ts | 1 + .../src/index.bundle.tracing.replay.feedback.logs.metrics.ts | 1 + packages/browser/src/index.bundle.tracing.replay.feedback.ts | 1 + packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts | 1 + packages/browser/src/index.bundle.tracing.replay.ts | 1 + packages/browser/src/index.bundle.tracing.ts | 1 + 6 files changed, 6 insertions(+) diff --git a/packages/browser/src/index.bundle.tracing.logs.metrics.ts b/packages/browser/src/index.bundle.tracing.logs.metrics.ts index ce6a65061385..d10bfea67687 100644 --- a/packages/browser/src/index.bundle.tracing.logs.metrics.ts +++ b/packages/browser/src/index.bundle.tracing.logs.metrics.ts @@ -25,6 +25,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts index 9fb81d9a4750..6caef09459ae 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts @@ -25,6 +25,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.ts index b6b298189aef..9d2a4af61d3f 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.ts @@ -26,6 +26,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { reportPageLoaded } from './tracing/reportPageLoaded'; diff --git a/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts b/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts index 6b856e7a37cc..9972cd85ca8a 100644 --- a/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts +++ b/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts @@ -25,6 +25,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; diff --git a/packages/browser/src/index.bundle.tracing.replay.ts b/packages/browser/src/index.bundle.tracing.replay.ts index a20a7b8388f1..fd8e794a2791 100644 --- a/packages/browser/src/index.bundle.tracing.replay.ts +++ b/packages/browser/src/index.bundle.tracing.replay.ts @@ -25,6 +25,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; diff --git a/packages/browser/src/index.bundle.tracing.ts b/packages/browser/src/index.bundle.tracing.ts index c3cb0a85cf1d..03e3eda95ebd 100644 --- a/packages/browser/src/index.bundle.tracing.ts +++ b/packages/browser/src/index.bundle.tracing.ts @@ -30,6 +30,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { reportPageLoaded } from './tracing/reportPageLoaded'; From 79107a8c599b71bcc264213e08bb1c93a320cb29 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 20 Mar 2026 08:41:45 -0700 Subject: [PATCH 07/20] fix: Replace fixed timeouts with deterministic polling in element timing tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of sleeping 8 seconds and hoping all metrics have arrived, poll until the expected element identifiers appear in the collected metrics. This makes the tests faster (6-13s vs 16-20s) and more reliable — they complete as soon as data arrives and fail with clear assertions when it doesn't. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tracing/metrics/element-timing/test.ts | 107 +++++++++++------- 1 file changed, 63 insertions(+), 44 deletions(-) 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 index 7edd8e39a477..d90ef27940f9 100644 --- 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 @@ -36,14 +36,49 @@ function extractMetricsFromRequest(req: Request): MetricItem[] { } } -function isElementTimingMetricRequest(req: Request): boolean { - if (!req.url().includes('/api/1337/envelope/')) return false; - const metrics = extractMetricsFromRequest(req); - return metrics.some(m => m.name.startsWith('element_timing.')); -} +/** + * Collects element timing metrics from envelope requests on the page. + * Returns a function to get all collected metrics so far and a function + * that waits until all expected identifiers have been seen in render_time metrics. + */ +function createMetricCollector(page: Page) { + const collectedRequests: Request[] = []; + + page.on('request', req => { + if (!req.url().includes('/api/1337/envelope/')) return; + const metrics = extractMetricsFromRequest(req); + if (metrics.some(m => m.name.startsWith('element_timing.'))) { + collectedRequests.push(req); + } + }); + + function getAll(): MetricItem[] { + return collectedRequests.flatMap(req => extractMetricsFromRequest(req)); + } + + async function waitForIdentifiers(identifiers: string[], timeout = 30_000): Promise { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const all = getAll().filter(m => m.name === 'element_timing.render_time'); + const seen = new Set(all.map(m => m.attributes['element.identifier']?.value)); + if (identifiers.every(id => seen.has(id))) { + return; + } + await page.waitForTimeout(500); + } + // Final check with assertion for clear error message + const all = getAll().filter(m => m.name === 'element_timing.render_time'); + const seen = all.map(m => m.attributes['element.identifier']?.value); + for (const id of identifiers) { + expect(seen).toContain(id); + } + } -function waitForElementTimingMetrics(page: Page): Promise { - return page.waitForRequest(req => isElementTimingMetricRequest(req), { timeout: 15_000 }); + function reset(): void { + collectedRequests.length = 0; + } + + return { getAll, waitForIdentifiers, reset }; } sentryTest( @@ -56,32 +91,23 @@ sentryTest( serveAssets(page); const url = await getLocalTestUrl({ testDir: __dirname }); - - // Collect all metric requests - const allMetricRequests: Request[] = []; - page.on('request', req => { - if (req.url().includes('/api/1337/envelope/')) { - const metrics = extractMetricsFromRequest(req); - if (metrics.some(m => m.name.startsWith('element_timing.'))) { - allMetricRequests.push(req); - } - } - }); + const collector = createMetricCollector(page); await page.goto(url); - // Wait for at least one element timing metric envelope to arrive - await waitForElementTimingMetrics(page); + // Wait until all expected element identifiers have been flushed as metrics + await collector.waitForIdentifiers([ + 'image-fast', + 'text1', + 'button1', + 'image-slow', + 'lazy-image', + 'lazy-text', + ]); - // Wait a bit more for slow images and lazy content + flush interval - await page.waitForTimeout(8000); - - // Extract all element timing metrics from all collected requests - const allMetrics = allMetricRequests.flatMap(req => extractMetricsFromRequest(req)); - const elementTimingMetrics = allMetrics.filter(m => m.name.startsWith('element_timing.')); - - const renderTimeMetrics = elementTimingMetrics.filter(m => m.name === 'element_timing.render_time'); - const loadTimeMetrics = elementTimingMetrics.filter(m => m.name === 'element_timing.load_time'); + const allMetrics = collector.getAll().filter(m => m.name.startsWith('element_timing.')); + const renderTimeMetrics = allMetrics.filter(m => m.name === 'element_timing.render_time'); + const loadTimeMetrics = allMetrics.filter(m => m.name === 'element_timing.load_time'); const renderIdentifiers = renderTimeMetrics.map(m => m.attributes['element.identifier']?.value); const loadIdentifiers = loadTimeMetrics.map(m => m.attributes['element.identifier']?.value); @@ -128,30 +154,23 @@ sentryTest('emits element timing metrics after navigation', async ({ getLocalTes serveAssets(page); const url = await getLocalTestUrl({ testDir: __dirname }); + const collector = createMetricCollector(page); await page.goto(url); - // Wait for pageload content to settle and flush - await page.waitForTimeout(8000); + // Wait for pageload element timing metrics to arrive before navigating + await collector.waitForIdentifiers(['image-fast', 'text1']); - // Now collect only post-navigation metrics - const postNavMetricRequests: Request[] = []; - page.on('request', req => { - if (req.url().includes('/api/1337/envelope/')) { - const metrics = extractMetricsFromRequest(req); - if (metrics.some(m => m.name.startsWith('element_timing.'))) { - postNavMetricRequests.push(req); - } - } - }); + // Reset so we only capture post-navigation metrics + collector.reset(); // Trigger navigation await page.locator('#button1').click(); - // Wait for navigation elements to render + flush interval - await page.waitForTimeout(8000); + // Wait for navigation element timing metrics + await collector.waitForIdentifiers(['navigation-image', 'navigation-text']); - const allMetrics = postNavMetricRequests.flatMap(req => extractMetricsFromRequest(req)); + const allMetrics = collector.getAll(); const renderTimeMetrics = allMetrics.filter(m => m.name === 'element_timing.render_time'); const renderIdentifiers = renderTimeMetrics.map(m => m.attributes['element.identifier']?.value); From 2c6d013e4f82ff484c87e7405aa5bd06c392ecaa Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 20 Mar 2026 12:08:21 -0400 Subject: [PATCH 08/20] fix: test format --- .../suites/tracing/metrics/element-timing/test.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) 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 index d90ef27940f9..3ec62b7f3003 100644 --- 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 @@ -96,14 +96,7 @@ sentryTest( await page.goto(url); // Wait until all expected element identifiers have been flushed as metrics - await collector.waitForIdentifiers([ - 'image-fast', - 'text1', - 'button1', - 'image-slow', - 'lazy-image', - 'lazy-text', - ]); + await collector.waitForIdentifiers(['image-fast', 'text1', 'button1', 'image-slow', 'lazy-image', 'lazy-text']); const allMetrics = collector.getAll().filter(m => m.name.startsWith('element_timing.')); const renderTimeMetrics = allMetrics.filter(m => m.name === 'element_timing.render_time'); From ef2a3255637c03f965d4bed9b1a45c38269f9e4e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 20 Mar 2026 12:36:37 -0400 Subject: [PATCH 09/20] fix: Remove dead enableElementTiming default value Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser/src/tracing/browserTracingIntegration.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 8963794e1a3b..364c24485d6b 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -334,7 +334,6 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { enableLongTask: true, enableLongAnimationFrame: true, enableInp: true, - enableElementTiming: true, ignoreResourceSpans: [], ignorePerformanceApiSpans: [], detectRedirects: true, From 4883317137bc3742154f234f7669907e7bbc46b8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 20 Mar 2026 14:09:14 -0400 Subject: [PATCH 10/20] fix: Make deprecated enableElementTiming optional in type The property was removed from defaults but the type still required it, causing a build error. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser/src/tracing/browserTracingIntegration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 364c24485d6b..86e61a109b8b 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -148,7 +148,7 @@ export interface BrowserTracingOptions { * @deprecated This option is no longer used. Element timing is now tracked via the standalone * `elementTimingIntegration`. Add it to your `integrations` array to collect element timing metrics. */ - enableElementTiming: boolean; + enableElementTiming?: boolean; /** * Flag to disable patching all together for fetch requests. From 9f67ef94fb9f871b322fa840f92161cd685e2d5f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 20 Mar 2026 18:14:37 -0400 Subject: [PATCH 11/20] fix: Use ui.element.* convention attributes for element timing metrics Align attribute names with Sentry conventions and add missing attributes (id, type, url, width, height) from the Element Timing API. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tracing/metrics/element-timing/test.ts | 18 +++++----- .../src/metrics/elementTiming.ts | 26 ++++++++++++-- .../test/metrics/elementTiming.test.ts | 34 +++++++++++++------ 3 files changed, 56 insertions(+), 22 deletions(-) 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 index 3ec62b7f3003..f8a65d27aa19 100644 --- 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 @@ -60,7 +60,7 @@ function createMetricCollector(page: Page) { const deadline = Date.now() + timeout; while (Date.now() < deadline) { const all = getAll().filter(m => m.name === 'element_timing.render_time'); - const seen = new Set(all.map(m => m.attributes['element.identifier']?.value)); + const seen = new Set(all.map(m => m.attributes['ui.element.identifier']?.value)); if (identifiers.every(id => seen.has(id))) { return; } @@ -68,7 +68,7 @@ function createMetricCollector(page: Page) { } // Final check with assertion for clear error message const all = getAll().filter(m => m.name === 'element_timing.render_time'); - const seen = all.map(m => m.attributes['element.identifier']?.value); + const seen = all.map(m => m.attributes['ui.element.identifier']?.value); for (const id of identifiers) { expect(seen).toContain(id); } @@ -102,8 +102,8 @@ sentryTest( const renderTimeMetrics = allMetrics.filter(m => m.name === 'element_timing.render_time'); const loadTimeMetrics = allMetrics.filter(m => m.name === 'element_timing.load_time'); - const renderIdentifiers = renderTimeMetrics.map(m => m.attributes['element.identifier']?.value); - const loadIdentifiers = loadTimeMetrics.map(m => m.attributes['element.identifier']?.value); + const renderIdentifiers = renderTimeMetrics.map(m => m.attributes['ui.element.identifier']?.value); + const loadIdentifiers = loadTimeMetrics.map(m => m.attributes['ui.element.identifier']?.value); // All text and image elements should have render_time expect(renderIdentifiers).toContain('image-fast'); @@ -124,18 +124,18 @@ sentryTest( expect(loadIdentifiers).not.toContain('lazy-text'); // Validate metric structure for image-fast - const imageFastRender = renderTimeMetrics.find(m => m.attributes['element.identifier']?.value === 'image-fast'); + const imageFastRender = renderTimeMetrics.find(m => m.attributes['ui.element.identifier']?.value === 'image-fast'); expect(imageFastRender).toMatchObject({ name: 'element_timing.render_time', type: 'distribution', unit: 'millisecond', value: expect.any(Number), }); - expect(imageFastRender!.attributes['element.paint_type']?.value).toBe('image-paint'); + expect(imageFastRender!.attributes['ui.element.paint_type']?.value).toBe('image-paint'); // Validate text-paint metric - const text1Render = renderTimeMetrics.find(m => m.attributes['element.identifier']?.value === 'text1'); - expect(text1Render!.attributes['element.paint_type']?.value).toBe('text-paint'); + const text1Render = renderTimeMetrics.find(m => m.attributes['ui.element.identifier']?.value === 'text1'); + expect(text1Render!.attributes['ui.element.paint_type']?.value).toBe('text-paint'); }, ); @@ -165,7 +165,7 @@ sentryTest('emits element timing metrics after navigation', async ({ getLocalTes const allMetrics = collector.getAll(); const renderTimeMetrics = allMetrics.filter(m => m.name === 'element_timing.render_time'); - const renderIdentifiers = renderTimeMetrics.map(m => m.attributes['element.identifier']?.value); + const renderIdentifiers = renderTimeMetrics.map(m => m.attributes['ui.element.identifier']?.value); expect(renderIdentifiers).toContain('navigation-image'); expect(renderIdentifiers).toContain('navigation-text'); diff --git a/packages/browser-utils/src/metrics/elementTiming.ts b/packages/browser-utils/src/metrics/elementTiming.ts index b7d51e9fa783..09307c05daac 100644 --- a/packages/browser-utils/src/metrics/elementTiming.ts +++ b/packages/browser-utils/src/metrics/elementTiming.ts @@ -40,12 +40,32 @@ const _elementTimingIntegration = (() => { const renderTime = elementEntry.renderTime; const loadTime = elementEntry.loadTime; - const metricAttributes: Record = { - 'element.identifier': identifier, + const metricAttributes: Record = { + 'ui.element.identifier': identifier, }; if (paintType) { - metricAttributes['element.paint_type'] = paintType; + metricAttributes['ui.element.paint_type'] = paintType; + } + + if (elementEntry.id) { + metricAttributes['ui.element.id'] = elementEntry.id; + } + + if (elementEntry.element) { + metricAttributes['ui.element.type'] = elementEntry.element.tagName.toLowerCase(); + } + + if (elementEntry.url) { + metricAttributes['ui.element.url'] = elementEntry.url; + } + + if (elementEntry.naturalWidth) { + metricAttributes['ui.element.width'] = elementEntry.naturalWidth; + } + + if (elementEntry.naturalHeight) { + metricAttributes['ui.element.height'] = elementEntry.naturalHeight; } if (renderTime) { diff --git a/packages/browser-utils/test/metrics/elementTiming.test.ts b/packages/browser-utils/test/metrics/elementTiming.test.ts index bf224a45573d..ef2089967c92 100644 --- a/packages/browser-utils/test/metrics/elementTiming.test.ts +++ b/packages/browser-utils/test/metrics/elementTiming.test.ts @@ -62,6 +62,10 @@ describe('elementTimingIntegration', () => { renderTime: 150, loadTime: 0, identifier: 'hero-text', + id: 'hero', + element: { tagName: 'P' }, + naturalWidth: 0, + naturalHeight: 0, } as unknown as PerformanceEntry, ], }); @@ -70,8 +74,10 @@ describe('elementTimingIntegration', () => { expect(distributionSpy).toHaveBeenCalledWith('element_timing.render_time', 150, { unit: 'millisecond', attributes: { - 'element.identifier': 'hero-text', - 'element.paint_type': 'text-paint', + 'ui.element.identifier': 'hero-text', + 'ui.element.paint_type': 'text-paint', + 'ui.element.id': 'hero', + 'ui.element.type': 'p', }, }); }); @@ -89,24 +95,32 @@ describe('elementTimingIntegration', () => { renderTime: 200, loadTime: 150, identifier: 'hero-image', + id: 'img1', + element: { tagName: 'IMG' }, + url: 'https://example.com/hero.jpg', + naturalWidth: 1920, + naturalHeight: 1080, } as unknown as PerformanceEntry, ], }); expect(distributionSpy).toHaveBeenCalledTimes(2); + const expectedAttributes = { + 'ui.element.identifier': 'hero-image', + 'ui.element.paint_type': 'image-paint', + 'ui.element.id': 'img1', + 'ui.element.type': 'img', + 'ui.element.url': 'https://example.com/hero.jpg', + 'ui.element.width': 1920, + 'ui.element.height': 1080, + }; expect(distributionSpy).toHaveBeenCalledWith('element_timing.render_time', 200, { unit: 'millisecond', - attributes: { - 'element.identifier': 'hero-image', - 'element.paint_type': 'image-paint', - }, + attributes: expectedAttributes, }); expect(distributionSpy).toHaveBeenCalledWith('element_timing.load_time', 150, { unit: 'millisecond', - attributes: { - 'element.identifier': 'hero-image', - 'element.paint_type': 'image-paint', - }, + attributes: expectedAttributes, }); }); From 1dc57fc4708164f675835c437afe2235748451f9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 23 Mar 2026 13:49:27 -0400 Subject: [PATCH 12/20] fix: Export elementTimingIntegration only from metrics CDN bundles Non-metrics bundles now export a no-op shim that warns at runtime, matching the established pattern for browserTracingIntegrationShim. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser/src/index.bundle.feedback.ts | 2 ++ .../browser/src/index.bundle.logs.metrics.ts | 2 ++ .../browser/src/index.bundle.replay.feedback.ts | 2 ++ .../src/index.bundle.replay.logs.metrics.ts | 7 ++++++- packages/browser/src/index.bundle.replay.ts | 2 ++ .../src/index.bundle.tracing.replay.feedback.ts | 4 ++-- .../browser/src/index.bundle.tracing.replay.ts | 9 +++++++-- packages/browser/src/index.bundle.tracing.ts | 3 ++- packages/browser/src/index.bundle.ts | 2 ++ packages/integration-shims/src/ElementTiming.ts | 17 +++++++++++++++++ packages/integration-shims/src/index.ts | 1 + 11 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 packages/integration-shims/src/ElementTiming.ts diff --git a/packages/browser/src/index.bundle.feedback.ts b/packages/browser/src/index.bundle.feedback.ts index 7f8e663bfd0a..f8d2dfd14014 100644 --- a/packages/browser/src/index.bundle.feedback.ts +++ b/packages/browser/src/index.bundle.feedback.ts @@ -1,6 +1,7 @@ import { browserTracingIntegrationShim, consoleLoggingIntegrationShim, + elementTimingIntegrationShim, loggerShim, replayIntegrationShim, } from '@sentry-internal/integration-shims'; @@ -15,6 +16,7 @@ export { getFeedback, sendFeedback } from '@sentry-internal/feedback'; export { browserTracingIntegrationShim as browserTracingIntegration, + elementTimingIntegrationShim as elementTimingIntegration, feedbackAsyncIntegration as feedbackAsyncIntegration, feedbackAsyncIntegration as feedbackIntegration, replayIntegrationShim as replayIntegration, diff --git a/packages/browser/src/index.bundle.logs.metrics.ts b/packages/browser/src/index.bundle.logs.metrics.ts index 461362830e7b..3d7be68e3a2f 100644 --- a/packages/browser/src/index.bundle.logs.metrics.ts +++ b/packages/browser/src/index.bundle.logs.metrics.ts @@ -1,5 +1,6 @@ import { browserTracingIntegrationShim, + elementTimingIntegrationShim, feedbackIntegrationShim, replayIntegrationShim, } from '@sentry-internal/integration-shims'; @@ -11,6 +12,7 @@ export { logger, consoleLoggingIntegration } from '@sentry/core'; export { browserTracingIntegrationShim as browserTracingIntegration, + elementTimingIntegrationShim as elementTimingIntegration, feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration, replayIntegrationShim as replayIntegration, diff --git a/packages/browser/src/index.bundle.replay.feedback.ts b/packages/browser/src/index.bundle.replay.feedback.ts index 60c2a0e2ac4b..da307df3a951 100644 --- a/packages/browser/src/index.bundle.replay.feedback.ts +++ b/packages/browser/src/index.bundle.replay.feedback.ts @@ -1,6 +1,7 @@ import { browserTracingIntegrationShim, consoleLoggingIntegrationShim, + elementTimingIntegrationShim, loggerShim, } from '@sentry-internal/integration-shims'; import { feedbackAsyncIntegration } from './feedbackAsync'; @@ -14,6 +15,7 @@ export { getFeedback, sendFeedback } from '@sentry-internal/feedback'; export { browserTracingIntegrationShim as browserTracingIntegration, + elementTimingIntegrationShim as elementTimingIntegration, feedbackAsyncIntegration as feedbackAsyncIntegration, feedbackAsyncIntegration as feedbackIntegration, }; diff --git a/packages/browser/src/index.bundle.replay.logs.metrics.ts b/packages/browser/src/index.bundle.replay.logs.metrics.ts index ce4f3334e21a..d42bb609657e 100644 --- a/packages/browser/src/index.bundle.replay.logs.metrics.ts +++ b/packages/browser/src/index.bundle.replay.logs.metrics.ts @@ -1,4 +1,8 @@ -import { browserTracingIntegrationShim, feedbackIntegrationShim } from '@sentry-internal/integration-shims'; +import { + browserTracingIntegrationShim, + elementTimingIntegrationShim, + feedbackIntegrationShim, +} from '@sentry-internal/integration-shims'; export * from './index.bundle.base'; @@ -9,6 +13,7 @@ export { replayIntegration, getReplay } from '@sentry-internal/replay'; export { browserTracingIntegrationShim as browserTracingIntegration, + elementTimingIntegrationShim as elementTimingIntegration, feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration, }; diff --git a/packages/browser/src/index.bundle.replay.ts b/packages/browser/src/index.bundle.replay.ts index 9a370ae51b81..e305596f190c 100644 --- a/packages/browser/src/index.bundle.replay.ts +++ b/packages/browser/src/index.bundle.replay.ts @@ -1,6 +1,7 @@ import { browserTracingIntegrationShim, consoleLoggingIntegrationShim, + elementTimingIntegrationShim, feedbackIntegrationShim, loggerShim, } from '@sentry-internal/integration-shims'; @@ -14,6 +15,7 @@ export { replayIntegration, getReplay } from '@sentry-internal/replay'; export { browserTracingIntegrationShim as browserTracingIntegration, + elementTimingIntegrationShim as elementTimingIntegration, feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration, }; diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.ts index 9d2a4af61d3f..362fbc4dd266 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.ts @@ -1,5 +1,5 @@ import { registerSpanErrorInstrumentation } from '@sentry/core'; -import { consoleLoggingIntegrationShim, loggerShim } from '@sentry-internal/integration-shims'; +import { consoleLoggingIntegrationShim, elementTimingIntegrationShim, loggerShim } from '@sentry-internal/integration-shims'; import { feedbackAsyncIntegration } from './feedbackAsync'; registerSpanErrorInstrumentation(); @@ -26,7 +26,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; -export { elementTimingIntegration } from '@sentry-internal/browser-utils'; +export { elementTimingIntegrationShim as elementTimingIntegration }; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { reportPageLoaded } from './tracing/reportPageLoaded'; diff --git a/packages/browser/src/index.bundle.tracing.replay.ts b/packages/browser/src/index.bundle.tracing.replay.ts index fd8e794a2791..f95e3d6cdcc9 100644 --- a/packages/browser/src/index.bundle.tracing.replay.ts +++ b/packages/browser/src/index.bundle.tracing.replay.ts @@ -1,5 +1,10 @@ import { registerSpanErrorInstrumentation } from '@sentry/core'; -import { consoleLoggingIntegrationShim, feedbackIntegrationShim, loggerShim } from '@sentry-internal/integration-shims'; +import { + consoleLoggingIntegrationShim, + elementTimingIntegrationShim, + feedbackIntegrationShim, + loggerShim, +} from '@sentry-internal/integration-shims'; registerSpanErrorInstrumentation(); @@ -25,7 +30,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; -export { elementTimingIntegration } from '@sentry-internal/browser-utils'; +export { elementTimingIntegrationShim as elementTimingIntegration }; export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; diff --git a/packages/browser/src/index.bundle.tracing.ts b/packages/browser/src/index.bundle.tracing.ts index 03e3eda95ebd..38186b3aded2 100644 --- a/packages/browser/src/index.bundle.tracing.ts +++ b/packages/browser/src/index.bundle.tracing.ts @@ -1,6 +1,7 @@ import { registerSpanErrorInstrumentation } from '@sentry/core'; import { consoleLoggingIntegrationShim, + elementTimingIntegrationShim, feedbackIntegrationShim, loggerShim, replayIntegrationShim, @@ -30,7 +31,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; -export { elementTimingIntegration } from '@sentry-internal/browser-utils'; +export { elementTimingIntegrationShim as elementTimingIntegration }; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { reportPageLoaded } from './tracing/reportPageLoaded'; diff --git a/packages/browser/src/index.bundle.ts b/packages/browser/src/index.bundle.ts index cd7de6dd80c8..7dfcd30ad2ef 100644 --- a/packages/browser/src/index.bundle.ts +++ b/packages/browser/src/index.bundle.ts @@ -1,6 +1,7 @@ import { browserTracingIntegrationShim, consoleLoggingIntegrationShim, + elementTimingIntegrationShim, feedbackIntegrationShim, loggerShim, replayIntegrationShim, @@ -13,6 +14,7 @@ export { consoleLoggingIntegrationShim as consoleLoggingIntegration, loggerShim export { browserTracingIntegrationShim as browserTracingIntegration, + elementTimingIntegrationShim as elementTimingIntegration, feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration, replayIntegrationShim as replayIntegration, diff --git a/packages/integration-shims/src/ElementTiming.ts b/packages/integration-shims/src/ElementTiming.ts new file mode 100644 index 000000000000..c9e6c05158e0 --- /dev/null +++ b/packages/integration-shims/src/ElementTiming.ts @@ -0,0 +1,17 @@ +import { consoleSandbox, defineIntegration } from '@sentry/core'; + +/** + * This is a shim for the ElementTiming integration. + * It is needed in order for the CDN bundles to continue working when users add/remove metrics + * from it, without changing their config. This is necessary for the loader mechanism. + */ +export const elementTimingIntegrationShim = defineIntegration(() => { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn('You are using elementTimingIntegration() even though this bundle does not include metrics.'); + }); + + return { + name: 'ElementTiming', + }; +}); diff --git a/packages/integration-shims/src/index.ts b/packages/integration-shims/src/index.ts index 1d535b6da35d..4cabb8a5e36f 100644 --- a/packages/integration-shims/src/index.ts +++ b/packages/integration-shims/src/index.ts @@ -2,4 +2,5 @@ export { feedbackIntegrationShim } from './Feedback'; export { replayIntegrationShim } from './Replay'; export { browserTracingIntegrationShim } from './BrowserTracing'; export { launchDarklyIntegrationShim, buildLaunchDarklyFlagUsedHandlerShim } from './launchDarkly'; +export { elementTimingIntegrationShim } from './ElementTiming'; export { loggerShim, consoleLoggingIntegrationShim } from './logs'; From 2845a9024e5046db65e6cbd8d25392fcaac865c4 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 23 Mar 2026 13:50:45 -0400 Subject: [PATCH 13/20] fix: Add sentry.origin attribute to element timing metrics Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser-utils/src/metrics/elementTiming.ts | 1 + packages/browser-utils/test/metrics/elementTiming.test.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/browser-utils/src/metrics/elementTiming.ts b/packages/browser-utils/src/metrics/elementTiming.ts index 09307c05daac..28b31e620d9b 100644 --- a/packages/browser-utils/src/metrics/elementTiming.ts +++ b/packages/browser-utils/src/metrics/elementTiming.ts @@ -41,6 +41,7 @@ const _elementTimingIntegration = (() => { const loadTime = elementEntry.loadTime; const metricAttributes: Record = { + 'sentry.origin': 'auto.ui.browser.element_timing', 'ui.element.identifier': identifier, }; diff --git a/packages/browser-utils/test/metrics/elementTiming.test.ts b/packages/browser-utils/test/metrics/elementTiming.test.ts index ef2089967c92..c661d636a651 100644 --- a/packages/browser-utils/test/metrics/elementTiming.test.ts +++ b/packages/browser-utils/test/metrics/elementTiming.test.ts @@ -74,6 +74,7 @@ describe('elementTimingIntegration', () => { expect(distributionSpy).toHaveBeenCalledWith('element_timing.render_time', 150, { unit: 'millisecond', attributes: { + 'sentry.origin': 'auto.ui.browser.element_timing', 'ui.element.identifier': 'hero-text', 'ui.element.paint_type': 'text-paint', 'ui.element.id': 'hero', @@ -106,6 +107,7 @@ describe('elementTimingIntegration', () => { expect(distributionSpy).toHaveBeenCalledTimes(2); const expectedAttributes = { + 'sentry.origin': 'auto.ui.browser.element_timing', 'ui.element.identifier': 'hero-image', 'ui.element.paint_type': 'image-paint', 'ui.element.id': 'img1', From 7e61d03ade0aa4cfe2cffb7ae34587fe2f1a086d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 23 Mar 2026 13:57:33 -0400 Subject: [PATCH 14/20] fix: Add runtime deprecation warning for enableElementTiming option Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser/src/tracing/browserTracingIntegration.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 86e61a109b8b..e176b6a702e0 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -354,6 +354,12 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { * We explicitly export the proper type here, as this has to be extended in some cases. */ export const browserTracingIntegration = ((options: Partial = {}) => { + if (DEBUG_BUILD && 'enableElementTiming' in options) { + debug.warn( + '[Tracing] `enableElementTiming` is deprecated and no longer has any effect. Use the standalone `elementTimingIntegration` instead.', + ); + } + const latestRoute: RouteInfo = { name: undefined, source: undefined, From 2baea151a6b9e81f77f5caf3ec81afc717375b29 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 23 Mar 2026 14:00:34 -0400 Subject: [PATCH 15/20] chore: format --- .../browser/src/index.bundle.tracing.replay.feedback.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.ts index 362fbc4dd266..dbff7b4dd7b3 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.ts @@ -1,5 +1,9 @@ import { registerSpanErrorInstrumentation } from '@sentry/core'; -import { consoleLoggingIntegrationShim, elementTimingIntegrationShim, loggerShim } from '@sentry-internal/integration-shims'; +import { + consoleLoggingIntegrationShim, + elementTimingIntegrationShim, + loggerShim, +} from '@sentry-internal/integration-shims'; import { feedbackAsyncIntegration } from './feedbackAsync'; registerSpanErrorInstrumentation(); From fc1e0234fd86c809e179a0d223ad5e4c95a8d6d6 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 23 Mar 2026 15:31:08 -0400 Subject: [PATCH 16/20] fix: Export real elementTimingIntegration from non-tracing metrics bundles The integration has no tracing dependency, so all .metrics bundles should export the real implementation. Also fixes misleading shim warning message. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser/src/index.bundle.logs.metrics.ts | 4 ++-- packages/browser/src/index.bundle.replay.logs.metrics.ts | 9 +++------ packages/integration-shims/src/ElementTiming.ts | 2 +- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/browser/src/index.bundle.logs.metrics.ts b/packages/browser/src/index.bundle.logs.metrics.ts index 3d7be68e3a2f..f03371dc40b8 100644 --- a/packages/browser/src/index.bundle.logs.metrics.ts +++ b/packages/browser/src/index.bundle.logs.metrics.ts @@ -1,6 +1,5 @@ import { browserTracingIntegrationShim, - elementTimingIntegrationShim, feedbackIntegrationShim, replayIntegrationShim, } from '@sentry-internal/integration-shims'; @@ -10,9 +9,10 @@ export * from './index.bundle.base'; // TODO(v11): Export metrics here once we remove it from the base bundle. export { logger, consoleLoggingIntegration } from '@sentry/core'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; + export { browserTracingIntegrationShim as browserTracingIntegration, - elementTimingIntegrationShim as elementTimingIntegration, feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration, replayIntegrationShim as replayIntegration, diff --git a/packages/browser/src/index.bundle.replay.logs.metrics.ts b/packages/browser/src/index.bundle.replay.logs.metrics.ts index d42bb609657e..6ceb7623d77f 100644 --- a/packages/browser/src/index.bundle.replay.logs.metrics.ts +++ b/packages/browser/src/index.bundle.replay.logs.metrics.ts @@ -1,8 +1,4 @@ -import { - browserTracingIntegrationShim, - elementTimingIntegrationShim, - feedbackIntegrationShim, -} from '@sentry-internal/integration-shims'; +import { browserTracingIntegrationShim, feedbackIntegrationShim } from '@sentry-internal/integration-shims'; export * from './index.bundle.base'; @@ -11,9 +7,10 @@ export { logger, consoleLoggingIntegration } from '@sentry/core'; export { replayIntegration, getReplay } from '@sentry-internal/replay'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; + export { browserTracingIntegrationShim as browserTracingIntegration, - elementTimingIntegrationShim as elementTimingIntegration, feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration, }; diff --git a/packages/integration-shims/src/ElementTiming.ts b/packages/integration-shims/src/ElementTiming.ts index c9e6c05158e0..8a521163f7e1 100644 --- a/packages/integration-shims/src/ElementTiming.ts +++ b/packages/integration-shims/src/ElementTiming.ts @@ -8,7 +8,7 @@ import { consoleSandbox, defineIntegration } from '@sentry/core'; export const elementTimingIntegrationShim = defineIntegration(() => { consoleSandbox(() => { // eslint-disable-next-line no-console - console.warn('You are using elementTimingIntegration() even though this bundle does not include metrics.'); + console.warn('You are using elementTimingIntegration() even though this bundle does not include element timing.'); }); return { From 043a377e37a0aac98bedc0a4ab4118ac1582efc7 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 23 Mar 2026 16:48:23 -0400 Subject: [PATCH 17/20] chore: Bump size limits for CDN bundles with logs/metrics Co-Authored-By: Claude Opus 4.6 (1M context) --- .size-limit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 3e0902c0a57c..fcc455808948 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -248,7 +248,7 @@ module.exports = [ path: createCDNPath('bundle.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '86 KB', + limit: '88 KB', }, { name: 'CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed', @@ -262,7 +262,7 @@ module.exports = [ path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '210 KB', + limit: '211 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', From d681cd0f1e3669a45c6f7a03fa90114205a14a72 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 25 Mar 2026 14:37:58 -0400 Subject: [PATCH 18/20] fix: Log enableElementTiming deprecation warning unconditionally Use consoleSandbox instead of DEBUG_BUILD guard so the warning shows in production bundles too. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../browser/src/tracing/browserTracingIntegration.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index e176b6a702e0..7eb87cd1d833 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -11,6 +11,7 @@ import type { import { addNonEnumerableProperty, browserPerformanceTimeOrigin, + consoleSandbox, dateTimestampInSeconds, debug, generateSpanId, @@ -354,10 +355,13 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { * We explicitly export the proper type here, as this has to be extended in some cases. */ export const browserTracingIntegration = ((options: Partial = {}) => { - if (DEBUG_BUILD && 'enableElementTiming' in options) { - debug.warn( - '[Tracing] `enableElementTiming` is deprecated and no longer has any effect. Use the standalone `elementTimingIntegration` instead.', - ); + if ('enableElementTiming' in options) { + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.warn( + '[Sentry] `enableElementTiming` is deprecated and no longer has any effect. Use the standalone `elementTimingIntegration` instead.', + ); + }); } const latestRoute: RouteInfo = { From b041ab361cb0c11fa0b27ea59d4451b49b02a08e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 25 Mar 2026 15:00:12 -0400 Subject: [PATCH 19/20] fix: Rename element timing metrics to follow ui.element.* convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename element_timing.render_time → ui.element.render_time and element_timing.load_time → ui.element.load_time to align with Sentry attribute conventions. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tracing/metrics/element-timing/test.ts | 16 ++++++++-------- .../browser-utils/src/metrics/elementTiming.ts | 6 +++--- .../test/metrics/elementTiming.test.ts | 6 +++--- 3 files changed, 14 insertions(+), 14 deletions(-) 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 index f8a65d27aa19..bbff70505c0a 100644 --- 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 @@ -47,7 +47,7 @@ function createMetricCollector(page: Page) { page.on('request', req => { if (!req.url().includes('/api/1337/envelope/')) return; const metrics = extractMetricsFromRequest(req); - if (metrics.some(m => m.name.startsWith('element_timing.'))) { + if (metrics.some(m => m.name.startsWith('ui.element.'))) { collectedRequests.push(req); } }); @@ -59,7 +59,7 @@ function createMetricCollector(page: Page) { async function waitForIdentifiers(identifiers: string[], timeout = 30_000): Promise { const deadline = Date.now() + timeout; while (Date.now() < deadline) { - const all = getAll().filter(m => m.name === 'element_timing.render_time'); + const all = getAll().filter(m => m.name === 'ui.element.render_time'); const seen = new Set(all.map(m => m.attributes['ui.element.identifier']?.value)); if (identifiers.every(id => seen.has(id))) { return; @@ -67,7 +67,7 @@ function createMetricCollector(page: Page) { await page.waitForTimeout(500); } // Final check with assertion for clear error message - const all = getAll().filter(m => m.name === 'element_timing.render_time'); + const all = getAll().filter(m => m.name === 'ui.element.render_time'); const seen = all.map(m => m.attributes['ui.element.identifier']?.value); for (const id of identifiers) { expect(seen).toContain(id); @@ -98,9 +98,9 @@ sentryTest( // Wait until all expected element identifiers have been flushed as metrics await collector.waitForIdentifiers(['image-fast', 'text1', 'button1', 'image-slow', 'lazy-image', 'lazy-text']); - const allMetrics = collector.getAll().filter(m => m.name.startsWith('element_timing.')); - const renderTimeMetrics = allMetrics.filter(m => m.name === 'element_timing.render_time'); - const loadTimeMetrics = allMetrics.filter(m => m.name === 'element_timing.load_time'); + const allMetrics = collector.getAll().filter(m => m.name.startsWith('ui.element.')); + const renderTimeMetrics = allMetrics.filter(m => m.name === 'ui.element.render_time'); + const loadTimeMetrics = allMetrics.filter(m => m.name === 'ui.element.load_time'); const renderIdentifiers = renderTimeMetrics.map(m => m.attributes['ui.element.identifier']?.value); const loadIdentifiers = loadTimeMetrics.map(m => m.attributes['ui.element.identifier']?.value); @@ -126,7 +126,7 @@ sentryTest( // Validate metric structure for image-fast const imageFastRender = renderTimeMetrics.find(m => m.attributes['ui.element.identifier']?.value === 'image-fast'); expect(imageFastRender).toMatchObject({ - name: 'element_timing.render_time', + name: 'ui.element.render_time', type: 'distribution', unit: 'millisecond', value: expect.any(Number), @@ -164,7 +164,7 @@ sentryTest('emits element timing metrics after navigation', async ({ getLocalTes await collector.waitForIdentifiers(['navigation-image', 'navigation-text']); const allMetrics = collector.getAll(); - const renderTimeMetrics = allMetrics.filter(m => m.name === 'element_timing.render_time'); + const renderTimeMetrics = allMetrics.filter(m => m.name === 'ui.element.render_time'); const renderIdentifiers = renderTimeMetrics.map(m => m.attributes['ui.element.identifier']?.value); expect(renderIdentifiers).toContain('navigation-image'); diff --git a/packages/browser-utils/src/metrics/elementTiming.ts b/packages/browser-utils/src/metrics/elementTiming.ts index 28b31e620d9b..592ae9a3a0a3 100644 --- a/packages/browser-utils/src/metrics/elementTiming.ts +++ b/packages/browser-utils/src/metrics/elementTiming.ts @@ -70,14 +70,14 @@ const _elementTimingIntegration = (() => { } if (renderTime) { - metrics.distribution(`element_timing.render_time`, renderTime, { + metrics.distribution(`ui.element.render_time`, renderTime, { unit: 'millisecond', attributes: metricAttributes, }); } if (loadTime) { - metrics.distribution(`element_timing.load_time`, loadTime, { + metrics.distribution(`ui.element.load_time`, loadTime, { unit: 'millisecond', attributes: metricAttributes, }); @@ -98,7 +98,7 @@ const _elementTimingIntegration = (() => { *

Welcome!

* ``` * - * This emits `element_timing.render_time` and `element_timing.load_time` (for images) + * This emits `ui.element.render_time` and `ui.element.load_time` (for images) * as distribution metrics, tagged with the element's identifier and paint type. */ export const elementTimingIntegration = defineIntegration(_elementTimingIntegration); diff --git a/packages/browser-utils/test/metrics/elementTiming.test.ts b/packages/browser-utils/test/metrics/elementTiming.test.ts index c661d636a651..c58a4faf6d45 100644 --- a/packages/browser-utils/test/metrics/elementTiming.test.ts +++ b/packages/browser-utils/test/metrics/elementTiming.test.ts @@ -71,7 +71,7 @@ describe('elementTimingIntegration', () => { }); expect(distributionSpy).toHaveBeenCalledTimes(1); - expect(distributionSpy).toHaveBeenCalledWith('element_timing.render_time', 150, { + expect(distributionSpy).toHaveBeenCalledWith('ui.element.render_time', 150, { unit: 'millisecond', attributes: { 'sentry.origin': 'auto.ui.browser.element_timing', @@ -116,11 +116,11 @@ describe('elementTimingIntegration', () => { 'ui.element.width': 1920, 'ui.element.height': 1080, }; - expect(distributionSpy).toHaveBeenCalledWith('element_timing.render_time', 200, { + expect(distributionSpy).toHaveBeenCalledWith('ui.element.render_time', 200, { unit: 'millisecond', attributes: expectedAttributes, }); - expect(distributionSpy).toHaveBeenCalledWith('element_timing.load_time', 150, { + expect(distributionSpy).toHaveBeenCalledWith('ui.element.load_time', 150, { unit: 'millisecond', attributes: expectedAttributes, }); From ba9f6c14a0339dc2d15be4a48fdeaf0eecfec251 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 25 Mar 2026 22:00:49 -0400 Subject: [PATCH 20/20] fix: Use explicit `> 0` checks for element timing metric values Avoids silently dropping metrics due to falsy `0` values from cross-origin images without `Timing-Allow-Origin`, making the intent clearer. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/browser-utils/src/metrics/elementTiming.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/browser-utils/src/metrics/elementTiming.ts b/packages/browser-utils/src/metrics/elementTiming.ts index 592ae9a3a0a3..16aced700844 100644 --- a/packages/browser-utils/src/metrics/elementTiming.ts +++ b/packages/browser-utils/src/metrics/elementTiming.ts @@ -69,14 +69,14 @@ const _elementTimingIntegration = (() => { metricAttributes['ui.element.height'] = elementEntry.naturalHeight; } - if (renderTime) { + if (renderTime > 0) { metrics.distribution(`ui.element.render_time`, renderTime, { unit: 'millisecond', attributes: metricAttributes, }); } - if (loadTime) { + if (loadTime > 0) { metrics.distribution(`ui.element.load_time`, loadTime, { unit: 'millisecond', attributes: metricAttributes,