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..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 @@ -1,218 +1,174 @@ -import type { Page, Route } from '@playwright/test'; +import type { Page, Request, Route } from '@playwright/test'; import { expect } from '@playwright/test'; +import type { Envelope } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; +import { + properFullEnvelopeRequestParser, + shouldSkipMetricsTest, + 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 []; + } +} + +/** + * 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 reset(): void { + collectedRequests.length = 0; + } + + return { getAll, waitForIdentifiers, reset }; +} 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 collector = createMetricCollector(page); 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 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 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'); + expect(renderIdentifiers).toContain('image-slow'); + expect(renderIdentifiers).toContain('lazy-image'); + expect(renderIdentifiers).toContain('lazy-text'); + + // Image elements should also have load_time + 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['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'); - // Check image-slow span - const imageSlowSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'image-slow'); - expect(imageSlowSpan).toBeDefined(); - expect(imageSlowSpan?.data).toEqual({ - 'element.id': '', - 'element.identifier': 'image-slow', - 'element.type': 'img', - 'element.size': '600x179', - 'element.url': 'https://sentry-test-site.example/path/to/image-slow.png', - 'element.paint_type': 'image-paint', - 'element.render_time': expect.any(Number), - 'element.load_time': expect.any(Number), - 'sentry.op': 'ui.elementtiming', - 'sentry.origin': 'auto.ui.browser.elementtiming', - 'sentry.source': 'component', - 'sentry.span_start_time_source': 'load-time', - 'sentry.transaction_name': '/index.html', - }); - const imageSlowRenderTime = imageSlowSpan?.data['element.render_time']; - const imageSlowLoadTime = imageSlowSpan?.data['element.load_time']; - const imageSlowDuration = imageSlowSpan!.timestamp! - imageSlowSpan!.start_timestamp; - expect(imageSlowRenderTime).toBeGreaterThan(1400); - expect(imageSlowRenderTime).toBeLessThan(2000); - expect(imageSlowLoadTime).toBeGreaterThan(1400); - expect(imageSlowLoadTime).toBeLessThan(2000); - expect(imageSlowDuration).toBeGreaterThan(0); - expect(imageSlowDuration).toBeLessThan(20); - - // Check lazy-image span - const lazyImageSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'lazy-image'); - expect(lazyImageSpan).toBeDefined(); - expect(lazyImageSpan?.data).toEqual({ - 'element.id': '', - 'element.identifier': 'lazy-image', - 'element.type': 'img', - 'element.size': '600x179', - 'element.url': 'https://sentry-test-site.example/path/to/image-lazy.png', - 'element.paint_type': 'image-paint', - 'element.render_time': expect.any(Number), - 'element.load_time': expect.any(Number), - 'sentry.op': 'ui.elementtiming', - 'sentry.origin': 'auto.ui.browser.elementtiming', - 'sentry.source': 'component', - 'sentry.span_start_time_source': 'load-time', - 'sentry.transaction_name': '/index.html', - }); - const lazyImageRenderTime = lazyImageSpan?.data['element.render_time']; - const lazyImageLoadTime = lazyImageSpan?.data['element.load_time']; - const lazyImageDuration = lazyImageSpan!.timestamp! - lazyImageSpan!.start_timestamp; - expect(lazyImageRenderTime).toBeGreaterThan(1000); - expect(lazyImageRenderTime).toBeLessThan(1500); - expect(lazyImageLoadTime).toBeGreaterThan(1000); - expect(lazyImageLoadTime).toBeLessThan(1500); - expect(lazyImageDuration).toBeGreaterThan(0); - expect(lazyImageDuration).toBeLessThan(20); - - // Check lazy-text span - const lazyTextSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'lazy-text'); - expect(lazyTextSpan?.data).toMatchObject({ - 'element.id': '', - 'element.identifier': 'lazy-text', - 'element.type': 'p', - 'sentry.transaction_name': '/index.html', - }); - const lazyTextRenderTime = lazyTextSpan?.data['element.render_time']; - const lazyTextLoadTime = lazyTextSpan?.data['element.load_time']; - const lazyTextDuration = lazyTextSpan!.timestamp! - lazyTextSpan!.start_timestamp; - expect(lazyTextRenderTime).toBeGreaterThan(1000); - expect(lazyTextRenderTime).toBeLessThan(1500); - expect(lazyTextLoadTime).toBe(0); - expect(lazyTextDuration).toBe(0); - - // the div1 entry does not emit an elementTiming entry because it's neither a text nor an image - expect(elementTimingSpans?.find(({ description }) => description === 'element[div1]')).toBeUndefined(); + // 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'); }, ); -sentryTest('emits element timing spans on navigation', async ({ getLocalTestUrl, page, browserName }) => { - if (shouldSkipTracingTest() || browserName === 'webkit') { +sentryTest('emits element timing metrics after navigation', async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipTracingTest() || shouldSkipMetricsTest() || browserName === 'webkit') { sentryTest.skip(); } serveAssets(page); const url = await getLocalTestUrl({ testDir: __dirname }); + const collector = createMetricCollector(page); await page.goto(url); - const pageloadEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); - - const navigationEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'navigation'); + // Wait for pageload element timing metrics to arrive before navigating + await collector.waitForIdentifiers(['image-fast', 'text1']); - await pageloadEventPromise; + // Reset so we only capture post-navigation metrics + collector.reset(); + // Trigger navigation await page.locator('#button1').click(); - const navigationTransactionEvent = envelopeRequestParser(await navigationEventPromise); - const pageloadTransactionEvent = envelopeRequestParser(await pageloadEventPromise); - - const navigationElementTimingSpans = navigationTransactionEvent.spans?.filter(({ op }) => op === 'ui.elementtiming'); - - expect(navigationElementTimingSpans?.length).toEqual(2); - - const navigationStartTime = navigationTransactionEvent.start_timestamp!; - const pageloadStartTime = pageloadTransactionEvent.start_timestamp!; - - const imageSpan = navigationElementTimingSpans?.find( - ({ description }) => description === 'element[navigation-image]', - ); - const textSpan = navigationElementTimingSpans?.find(({ description }) => description === 'element[navigation-text]'); + // Wait for navigation element timing metrics + await collector.waitForIdentifiers(['navigation-image', 'navigation-text']); - // Image started loading after navigation, but render-time and load-time still start from the time origin - // of the pageload. This is somewhat a limitation (though by design according to the ElementTiming spec) - expect((imageSpan!.data['element.render_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan( - navigationStartTime, - ); - expect((imageSpan!.data['element.load_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan( - navigationStartTime, - ); + 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); - 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) { 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..bf224a45573d 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.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'; 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..86e61a109b8b 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,12 +145,10 @@ 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; + enableElementTiming?: boolean; /** * Flag to disable patching all together for fetch requests. @@ -337,7 +334,6 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { enableLongTask: true, enableLongAnimationFrame: true, enableInp: true, - enableElementTiming: true, ignoreResourceSpans: [], ignorePerformanceApiSpans: [], detectRedirects: true, @@ -371,7 +367,6 @@ export const browserTracingIntegration = ((options: Partial