Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
aee600b
feat(browser): Replace element timing spans with metrics via standalo…
logaretm Mar 19, 2026
4bd6b01
fix: test type error
logaretm Mar 19, 2026
74b7648
fix: Update browser integration tests for element timing metrics
logaretm Mar 19, 2026
a9790bf
fix: fix test formating
logaretm Mar 19, 2026
7bdf807
fix: Fix element timing integration tests to wait for metric flush
logaretm Mar 19, 2026
21d21eb
fix: Export elementTimingIntegration from all tracing CDN bundles
logaretm Mar 19, 2026
79107a8
fix: Replace fixed timeouts with deterministic polling in element tim…
logaretm Mar 20, 2026
2c6d013
fix: test format
logaretm Mar 20, 2026
ef2a325
fix: Remove dead enableElementTiming default value
logaretm Mar 20, 2026
4883317
fix: Make deprecated enableElementTiming optional in type
logaretm Mar 20, 2026
9f67ef9
fix: Use ui.element.* convention attributes for element timing metrics
logaretm Mar 20, 2026
1dc57fc
fix: Export elementTimingIntegration only from metrics CDN bundles
logaretm Mar 23, 2026
2845a90
fix: Add sentry.origin attribute to element timing metrics
logaretm Mar 23, 2026
7e61d03
fix: Add runtime deprecation warning for enableElementTiming option
logaretm Mar 23, 2026
2baea15
chore: format
logaretm Mar 23, 2026
fc1e023
fix: Export real elementTimingIntegration from non-tracing metrics bu…
logaretm Mar 23, 2026
043a377
chore: Bump size limits for CDN bundles with logs/metrics
logaretm Mar 23, 2026
d681cd0
fix: Log enableElementTiming deprecation warning unconditionally
logaretm Mar 25, 2026
b041ab3
fix: Rename element timing metrics to follow ui.element.* convention
logaretm Mar 25, 2026
ba9f6c1
fix: Use explicit `> 0` checks for element timing metric values
logaretm Mar 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ module.exports = [
path: createCDNPath('bundle.logs.metrics.min.js'),
gzip: false,
brotli: false,
limit: '86 KB',
limit: '88 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed',
Expand All @@ -262,7 +262,7 @@ module.exports = [
path: createCDNPath('bundle.replay.logs.metrics.min.js'),
gzip: false,
brotli: false,
limit: '210 KB',
limit: '211 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ window.Sentry = Sentry;
Sentry.init({
debug: true,
dsn: 'https://public@dsn.ingest.sentry.io/1337',
integrations: [Sentry.browserTracingIntegration()],
integrations: [Sentry.browserTracingIntegration(), Sentry.elementTimingIntegration()],
tracesSampleRate: 1,
});
Original file line number Diff line number Diff line change
@@ -1,218 +1,174 @@
import type { Page, Route } from '@playwright/test';
import type { Page, Request, Route } from '@playwright/test';
import { expect } from '@playwright/test';
import type { Envelope } from '@sentry/core';
import { sentryTest } from '../../../../utils/fixtures';
import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers';
import {
properFullEnvelopeRequestParser,
shouldSkipMetricsTest,
shouldSkipTracingTest,
} from '../../../../utils/helpers';

type MetricItem = Record<string, unknown> & {
name: string;
type: string;
value: number;
unit?: string;
attributes: Record<string, { value: string | number; type: string }>;
};

function extractMetricsFromRequest(req: Request): MetricItem[] {
try {
const envelope = properFullEnvelopeRequestParser<Envelope>(req);
const items = envelope[1];
const metrics: MetricItem[] = [];
for (const item of items) {
const [header] = item;
if (header.type === 'trace_metric') {
const payload = item[1] as { items?: MetricItem[] };
if (payload.items) {
metrics.push(...payload.items);
}
}
}
return metrics;
} catch {
return [];
Comment on lines +34 to +35
}
}

/**
* Collects element timing metrics from envelope requests on the page.
* Returns a function to get all collected metrics so far and a function
* that waits until all expected identifiers have been seen in render_time metrics.
*/
function createMetricCollector(page: Page) {
const collectedRequests: Request[] = [];

page.on('request', req => {
if (!req.url().includes('/api/1337/envelope/')) return;
const metrics = extractMetricsFromRequest(req);
if (metrics.some(m => m.name.startsWith('ui.element.'))) {
collectedRequests.push(req);
}
});

function getAll(): MetricItem[] {
return collectedRequests.flatMap(req => extractMetricsFromRequest(req));
}

async function waitForIdentifiers(identifiers: string[], timeout = 30_000): Promise<void> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const all = getAll().filter(m => m.name === 'ui.element.render_time');
const seen = new Set(all.map(m => m.attributes['ui.element.identifier']?.value));
if (identifiers.every(id => seen.has(id))) {
return;
}
await page.waitForTimeout(500);
}
// Final check with assertion for clear error message
const all = getAll().filter(m => m.name === 'ui.element.render_time');
const seen = all.map(m => m.attributes['ui.element.identifier']?.value);
for (const id of identifiers) {
expect(seen).toContain(id);
}
}

function reset(): void {
collectedRequests.length = 0;
}

return { getAll, waitForIdentifiers, reset };
}

