From 39961c1e4fb7078aed09a678120800682e4f59ff Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 19 Mar 2026 02:07:33 -0400 Subject: [PATCH 1/6] 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 2/6] 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 096763e4d2926d8bed988f8102a2fa8d31353262 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 19 Mar 2026 10:42:14 -0400 Subject: [PATCH 3/6] 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 b1574164b739c82e33a98b6d5053f30e916fe21e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 19 Mar 2026 12:17:44 -0400 Subject: [PATCH 4/6] 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 1d1044770d29b120faacc14a2a7100418a159555 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 19 Mar 2026 13:05:59 -0400 Subject: [PATCH 5/6] 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 4016f22fad1c8f7ae8f144c9e8ca8f42bbe16c09 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 19 Mar 2026 13:43:29 -0400 Subject: [PATCH 6/6] 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';