Skip to content
Open
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('element_timing.'))) {
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 === 'element_timing.render_time');
const seen = new Set(all.map(m => m.attributes['element.identifier']?.value));
if (identifiers.every(id => seen.has(id))) {
return;
}
await page.waitForTimeout(500);
}
// Final check with assertion for clear error message
const all = getAll().filter(m => m.name === 'element_timing.render_time');
const seen = all.map(m => m.attributes['element.identifier']?.value);
for (const id of identifiers) {
expect(seen).toContain(id);
}
}

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

return { getAll, waitForIdentifiers, reset };
}

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

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

serveAssets(page);

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

await page.goto(url);

const eventData = envelopeRequestParser(await pageloadEventPromise);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

serveAssets(page);

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

await page.goto(url);

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

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

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

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

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

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

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

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

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

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

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

function serveAssets(page: Page) {
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