sentryTest(
'adds element timing spans to pageload span tree for elements rendered during pageload',
'emits element timing metrics for elements rendered during pageload',
async ({ getLocalTestUrl, page, browserName }) => {
if (shouldSkipTracingTest() || browserName === 'webkit') {
if (shouldSkipTracingTest() || shouldSkipMetricsTest() || browserName === 'webkit') {
sentryTest.skip();
}

const pageloadEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload');

serveAssets(page);

const url = await getLocalTestUrl({ testDir: __dirname });
const collector = createMetricCollector(page);

await page.goto(url);

const eventData = envelopeRequestParser(await pageloadEventPromise);

const elementTimingSpans = eventData.spans?.filter(({ op }) => op === 'ui.elementtiming');

expect(elementTimingSpans?.length).toEqual(8);

// Check image-fast span (this is served with a 100ms delay)
const imageFastSpan = elementTimingSpans?.find(({ description }) => description === 'element[image-fast]');
const imageFastRenderTime = imageFastSpan?.data['element.render_time'];
const imageFastLoadTime = imageFastSpan?.data['element.load_time'];
const duration = imageFastSpan!.timestamp! - imageFastSpan!.start_timestamp;

expect(imageFastSpan).toBeDefined();
expect(imageFastSpan?.data).toEqual({
'sentry.op': 'ui.elementtiming',
'sentry.origin': 'auto.ui.browser.elementtiming',
'sentry.source': 'component',
'sentry.span_start_time_source': 'load-time',
'element.id': 'image-fast-id',
'element.identifier': 'image-fast',
'element.type': 'img',
'element.size': '600x179',
'element.url': 'https://sentry-test-site.example/path/to/image-fast.png',
'element.render_time': expect.any(Number),
'element.load_time': expect.any(Number),
'element.paint_type': 'image-paint',
'sentry.transaction_name': '/index.html',
});
expect(imageFastRenderTime).toBeGreaterThan(90);
expect(imageFastRenderTime).toBeLessThan(400);
expect(imageFastLoadTime).toBeGreaterThan(90);
expect(imageFastLoadTime).toBeLessThan(400);
expect(imageFastRenderTime).toBeGreaterThan(imageFastLoadTime as number);
expect(duration).toBeGreaterThan(0);
expect(duration).toBeLessThan(20);

// Check text1 span
const text1Span = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'text1');
const text1RenderTime = text1Span?.data['element.render_time'];
const text1LoadTime = text1Span?.data['element.load_time'];
const text1Duration = text1Span!.timestamp! - text1Span!.start_timestamp;
expect(text1Span).toBeDefined();
expect(text1Span?.data).toEqual({
'sentry.op': 'ui.elementtiming',
'sentry.origin': 'auto.ui.browser.elementtiming',
'sentry.source': 'component',
'sentry.span_start_time_source': 'render-time',
'element.id': 'text1-id',
'element.identifier': 'text1',
'element.type': 'p',
'element.render_time': expect.any(Number),
'element.load_time': expect.any(Number),
'element.paint_type': 'text-paint',
'sentry.transaction_name': '/index.html',
});
expect(text1RenderTime).toBeGreaterThan(0);
expect(text1RenderTime).toBeLessThan(300);
expect(text1LoadTime).toBe(0);
expect(text1RenderTime).toBeGreaterThan(text1LoadTime as number);
expect(text1Duration).toBe(0);

// Check button1 span (no need for a full assertion)
const button1Span = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'button1');
expect(button1Span).toBeDefined();
expect(button1Span?.data).toMatchObject({
'element.identifier': 'button1',
'element.type': 'button',
'element.paint_type': 'text-paint',
'sentry.transaction_name': '/index.html',
// Wait until all expected element identifiers have been flushed as metrics
await collector.waitForIdentifiers(['image-fast', 'text1', 'button1', 'image-slow', 'lazy-image', 'lazy-text']);

const allMetrics = collector.getAll().filter(m => m.name.startsWith('ui.element.'));
const renderTimeMetrics = allMetrics.filter(m => m.name === 'ui.element.render_time');
const loadTimeMetrics = allMetrics.filter(m => m.name === 'ui.element.load_time');

const renderIdentifiers = renderTimeMetrics.map(m => m.attributes['ui.element.identifier']?.value);
const loadIdentifiers = loadTimeMetrics.map(m => m.attributes['ui.element.identifier']?.value);

// All text and image elements should have render_time
expect(renderIdentifiers).toContain('image-fast');
expect(renderIdentifiers).toContain('text1');
expect(renderIdentifiers).toContain('button1');
expect(renderIdentifiers).toContain('image-slow');
expect(renderIdentifiers).toContain('lazy-image');
expect(renderIdentifiers).toContain('lazy-text');

// Image elements should also have load_time
expect(loadIdentifiers).toContain('image-fast');
expect(loadIdentifiers).toContain('image-slow');
expect(loadIdentifiers).toContain('lazy-image');

// Text elements should NOT have load_time (loadTime is 0 for text-paint)
expect(loadIdentifiers).not.toContain('text1');
expect(loadIdentifiers).not.toContain('button1');
expect(loadIdentifiers).not.toContain('lazy-text');

// Validate metric structure for image-fast
const imageFastRender = renderTimeMetrics.find(m => m.attributes['ui.element.identifier']?.value === 'image-fast');
expect(imageFastRender).toMatchObject({
name: 'ui.element.render_time',
type: 'distribution',
unit: 'millisecond',
value: expect.any(Number),
});
expect(imageFastRender!.attributes['ui.element.paint_type']?.value).toBe('image-paint');

// Check image-slow span
const imageSlowSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'image-slow');
expect(imageSlowSpan).toBeDefined();
expect(imageSlowSpan?.data).toEqual({
'element.id': '',
'element.identifier': 'image-slow',
'element.type': 'img',
'element.size': '600x179',
'element.url': 'https://sentry-test-site.example/path/to/image-slow.png',
'element.paint_type': 'image-paint',
'element.render_time': expect.any(Number),
'element.load_time': expect.any(Number),
'sentry.op': 'ui.elementtiming',
'sentry.origin': 'auto.ui.browser.elementtiming',
'sentry.source': 'component',
'sentry.span_start_time_source': 'load-time',
'sentry.transaction_name': '/index.html',
});
const imageSlowRenderTime = imageSlowSpan?.data['element.render_time'];
const imageSlowLoadTime = imageSlowSpan?.data['element.load_time'];
const imageSlowDuration = imageSlowSpan!.timestamp! - imageSlowSpan!.start_timestamp;
expect(imageSlowRenderTime).toBeGreaterThan(1400);
expect(imageSlowRenderTime).toBeLessThan(2000);
expect(imageSlowLoadTime).toBeGreaterThan(1400);
expect(imageSlowLoadTime).toBeLessThan(2000);
expect(imageSlowDuration).toBeGreaterThan(0);
expect(imageSlowDuration).toBeLessThan(20);

// Check lazy-image span
const lazyImageSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'lazy-image');
expect(lazyImageSpan).toBeDefined();
expect(lazyImageSpan?.data).toEqual({
'element.id': '',
'element.identifier': 'lazy-image',
'element.type': 'img',
'element.size': '600x179',
'element.url': 'https://sentry-test-site.example/path/to/image-lazy.png',
'element.paint_type': 'image-paint',
'element.render_time': expect.any(Number),
'element.load_time': expect.any(Number),
'sentry.op': 'ui.elementtiming',
'sentry.origin': 'auto.ui.browser.elementtiming',
'sentry.source': 'component',
'sentry.span_start_time_source': 'load-time',
'sentry.transaction_name': '/index.html',
});
const lazyImageRenderTime = lazyImageSpan?.data['element.render_time'];
const lazyImageLoadTime = lazyImageSpan?.data['element.load_time'];
const lazyImageDuration = lazyImageSpan!.timestamp! - lazyImageSpan!.start_timestamp;
expect(lazyImageRenderTime).toBeGreaterThan(1000);
expect(lazyImageRenderTime).toBeLessThan(1500);
expect(lazyImageLoadTime).toBeGreaterThan(1000);
expect(lazyImageLoadTime).toBeLessThan(1500);
expect(lazyImageDuration).toBeGreaterThan(0);
expect(lazyImageDuration).toBeLessThan(20);

// Check lazy-text span
const lazyTextSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'lazy-text');
expect(lazyTextSpan?.data).toMatchObject({
'element.id': '',
'element.identifier': 'lazy-text',
'element.type': 'p',
'sentry.transaction_name': '/index.html',
});
const lazyTextRenderTime = lazyTextSpan?.data['element.render_time'];
const lazyTextLoadTime = lazyTextSpan?.data['element.load_time'];
const lazyTextDuration = lazyTextSpan!.timestamp! - lazyTextSpan!.start_timestamp;
expect(lazyTextRenderTime).toBeGreaterThan(1000);
expect(lazyTextRenderTime).toBeLessThan(1500);
expect(lazyTextLoadTime).toBe(0);
expect(lazyTextDuration).toBe(0);

