Skip to content

feat(browser): Add ElementTiming instrumentation and spans #16589

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jul 1, 2025
Merged
14 changes: 7 additions & 7 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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)',
Expand Down Expand Up @@ -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)
{
Expand All @@ -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)
{
Expand All @@ -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)',
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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,
});
Original file line number Diff line number Diff line change
@@ -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 = `
<img src="https://sentry-test-site.example/path/to/image-navigation.png" elementtiming="navigation-image" />
<p elementtiming="navigation-text">This is navigation content</p>
`;
});

setTimeout(() => {
lazyDiv.innerHTML = `
<img src="https://sentry-test-site.example/path/to/image-lazy.png" elementtiming="lazy-image" />
<p elementtiming="lazy-text">This is lazy loaded content</p>
`;
}, 1000);

clickButton.addEventListener('click', () => {
clickDiv.innerHTML = `
<img src="https://sentry-test-site.example/path/to/image-click.png" elementtiming="click-image" />
<p elementtiming="click-text">This is click loaded content</p>
`;
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<!-- eagerly loaded image (image-paint) with fast load time -->
<img src="https://sentry-test-site.example/path/to/image-fast.png" elementtiming="image-fast" />

<!-- eagerly rendered text (text-paint) -->
<p elementtiming="text1">
This is some text content
<pan>with another nested span</pan>
<small>and a small text</small>
</p>

<!--
eagerly rendered div with an eagerly loaded nested image with slow load time (image-paint)
Although the div has an elementtiming attribute, it will not emit an entry because it's
neither a text nor an image
-->
<div elementtiming="div1">
<h1>Header with element timing</h1>
<img src="https://sentry-test-site.example/path/to/image-slow.png" elementtiming="image-nested-slow" />
</div>

<!-- eagerly loaded image (image-paint) with slow load time -->
<img src="https://sentry-test-site.example/path/to/image-slow.png" elementtiming="image-slow" />

<!-- lazily loaded content (image-paint and text-paint) with slow load time -->
<div id="content-lazy">
<p>This div will be populated lazily</p>
</div>

<!-- content loaded after navigation (image-paint and text-paint) -->
<div id="content-navigation">
<p>This div will be populated after a navigation</p>
</div>

<!-- content loaded after navigation (image-paint and text-paint) -->
<div id="content-click">
<p>This div will be populated on click</p>
</div>

<!-- eagerly rendered buttons-->
<button id="button1" elementtiming="button1">Navigate</button>
<button id="button2" elementtiming="button2">Populate on Click</button>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
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.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(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.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(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',
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, 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,
);
});
2 changes: 2 additions & 0 deletions packages/browser-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export {
registerInpInteractionListener,
} from './metrics/browserMetrics';

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

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

export { addClickKeypressInstrumentationHandler } from './instrument/dom';
Expand Down
Loading
Loading