From 5379c13f8abca54158733d9467bb51b0fd5741fe Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 16 Jun 2025 13:25:00 +0200 Subject: [PATCH 01/10] feat(browser): Add ElementTiming instrumentation and spans --- .../assets/sentry-logo-600x179.png | Bin 0 -> 16118 bytes .../tracing/metrics/element-timing/init.js | 10 + .../tracing/metrics/element-timing/subject.js | 27 ++ .../metrics/element-timing/template.html | 49 +++ .../tracing/metrics/element-timing/test.ts | 294 +++++++++++++++ packages/browser-utils/src/index.ts | 2 + .../src/metrics/elementTiming.ts | 118 ++++++ .../browser-utils/src/metrics/instrument.ts | 3 +- .../src/metrics/web-vitals/lib/observe.ts | 3 + .../instrument/metrics/elementTiming.test.ts | 355 ++++++++++++++++++ .../src/tracing/browserTracingIntegration.ts | 15 + 11 files changed, 875 insertions(+), 1 deletion(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/assets/sentry-logo-600x179.png create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts create mode 100644 packages/browser-utils/src/metrics/elementTiming.ts create mode 100644 packages/browser-utils/test/instrument/metrics/elementTiming.test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/assets/sentry-logo-600x179.png b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/assets/sentry-logo-600x179.png new file mode 100644 index 0000000000000000000000000000000000000000..353b7233d6bfa4f026f9998cacfa4add4bba9274 GIT binary patch literal 16118 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 Cl9AH@ literal 0 HcmV?d00001 diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/init.js new file mode 100644 index 000000000000..5a4cb2dff8b7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/init.js @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + debug: true, + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration()], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/subject.js new file mode 100644 index 000000000000..0fff6c2a88e6 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/subject.js @@ -0,0 +1,27 @@ +const lazyDiv = document.getElementById('content-lazy'); +const navigationButton = document.getElementById('button1'); +const navigationDiv = document.getElementById('content-navigation'); +const clickButton = document.getElementById('button2'); +const clickDiv = document.getElementById('content-click'); + +navigationButton.addEventListener('click', () => { + window.history.pushState({}, '', '/some-other-path'); + navigationDiv.innerHTML = ` + +

This is navigation content

+ `; +}); + +setTimeout(() => { + lazyDiv.innerHTML = ` + +

This is lazy loaded content

+ `; +}, 1000); + +clickButton.addEventListener('click', () => { + clickDiv.innerHTML = ` + +

This is click loaded content

+ `; +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/template.html new file mode 100644 index 000000000000..a9bc15eb4c70 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/template.html @@ -0,0 +1,49 @@ + + + + + + + + + + +

+ This is some text content + with another nested span + and a small text +

+ + +
+

Header with element timing

+ +
+ + + + + +
+

This div will be populated lazily

+
+ + +
+

This div will be populated after a navigation

+
+ + +
+

This div will be populated on click

+
+ + + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts new file mode 100644 index 000000000000..830121c4a7de --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts @@ -0,0 +1,294 @@ +import type { Page, Route } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; + +sentryTest( + 'adds element timing spans to pageload span tree for elements rendered during pageload', + async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + const pageloadEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); + + serveAssets(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const eventData = envelopeRequestParser(await pageloadEventPromise); + + const elementTimingSpans = eventData.spans?.filter(({ op }) => op === 'ui.elementtiming'); + + expect(elementTimingSpans?.length).toEqual(8); + + // Check image-fast span (this is served with a 100ms delay) + const imageFastSpan = elementTimingSpans?.find(({ description }) => description === 'element[image-fast]'); + const imageFastRenderTime = imageFastSpan?.data['element.render-time']; + const imageFastLoadTime = imageFastSpan?.data['element.load-time']; + const duration = imageFastSpan!.timestamp! - imageFastSpan!.start_timestamp!; + + expect(imageFastSpan).toBeDefined(); + expect(imageFastSpan?.data).toEqual({ + 'sentry.op': 'ui.elementtiming', + 'sentry.origin': 'auto.ui.browser.elementtiming', + 'sentry.source': 'component', + 'sentry.span-start-time-source': 'load-time', + 'element.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', + route: '/index.html', + }); + expect(imageFastRenderTime).toBeGreaterThan(90); + expect(imageFastRenderTime).toBeLessThan(200); + expect(imageFastLoadTime).toBeGreaterThan(90); + expect(imageFastLoadTime).toBeLessThan(200); + 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.identifier': 'text1', + 'element.type': 'p', + 'element.render-time': expect.any(Number), + 'element.load-time': expect.any(Number), + 'element.paint-type': 'text-paint', + route: '/index.html', + }); + expect(text1RenderTime).toBeGreaterThan(0); + expect(text1RenderTime).toBeLessThan(100); + 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', + route: '/index.html', + }); + + // Check image-slow span + const imageSlowSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'image-slow'); + expect(imageSlowSpan).toBeDefined(); + expect(imageSlowSpan?.data).toEqual({ + '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', + route: '/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.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', + route: '/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.identifier': 'lazy-text', + 'element.type': 'p', + route: '/index.html', + }); + const lazyTextRenderTime = lazyTextSpan?.data['element.render-time']; + const lazyTextLoadTime = lazyTextSpan?.data['element.load-time']; + const lazyTextDuration = lazyTextSpan!.timestamp! - lazyTextSpan!.start_timestamp!; + expect(lazyTextRenderTime).toBeGreaterThan(1000); + expect(lazyTextRenderTime).toBeLessThan(1500); + expect(lazyTextLoadTime).toBe(0); + expect(lazyTextDuration).toBe(0); + + // the div1 entry does not emit an elementTiming entry because it's neither a text nor an image + expect(elementTimingSpans?.find(({ description }) => description === 'element[div1]')).toBeUndefined(); + }, +); + +sentryTest('emits element timing spans on navigation', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + serveAssets(page); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + const pageloadEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); + + const navigationEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'navigation'); + + await pageloadEventPromise; + + await page.locator('#button1').click(); + + const navigationTransactionEvent = envelopeRequestParser(await navigationEventPromise); + const pageloadTransactionEvent = envelopeRequestParser(await pageloadEventPromise); + + const navigationElementTimingSpans = navigationTransactionEvent.spans?.filter(({ op }) => op === 'ui.elementtiming'); + + expect(navigationElementTimingSpans?.length).toEqual(2); + + const navigationStartTime = navigationTransactionEvent.start_timestamp!; + const pageloadStartTime = pageloadTransactionEvent.start_timestamp!; + + const imageSpan = navigationElementTimingSpans?.find( + ({ description }) => description === 'element[navigation-image]', + ); + const textSpan = navigationElementTimingSpans?.find(({ description }) => description === 'element[navigation-text]'); + + // Image started loading after navigation, but render-time and load-time still start from the time origin + // of the pageload. This is somewhat a limitation (though by design according to the ElementTiming spec) + expect((imageSpan!.data['element.render-time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan( + navigationStartTime, + ); + expect((imageSpan!.data['element.load-time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan( + navigationStartTime, + ); + + expect(textSpan?.data['element.load-time']).toBe(0); + expect((textSpan!.data['element.render-time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan( + navigationStartTime, + ); +}); + +// For element timing, we're fine with just always emitting a transaction, +// regardless of a parent span being present or not (as in this case) +sentryTest('emits element timing spans if no parent span is active', async ({ getLocalTestUrl, page }) => { + if (shouldSkipTracingTest()) { + sentryTest.skip(); + } + + serveAssets(page); + + const pageloadEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); + + const elementTimingTransactionPromise1 = waitForTransactionRequest( + page, + evt => evt.contexts?.trace?.op === 'ui.elementtiming' && evt.transaction === 'element[click-image]', + ); + + const elementTimingTransactionPromise2 = waitForTransactionRequest( + page, + evt => evt.contexts?.trace?.op === 'ui.elementtiming' && evt.transaction === 'element[click-text]', + ); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + await pageloadEventPromise; + + await page.locator('#button2').click(); + + const imageElementTimingTransaction = envelopeRequestParser(await elementTimingTransactionPromise1); + const textElementTimingTransaction = envelopeRequestParser(await elementTimingTransactionPromise2); + + expect(imageElementTimingTransaction.spans?.length).toEqual(0); + expect(textElementTimingTransaction.spans?.length).toEqual(0); + + const imageETattributes = imageElementTimingTransaction.contexts?.trace?.data; + const textETattributes = textElementTimingTransaction.contexts?.trace?.data; + + expect(imageETattributes).toEqual({ + 'element.identifier': 'click-image', + 'element.paint-type': 'image-paint', + 'element.load-time': expect.any(Number), + 'element.render-time': expect.any(Number), + 'element.size': '600x179', + 'element.type': 'img', + 'element.url': 'https://sentry-test-site.example/path/to/image-click.png', + 'sentry.span-start-time-source': 'load-time', + 'sentry.op': 'ui.elementtiming', + 'sentry.origin': 'auto.ui.browser.elementtiming', + 'sentry.source': 'component', + route: '/index.html', + }); + + expect(textETattributes).toEqual({ + 'element.identifier': 'click-text', + 'element.load-time': 0, + 'element.render-time': expect.any(Number), + 'element.paint-type': 'text-paint', + 'element.type': 'p', + 'sentry.span-start-time-source': 'render-time', + 'sentry.op': 'ui.elementtiming', + 'sentry.origin': 'auto.ui.browser.elementtiming', + 'sentry.source': 'component', + route: '/index.html', + }); +}); + +function serveAssets(page: Page) { + page.route(/image-(fast|lazy|navigation|click)\.png/, async (route: Route) => { + await new Promise(resolve => setTimeout(resolve, 100)); + return route.fulfill({ + path: `${__dirname}/assets/sentry-logo-600x179.png`, + }); + }); + + page.route('**/image-slow.png', async (route: Route) => { + await new Promise(resolve => setTimeout(resolve, 1500)); + return route.fulfill({ + path: `${__dirname}/assets/sentry-logo-600x179.png`, + }); + }); +} diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index f66446ea5159..0a2d9e85ade9 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -17,6 +17,8 @@ export { registerInpInteractionListener, } from './metrics/browserMetrics'; +export { startTrackingElementTiming } from './metrics/elementTiming'; + export { extractNetworkProtocol } from './metrics/utils'; export { addClickKeypressInstrumentationHandler } from './instrument/dom'; diff --git a/packages/browser-utils/src/metrics/elementTiming.ts b/packages/browser-utils/src/metrics/elementTiming.ts new file mode 100644 index 000000000000..645e93abd13f --- /dev/null +++ b/packages/browser-utils/src/metrics/elementTiming.ts @@ -0,0 +1,118 @@ +import type { SpanAttributes } from '@sentry/core'; +import { + browserPerformanceTimeOrigin, + getActiveSpan, + getCurrentScope, + getRootSpan, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + spanToJSON, + startSpan, + timestampInSeconds, +} from '@sentry/core'; +import { addPerformanceInstrumentationHandler } from './instrument'; +import { getBrowserPerformanceAPI, msToSec } from './utils'; + +// ElementTiming interface based on the W3C spec +interface PerformanceElementTiming extends PerformanceEntry { + renderTime: number; + loadTime: number; + intersectionRect: DOMRectReadOnly; + identifier: string; + naturalWidth: number; + naturalHeight: number; + id: string; + element: Element | null; + url?: string; +} + +/** + * Start tracking ElementTiming performance entries. + */ +export function startTrackingElementTiming(): () => void { + const performance = getBrowserPerformanceAPI(); + if (performance && browserPerformanceTimeOrigin()) { + return addPerformanceInstrumentationHandler('element', _onElementTiming); + } + + return () => undefined; +} + +/** + * exported only for testing + */ +export const _onElementTiming = ({ entries }: { entries: PerformanceEntry[] }): void => { + entries.forEach(entry => { + const elementEntry = entry as PerformanceElementTiming; + + // Skip entries without identifier (elementtiming attribute) + if (!elementEntry.identifier) { + return; + } + + // `name` contains the type of the element paint. Can be `'image-paint'` or `'text-paint'`. + // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming#instance_properties + const paintType = elementEntry.name as 'image-paint' | 'text-paint' | undefined; + + const renderTime = elementEntry.renderTime; + const loadTime = elementEntry.loadTime; + + // starting the span at: + // - `loadTime` if available (should be available for all "image-paint" entries, 0 otherwise) + // - `renderTime` if available (available for all entries, except 3rd party images, but these should be covered by `loadTime`, 0 otherwise) + // - `timestampInSeconds()` as a safeguard + // see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming/renderTime#cross-origin_image_render_time + const { spanStartTime, spanStartTimeSource } = loadTime + ? { spanStartTime: msToSec(loadTime), spanStartTimeSource: 'load-time' } + : renderTime + ? { spanStartTime: msToSec(renderTime), spanStartTimeSource: 'render-time' } + : { spanStartTime: timestampInSeconds(), spanStartTimeSource: 'entry-emission' }; + + const duration = + paintType === 'image-paint' + ? // for image paints, we can acually get a duration because image-paint entries also have a `loadTime` + // and `renderTime`. `loadTime` is the time when the image finished loading and `renderTime` is the + // time when the image finished rendering. + msToSec(Math.max(0, (renderTime ?? 0) - (loadTime ?? 0))) + : // for `'text-paint'` entries, we can't get a duration because the `loadTime` is always zero. + 0; + + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + const route = rootSpan ? spanToJSON(rootSpan).description : getCurrentScope().getScopeData().transactionName; + + 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, + route, + + 'element.type': elementEntry.element?.tagName?.toLowerCase() || 'unknown', + 'element.size': + elementEntry.naturalWidth && elementEntry.naturalHeight + ? `${elementEntry.naturalWidth}x${elementEntry.naturalHeight}` + : undefined, + 'element.render-time': renderTime, + 'element.load-time': loadTime, + // `url` is `0`(number) for text paints (hence we fall back to undefined) + 'element.url': elementEntry.url || undefined, + 'element.identifier': elementEntry.identifier, + 'element.paint-type': paintType, + }; + + startSpan( + { + name: `element[${elementEntry.identifier}]`, + attributes, + startTime: spanStartTime, + }, + span => { + span.end(spanStartTime + duration); + }, + ); + }); +}; diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts index cb84908ce55b..9fbf075a7712 100644 --- a/packages/browser-utils/src/metrics/instrument.ts +++ b/packages/browser-utils/src/metrics/instrument.ts @@ -13,7 +13,8 @@ type InstrumentHandlerTypePerformanceObserver = | 'navigation' | 'paint' | 'resource' - | 'first-input'; + | 'first-input' + | 'element'; type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid' | 'ttfb' | 'inp'; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts b/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts index 9af0116cd0b1..6071893dfa8e 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/observe.ts @@ -28,6 +28,9 @@ interface PerformanceEntryMap { // our `instrumentPerformanceObserver` function also observes 'longtask' // entries. longtask: PerformanceEntry[]; + // Sentry-specific change: + // We add element as a supported entry type for ElementTiming API + element: PerformanceEntry[]; } /** diff --git a/packages/browser-utils/test/instrument/metrics/elementTiming.test.ts b/packages/browser-utils/test/instrument/metrics/elementTiming.test.ts new file mode 100644 index 000000000000..c1e76becd86c --- /dev/null +++ b/packages/browser-utils/test/instrument/metrics/elementTiming.test.ts @@ -0,0 +1,355 @@ +import * as sentryCore from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { _onElementTiming, startTrackingElementTiming } from '../../../src/metrics/elementTiming'; +import * as browserMetricsInstrumentation from '../../../src/metrics/instrument'; +import * as browserMetricsUtils from '../../../src/metrics/utils'; + +describe('_onElementTiming', () => { + const spanEndSpy = vi.fn(); + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan').mockImplementation((opts, cb) => { + // @ts-expect-error - only passing a partial span. This is fine for the test. + cb({ + end: spanEndSpy, + }); + }); + + beforeEach(() => { + startSpanSpy.mockClear(); + spanEndSpy.mockClear(); + }); + + it('does nothing if the ET entry has no identifier', () => { + const entry = { + name: 'image-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 100, + } as Partial; + + // @ts-expect-error - only passing a partial entry. This is fine for the test. + _onElementTiming({ entries: [entry] }); + + expect(startSpanSpy).not.toHaveBeenCalled(); + }); + + describe('span start time', () => { + it('uses the load time as span start time if available', () => { + const entry = { + name: 'image-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 100, + loadTime: 50, + identifier: 'test-element', + } as Partial; + + // @ts-expect-error - only passing a partial entry. This is fine for the test. + _onElementTiming({ entries: [entry] }); + + expect(startSpanSpy).toHaveBeenCalledWith( + { + name: 'element[test-element]', + startTime: 0.05, + attributes: expect.objectContaining({ + 'sentry.op': 'ui.elementtiming', + 'sentry.span-start-time-source': 'load-time', + 'element.render-time': 100, + 'element.load-time': 50, + }), + }, + expect.any(Function), + ); + }); + + it('uses the render time as span start time if load time is not available', () => { + const entry = { + name: 'image-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 100, + identifier: 'test-element', + } as Partial; + + // @ts-expect-error - only passing a partial entry. This is fine for the test. + _onElementTiming({ entries: [entry] }); + + expect(startSpanSpy).toHaveBeenCalledWith( + { + name: 'element[test-element]', + startTime: 0.1, + attributes: expect.objectContaining({ + 'sentry.span-start-time-source': 'render-time', + 'element.render-time': 100, + 'element.load-time': undefined, + }), + }, + expect.any(Function), + ); + }); + + it('falls back to the time of handling the entry if load and render time are not available', () => { + const entry = { + name: 'image-paint', + entryType: 'element', + startTime: 0, + duration: 0, + identifier: 'test-element', + } as Partial; + + // @ts-expect-error - only passing a partial entry. This is fine for the test. + _onElementTiming({ entries: [entry] }); + + expect(startSpanSpy).toHaveBeenCalledWith( + { + name: 'element[test-element]', + startTime: expect.any(Number), + attributes: expect.objectContaining({ + 'sentry.span-start-time-source': 'entry-emission', + 'element.render-time': undefined, + 'element.load-time': undefined, + }), + }, + expect.any(Function), + ); + }); + }); + + describe('span duration', () => { + it('uses (render-load) time as duration for image paints', () => { + const entry = { + name: 'image-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 1505, + loadTime: 1500, + identifier: 'test-element', + } as Partial; + + // @ts-expect-error - only passing a partial entry. This is fine for the test. + _onElementTiming({ entries: [entry] }); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'element[test-element]', + startTime: 1.5, + attributes: expect.objectContaining({ + 'element.render-time': 1505, + 'element.load-time': 1500, + 'element.paint-type': 'image-paint', + }), + }), + expect.any(Function), + ); + + expect(spanEndSpy).toHaveBeenCalledWith(1.505); + }); + + it('uses 0 as duration for text paints', () => { + const entry = { + name: 'text-paint', + entryType: 'element', + startTime: 0, + duration: 0, + loadTime: 0, + renderTime: 1600, + identifier: 'test-element', + } as Partial; + + // @ts-expect-error - only passing a partial entry. This is fine for the test. + _onElementTiming({ entries: [entry] }); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'element[test-element]', + startTime: 1.6, + attributes: expect.objectContaining({ + 'element.paint-type': 'text-paint', + 'element.render-time': 1600, + 'element.load-time': 0, + }), + }), + expect.any(Function), + ); + + expect(spanEndSpy).toHaveBeenCalledWith(1.6); + }); + + // per spec, no other kinds are supported but let's make sure we're defensive + it('uses 0 as duration for other kinds of entries', () => { + const entry = { + name: 'somethingelse', + entryType: 'element', + startTime: 0, + duration: 0, + loadTime: 0, + renderTime: 1700, + identifier: 'test-element', + } as Partial; + + // @ts-expect-error - only passing a partial entry. This is fine for the test. + _onElementTiming({ entries: [entry] }); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'element[test-element]', + startTime: 1.7, + attributes: expect.objectContaining({ + 'element.paint-type': 'somethingelse', + 'element.render-time': 1700, + 'element.load-time': 0, + }), + }), + expect.any(Function), + ); + + expect(spanEndSpy).toHaveBeenCalledWith(1.7); + }); + }); + + describe('span attributes', () => { + it('sets element type, identifier, paint type, load and render time', () => { + const entry = { + name: 'image-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 100, + identifier: 'my-image', + element: { + tagName: 'IMG', + }, + } as Partial; + + // @ts-expect-error - only passing a partial entry. This is fine for the test. + _onElementTiming({ entries: [entry] }); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'element.type': 'img', + 'element.identifier': 'my-image', + 'element.paint-type': 'image-paint', + 'element.render-time': 100, + 'element.load-time': undefined, + 'element.size': undefined, + 'element.url': undefined, + }), + }), + expect.any(Function), + ); + }); + + it('sets element size if available', () => { + const entry = { + name: 'image-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 100, + naturalWidth: 512, + naturalHeight: 256, + identifier: 'my-image', + } as Partial; + + // @ts-expect-error - only passing a partial entry. This is fine for the test. + _onElementTiming({ entries: [entry] }); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'element.size': '512x256', + 'element.identifier': 'my-image', + }), + }), + expect.any(Function), + ); + }); + + it('sets element url if available', () => { + const entry = { + name: 'image-paint', + entryType: 'element', + startTime: 0, + duration: 0, + url: 'https://santry.com/image.png', + identifier: 'my-image', + } as Partial; + + // @ts-expect-error - only passing a partial entry. This is fine for the test. + _onElementTiming({ entries: [entry] }); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'element.identifier': 'my-image', + 'element.url': 'https://santry.com/image.png', + }), + }), + expect.any(Function), + ); + }); + + it('sets sentry attributes', () => { + const entry = { + name: 'image-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 100, + identifier: 'my-image', + } as Partial; + + // @ts-expect-error - only passing a partial entry. This is fine for the test. + _onElementTiming({ entries: [entry] }); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': 'ui.elementtiming', + 'sentry.origin': 'auto.ui.browser.elementtiming', + 'sentry.source': 'component', + 'sentry.span-start-time-source': 'render-time', + route: undefined, + }), + }), + expect.any(Function), + ); + }); + }); +}); + +describe('startTrackingElementTiming', () => { + const addInstrumentationHandlerSpy = vi.spyOn(browserMetricsInstrumentation, 'addPerformanceInstrumentationHandler'); + + beforeEach(() => { + addInstrumentationHandlerSpy.mockClear(); + }); + + it('returns a function that does nothing if the browser does not support the performance API', () => { + vi.spyOn(browserMetricsUtils, 'getBrowserPerformanceAPI').mockReturnValue(undefined); + expect(typeof startTrackingElementTiming()).toBe('function'); + + expect(addInstrumentationHandlerSpy).not.toHaveBeenCalled(); + }); + + it('adds an instrumentation handler for elementtiming entries, if the browser supports the performance API', () => { + vi.spyOn(browserMetricsUtils, 'getBrowserPerformanceAPI').mockReturnValue({ + getEntriesByType: vi.fn().mockReturnValue([]), + } as unknown as Performance); + + const addInstrumentationHandlerSpy = vi.spyOn( + browserMetricsInstrumentation, + 'addPerformanceInstrumentationHandler', + ); + + const stopTracking = startTrackingElementTiming(); + + expect(typeof stopTracking).toBe('function'); + + expect(addInstrumentationHandlerSpy).toHaveBeenCalledWith('element', expect.any(Function)); + }); +}); diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 1ba733ac4ca8..cf02490a3bf4 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -26,6 +26,7 @@ import { addHistoryInstrumentationHandler, addPerformanceEntries, registerInpInteractionListener, + startTrackingElementTiming, startTrackingINP, startTrackingInteractions, startTrackingLongAnimationFrames, @@ -115,6 +116,14 @@ export interface BrowserTracingOptions { */ enableInp: boolean; + /** + * If true, Sentry will capture [element timing](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming) + * information and add it to the corresponding transaction. + * + * Default: true + */ + enableElementTiming: boolean; + /** * Flag to disable patching all together for fetch requests. * @@ -268,6 +277,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { enableLongTask: true, enableLongAnimationFrame: true, enableInp: true, + enableElementTiming: true, ignoreResourceSpans: [], ignorePerformanceApiSpans: [], linkPreviousTrace: 'in-memory', @@ -299,6 +309,7 @@ export const browserTracingIntegration = ((_options: Partial Date: Mon, 16 Jun 2025 13:50:47 +0200 Subject: [PATCH 02/10] skip on webkit (surprise surprise) --- .../suites/tracing/metrics/element-timing/test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 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 830121c4a7de..9d0b16c4b3b3 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 @@ -5,8 +5,8 @@ import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest sentryTest( 'adds element timing spans to pageload span tree for elements rendered during pageload', - async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipTracingTest() || browserName === 'webkit') { sentryTest.skip(); } @@ -161,8 +161,8 @@ sentryTest( }, ); -sentryTest('emits element timing spans on navigation', async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { +sentryTest('emits element timing spans on navigation', async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipTracingTest() || browserName === 'webkit') { sentryTest.skip(); } @@ -212,8 +212,8 @@ sentryTest('emits element timing spans on navigation', async ({ getLocalTestUrl, // For element timing, we're fine with just always emitting a transaction, // regardless of a parent span being present or not (as in this case) -sentryTest('emits element timing spans if no parent span is active', async ({ getLocalTestUrl, page }) => { - if (shouldSkipTracingTest()) { +sentryTest('emits element timing spans if no parent span is active', async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipTracingTest() || browserName === 'webkit') { sentryTest.skip(); } From 140171b2f70ea52c45264e9106a4db4f326aa20b Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 16 Jun 2025 16:41:24 +0200 Subject: [PATCH 03/10] update time margins in tests to account for CI slowness --- .../suites/tracing/metrics/element-timing/test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 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 9d0b16c4b3b3..46027fe0e44b 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 @@ -46,9 +46,9 @@ sentryTest( route: '/index.html', }); expect(imageFastRenderTime).toBeGreaterThan(90); - expect(imageFastRenderTime).toBeLessThan(200); + expect(imageFastRenderTime).toBeLessThan(400); expect(imageFastLoadTime).toBeGreaterThan(90); - expect(imageFastLoadTime).toBeLessThan(200); + expect(imageFastLoadTime).toBeLessThan(400); expect(imageFastRenderTime).toBeGreaterThan(imageFastLoadTime as number); expect(duration).toBeGreaterThan(0); expect(duration).toBeLessThan(20); @@ -72,7 +72,7 @@ sentryTest( route: '/index.html', }); expect(text1RenderTime).toBeGreaterThan(0); - expect(text1RenderTime).toBeLessThan(100); + expect(text1RenderTime).toBeLessThan(300); expect(text1LoadTime).toBe(0); expect(text1RenderTime).toBeGreaterThan(text1LoadTime as number); expect(text1Duration).toBe(0); From 7e66499380026e5e333f5db17a9b29fc57d59970 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 16 Jun 2025 16:43:12 +0200 Subject: [PATCH 04/10] size limit --- .size-limit.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index c1725577c856..833357ad1c2c 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -38,21 +38,21 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '39 KB', + limit: '40.5 KB', }, { name: '@sentry/browser (incl. Tracing, Replay)', path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '77 KB', + limit: '78 KB', }, { name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags', path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '70.1 KB', + limit: '71 KB', modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -75,7 +75,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '82 KB', + limit: '83 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', @@ -120,7 +120,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '41 KB', + limit: '42 KB', }, // Vue SDK (ESM) { @@ -135,7 +135,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '41 KB', + limit: '42 KB', }, // Svelte SDK (ESM) { @@ -156,7 +156,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '39 KB', + limit: '40 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay)', From 043393907474775dbe723f2319f5d0f3cd266c0b Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 16 Jun 2025 16:51:21 +0200 Subject: [PATCH 05/10] send onlyIfParent --- .../tracing/metrics/element-timing/test.ts | 83 ------------------- .../src/metrics/elementTiming.ts | 1 + 2 files changed, 1 insertion(+), 83 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 46027fe0e44b..246b08627537 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 @@ -209,86 +209,3 @@ sentryTest('emits element timing spans on navigation', async ({ getLocalTestUrl, navigationStartTime, ); }); - -// For element timing, we're fine with just always emitting a transaction, -// regardless of a parent span being present or not (as in this case) -sentryTest('emits element timing spans if no parent span is active', async ({ getLocalTestUrl, page, browserName }) => { - if (shouldSkipTracingTest() || browserName === 'webkit') { - sentryTest.skip(); - } - - serveAssets(page); - - const pageloadEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); - - const elementTimingTransactionPromise1 = waitForTransactionRequest( - page, - evt => evt.contexts?.trace?.op === 'ui.elementtiming' && evt.transaction === 'element[click-image]', - ); - - const elementTimingTransactionPromise2 = waitForTransactionRequest( - page, - evt => evt.contexts?.trace?.op === 'ui.elementtiming' && evt.transaction === 'element[click-text]', - ); - - const url = await getLocalTestUrl({ testDir: __dirname }); - - await page.goto(url); - - await pageloadEventPromise; - - await page.locator('#button2').click(); - - const imageElementTimingTransaction = envelopeRequestParser(await elementTimingTransactionPromise1); - const textElementTimingTransaction = envelopeRequestParser(await elementTimingTransactionPromise2); - - expect(imageElementTimingTransaction.spans?.length).toEqual(0); - expect(textElementTimingTransaction.spans?.length).toEqual(0); - - const imageETattributes = imageElementTimingTransaction.contexts?.trace?.data; - const textETattributes = textElementTimingTransaction.contexts?.trace?.data; - - expect(imageETattributes).toEqual({ - 'element.identifier': 'click-image', - 'element.paint-type': 'image-paint', - 'element.load-time': expect.any(Number), - 'element.render-time': expect.any(Number), - 'element.size': '600x179', - 'element.type': 'img', - 'element.url': 'https://sentry-test-site.example/path/to/image-click.png', - 'sentry.span-start-time-source': 'load-time', - 'sentry.op': 'ui.elementtiming', - 'sentry.origin': 'auto.ui.browser.elementtiming', - 'sentry.source': 'component', - route: '/index.html', - }); - - expect(textETattributes).toEqual({ - 'element.identifier': 'click-text', - 'element.load-time': 0, - 'element.render-time': expect.any(Number), - 'element.paint-type': 'text-paint', - 'element.type': 'p', - 'sentry.span-start-time-source': 'render-time', - 'sentry.op': 'ui.elementtiming', - 'sentry.origin': 'auto.ui.browser.elementtiming', - 'sentry.source': 'component', - route: '/index.html', - }); -}); - -function serveAssets(page: Page) { - page.route(/image-(fast|lazy|navigation|click)\.png/, async (route: Route) => { - await new Promise(resolve => setTimeout(resolve, 100)); - return route.fulfill({ - path: `${__dirname}/assets/sentry-logo-600x179.png`, - }); - }); - - page.route('**/image-slow.png', async (route: Route) => { - await new Promise(resolve => setTimeout(resolve, 1500)); - return route.fulfill({ - path: `${__dirname}/assets/sentry-logo-600x179.png`, - }); - }); -} diff --git a/packages/browser-utils/src/metrics/elementTiming.ts b/packages/browser-utils/src/metrics/elementTiming.ts index 645e93abd13f..7305eae27e6e 100644 --- a/packages/browser-utils/src/metrics/elementTiming.ts +++ b/packages/browser-utils/src/metrics/elementTiming.ts @@ -109,6 +109,7 @@ export const _onElementTiming = ({ entries }: { entries: PerformanceEntry[] }): name: `element[${elementEntry.identifier}]`, attributes, startTime: spanStartTime, + onlyIfParent: true, }, span => { span.end(spanStartTime + duration); From 083d170f88b5a46c96db46d6b86a56d024097860 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 17 Jun 2025 13:23:44 -0400 Subject: [PATCH 06/10] improvements for refactors --- .../src/metrics/elementTiming.ts | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/browser-utils/src/metrics/elementTiming.ts b/packages/browser-utils/src/metrics/elementTiming.ts index 7305eae27e6e..f746b16645af 100644 --- a/packages/browser-utils/src/metrics/elementTiming.ts +++ b/packages/browser-utils/src/metrics/elementTiming.ts @@ -43,6 +43,12 @@ export function startTrackingElementTiming(): () => void { * exported only for testing */ export const _onElementTiming = ({ entries }: { entries: PerformanceEntry[] }): void => { + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + const transactionName = rootSpan + ? spanToJSON(rootSpan).description + : getCurrentScope().getScopeData().transactionName; + entries.forEach(entry => { const elementEntry = entry as PerformanceElementTiming; @@ -63,11 +69,11 @@ export const _onElementTiming = ({ entries }: { entries: PerformanceEntry[] }): // - `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 - ? { spanStartTime: msToSec(loadTime), spanStartTimeSource: 'load-time' } + const [spanStartTime, spanStartTimeSource] = loadTime + ? [msToSec(loadTime), 'load-time'] : renderTime - ? { spanStartTime: msToSec(renderTime), spanStartTimeSource: 'render-time' } - : { spanStartTime: timestampInSeconds(), spanStartTimeSource: 'entry-emission' }; + ? [msToSec(renderTime), 'render-time'] + : [timestampInSeconds(), 'entry-emission']; const duration = paintType === 'image-paint' @@ -78,30 +84,26 @@ export const _onElementTiming = ({ entries }: { entries: PerformanceEntry[] }): : // for `'text-paint'` entries, we can't get a duration because the `loadTime` is always zero. 0; - const activeSpan = getActiveSpan(); - const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; - const route = rootSpan ? spanToJSON(rootSpan).description : getCurrentScope().getScopeData().transactionName; - 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, - route, - + '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, + '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, + 'element.paint_type': paintType, }; startSpan( From cf0b02eb5c295012e10f5dad959067452049fed7 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 23 Jun 2025 22:40:31 -0400 Subject: [PATCH 07/10] serve pages func --- .../tracing/metrics/element-timing/test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 246b08627537..b7b46ca85af6 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 @@ -209,3 +209,19 @@ sentryTest('emits element timing spans on navigation', async ({ getLocalTestUrl, navigationStartTime, ); }); + +function serveAssets(page: Page) { + page.route(/image-(fast|lazy|navigation|click)\.png/, async (route: Route) => { + await new Promise(resolve => setTimeout(resolve, 100)); + return route.fulfill({ + path: `${__dirname}/assets/sentry-logo-600x179.png`, + }); + }); + + page.route('**/image-slow.png', async (route: Route) => { + await new Promise(resolve => setTimeout(resolve, 1500)); + return route.fulfill({ + path: `${__dirname}/assets/sentry-logo-600x179.png`, + }); + }); +} From 127effd9e931700f21bdb8865604dc69a308de97 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 1 Jul 2025 15:06:47 +0200 Subject: [PATCH 08/10] fix tests --- .../tracing/metrics/element-timing/test.ts | 74 +++++++++---------- .../instrument/metrics/elementTiming.test.ts | 72 ++++++++++-------- 2 files changed, 80 insertions(+), 66 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 b7b46ca85af6..5afa766244fe 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 @@ -26,8 +26,8 @@ sentryTest( // 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 imageFastRenderTime = imageFastSpan?.data['element.render_time']; + const imageFastLoadTime = imageFastSpan?.data['element.load_time']; const duration = imageFastSpan!.timestamp! - imageFastSpan!.start_timestamp!; expect(imageFastSpan).toBeDefined(); @@ -35,15 +35,15 @@ sentryTest( 'sentry.op': 'ui.elementtiming', 'sentry.origin': 'auto.ui.browser.elementtiming', 'sentry.source': 'component', - 'sentry.span-start-time-source': 'load-time', + 'sentry.span_start_time_source': 'load-time', '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', - route: '/index.html', + '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); @@ -55,21 +55,21 @@ sentryTest( // 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 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', + 'sentry.span_start_time_source': 'render-time', 'element.identifier': 'text1', 'element.type': 'p', - 'element.render-time': expect.any(Number), - 'element.load-time': expect.any(Number), - 'element.paint-type': 'text-paint', - route: '/index.html', + '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); @@ -83,8 +83,8 @@ sentryTest( expect(button1Span?.data).toMatchObject({ 'element.identifier': 'button1', 'element.type': 'button', - 'element.paint-type': 'text-paint', - route: '/index.html', + 'element.paint_type': 'text-paint', + 'sentry.transaction_name': '/index.html', }); // Check image-slow span @@ -95,17 +95,17 @@ sentryTest( '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), + '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', - route: '/index.html', + '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 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); @@ -122,17 +122,17 @@ sentryTest( '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), + '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', - route: '/index.html', + '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 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); @@ -146,10 +146,10 @@ sentryTest( expect(lazyTextSpan?.data).toMatchObject({ 'element.identifier': 'lazy-text', 'element.type': 'p', - route: '/index.html', + 'sentry.transaction_name': '/index.html', }); - const lazyTextRenderTime = lazyTextSpan?.data['element.render-time']; - const lazyTextLoadTime = lazyTextSpan?.data['element.load-time']; + 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); @@ -197,15 +197,15 @@ sentryTest('emits element timing spans on navigation', async ({ getLocalTestUrl, // 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( + expect((imageSpan!.data['element.render_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan( navigationStartTime, ); - expect((imageSpan!.data['element.load-time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan( + expect((imageSpan!.data['element.load_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan( navigationStartTime, ); - expect(textSpan?.data['element.load-time']).toBe(0); - expect((textSpan!.data['element.render-time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan( + expect(textSpan?.data['element.load_time']).toBe(0); + expect((textSpan!.data['element.render_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan( navigationStartTime, ); }); diff --git a/packages/browser-utils/test/instrument/metrics/elementTiming.test.ts b/packages/browser-utils/test/instrument/metrics/elementTiming.test.ts index c1e76becd86c..04456ceadc44 100644 --- a/packages/browser-utils/test/instrument/metrics/elementTiming.test.ts +++ b/packages/browser-utils/test/instrument/metrics/elementTiming.test.ts @@ -49,16 +49,20 @@ describe('_onElementTiming', () => { _onElementTiming({ entries: [entry] }); expect(startSpanSpy).toHaveBeenCalledWith( - { + expect.objectContaining({ name: 'element[test-element]', startTime: 0.05, attributes: expect.objectContaining({ 'sentry.op': 'ui.elementtiming', - 'sentry.span-start-time-source': 'load-time', - 'element.render-time': 100, - 'element.load-time': 50, + '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), ); }); @@ -77,15 +81,20 @@ describe('_onElementTiming', () => { _onElementTiming({ entries: [entry] }); expect(startSpanSpy).toHaveBeenCalledWith( - { + expect.objectContaining({ name: 'element[test-element]', startTime: 0.1, attributes: expect.objectContaining({ - 'sentry.span-start-time-source': 'render-time', - 'element.render-time': 100, - 'element.load-time': undefined, + '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), ); }); @@ -103,15 +112,20 @@ describe('_onElementTiming', () => { _onElementTiming({ entries: [entry] }); expect(startSpanSpy).toHaveBeenCalledWith( - { + expect.objectContaining({ name: 'element[test-element]', startTime: expect.any(Number), attributes: expect.objectContaining({ - 'sentry.span-start-time-source': 'entry-emission', - 'element.render-time': undefined, - 'element.load-time': undefined, + '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), ); }); @@ -137,9 +151,9 @@ describe('_onElementTiming', () => { name: 'element[test-element]', startTime: 1.5, attributes: expect.objectContaining({ - 'element.render-time': 1505, - 'element.load-time': 1500, - 'element.paint-type': 'image-paint', + 'element.render_time': 1505, + 'element.load_time': 1500, + 'element.paint_type': 'image-paint', }), }), expect.any(Function), @@ -167,9 +181,9 @@ describe('_onElementTiming', () => { name: 'element[test-element]', startTime: 1.6, attributes: expect.objectContaining({ - 'element.paint-type': 'text-paint', - 'element.render-time': 1600, - 'element.load-time': 0, + 'element.paint_type': 'text-paint', + 'element.render_time': 1600, + 'element.load_time': 0, }), }), expect.any(Function), @@ -198,9 +212,9 @@ describe('_onElementTiming', () => { name: 'element[test-element]', startTime: 1.7, attributes: expect.objectContaining({ - 'element.paint-type': 'somethingelse', - 'element.render-time': 1700, - 'element.load-time': 0, + 'element.paint_type': 'somethingelse', + 'element.render_time': 1700, + 'element.load_time': 0, }), }), expect.any(Function), @@ -232,9 +246,9 @@ describe('_onElementTiming', () => { attributes: expect.objectContaining({ 'element.type': 'img', 'element.identifier': 'my-image', - 'element.paint-type': 'image-paint', - 'element.render-time': 100, - 'element.load-time': undefined, + 'element.paint_type': 'image-paint', + 'element.render_time': 100, + 'element.load_time': undefined, 'element.size': undefined, 'element.url': undefined, }), @@ -312,8 +326,8 @@ describe('_onElementTiming', () => { 'sentry.op': 'ui.elementtiming', 'sentry.origin': 'auto.ui.browser.elementtiming', 'sentry.source': 'component', - 'sentry.span-start-time-source': 'render-time', - route: undefined, + 'sentry.span_start_time_source': 'render-time', + 'sentry.transaction_name': undefined, }), }), expect.any(Function), From b8dedcb49eab7b87b9ad3e237a486e4166d1547d Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 1 Jul 2025 15:39:36 +0200 Subject: [PATCH 09/10] fix integration tests --- .../suites/tracing/metrics/element-timing/template.html | 4 ++-- .../suites/tracing/metrics/element-timing/test.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/template.html index a9bc15eb4c70..6f536f8d2aa4 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/template.html +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/template.html @@ -5,10 +5,10 @@ - + -

+

This is some text content with another nested span and a small text 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 5afa766244fe..e17cbbbda691 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts @@ -36,6 +36,7 @@ sentryTest( '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', @@ -64,6 +65,7 @@ sentryTest( '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), @@ -91,6 +93,7 @@ sentryTest( 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', @@ -118,6 +121,7 @@ sentryTest( 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', @@ -144,6 +148,7 @@ sentryTest( // 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', From f6b225ce85a1b3366589b4f7c7d4ea11c24ee367 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 1 Jul 2025 15:41:29 +0200 Subject: [PATCH 10/10] increase size limit --- .size-limit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 292868d07290..685b40b00fbe 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -206,7 +206,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '43 KB', + limit: '44 KB', }, // SvelteKit SDK (ESM) { @@ -215,7 +215,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '40 KB', + limit: '41 KB', }, // Node SDK (ESM) {