// the div1 entry does not emit an elementTiming entry because it's neither a text nor an image
expect(elementTimingSpans?.find(({ description }) => description === 'element[div1]')).toBeUndefined();
// Validate text-paint metric
const text1Render = renderTimeMetrics.find(m => m.attributes['ui.element.identifier']?.value === 'text1');
expect(text1Render!.attributes['ui.element.paint_type']?.value).toBe('text-paint');
},
);

sentryTest('emits element timing spans on navigation', async ({ getLocalTestUrl, page, browserName }) => {
if (shouldSkipTracingTest() || browserName === 'webkit') {
sentryTest('emits element timing metrics after navigation', async ({ getLocalTestUrl, page, browserName }) => {
if (shouldSkipTracingTest() || shouldSkipMetricsTest() || browserName === 'webkit') {
sentryTest.skip();
}

serveAssets(page);

const url = await getLocalTestUrl({ testDir: __dirname });
const collector = createMetricCollector(page);

await page.goto(url);

const pageloadEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload');

const navigationEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'navigation');
// Wait for pageload element timing metrics to arrive before navigating
await collector.waitForIdentifiers(['image-fast', 'text1']);

await pageloadEventPromise;
// Reset so we only capture post-navigation metrics
collector.reset();

// Trigger navigation
await page.locator('#button1').click();

const navigationTransactionEvent = envelopeRequestParser(await navigationEventPromise);
const pageloadTransactionEvent = envelopeRequestParser(await pageloadEventPromise);

const navigationElementTimingSpans = navigationTransactionEvent.spans?.filter(({ op }) => op === 'ui.elementtiming');

expect(navigationElementTimingSpans?.length).toEqual(2);

const navigationStartTime = navigationTransactionEvent.start_timestamp!;
const pageloadStartTime = pageloadTransactionEvent.start_timestamp!;

const imageSpan = navigationElementTimingSpans?.find(
({ description }) => description === 'element[navigation-image]',
);
const textSpan = navigationElementTimingSpans?.find(({ description }) => description === 'element[navigation-text]');
// Wait for navigation element timing metrics
await collector.waitForIdentifiers(['navigation-image', 'navigation-text']);

// Image started loading after navigation, but render-time and load-time still start from the time origin
// of the pageload. This is somewhat a limitation (though by design according to the ElementTiming spec)
expect((imageSpan!.data['element.render_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan(
navigationStartTime,
);
expect((imageSpan!.data['element.load_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan(
navigationStartTime,
);
const allMetrics = collector.getAll();
const renderTimeMetrics = allMetrics.filter(m => m.name === 'ui.element.render_time');
const renderIdentifiers = renderTimeMetrics.map(m => m.attributes['ui.element.identifier']?.value);

expect(textSpan?.data['element.load_time']).toBe(0);
expect((textSpan!.data['element.render_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan(
navigationStartTime,
);
expect(renderIdentifiers).toContain('navigation-image');
expect(renderIdentifiers).toContain('navigation-text');
});

function serveAssets(page: Page) {
Expand Down
2 changes: 1 addition & 1 deletion packages/browser-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export {
registerInpInteractionListener,
} from './metrics/browserMetrics';

export { startTrackingElementTiming } from './metrics/elementTiming';
export { elementTimingIntegration, startTrackingElementTiming } from './metrics/elementTiming';

export { extractNetworkProtocol } from './metrics/utils';

Expand Down
Loading
Loading