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

This is navigation content

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

This is lazy loaded content

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

This is click loaded content

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

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

+ + +
+

Header with element timing

+ +
+ + + + + +
+

This div will be populated lazily

+
+ + +
+

This div will be populated after a navigation

+
+ + +
+

This div will be populated on click

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