Skip to content

Commit f2f8e1f

Browse files
Lms24AbhiPrasads1gr1d
authored
feat(browser): Add ElementTiming instrumentation and spans (#16589)
This PR adds support for instrumenting and sending spans from [`ElementTiming` API ](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/elementtiming)entries. Just like with web vitals and long tasks/animation frames, we register a `PerformanceObserver` and extract spans from newly emitted ET entries. Important: - We'll by default emit ET spans. Users can opt out by setting `enableElementTiming: false` in `browserTracingIntegration` - We for now only emit an ET span, if there is an active parent span. Happy to adjust this but considering the limitations from below I'm not sure if we actually want all spans after the pageload span. For now, we'll also emit spans on other transactions that pageload (most prominently `navigation` spans as well). We could also go the route of only sending until the first navigation as with standalone CLS/LCP spans. Happy to accept any direction we wanna take this. Some noteworthy findings while working on this: - ET is only emitted for text and image nodes. - For image nodes, we get the `loadTime` which is the relative timestamp to the browser's `timeOrigin`, when the image _finished_ loading. For text nodes, `loadTime` is always `0`, since nothing needs to be loaded. - For all nodes, we get `renderTime` which is the relative timestamp to the browser's `timeOrigin`, when the node finished rendering (i.e. was painted by the browser). - In any case, we do not get start times for rendering or loading. Consequently, the span duration is - `renderTime - loadTime` for image nodes - `0` for text nodes - The span start time is: - `timeOrigin + loadTime` for image nodes - `timeOrigin + renderTime` for text nodes In addition to the raw span and conventional attributes, we also collect a bunch of ET-specific attributes: - `element.type` - tag name of the element (e.g. `img` or `p`) - `element.size` - width x height of the element - `element.render-time` - `entry.renderTime` - `element.load-time` - `entry.loadTime` - `element.url` - url of the loaded image (`undefined` for text nodes) - `element.identifier` - the identifier passed to the `elementtiming=identifier` HTML attribute - `element.paint-type` - the node paint type (`image-paint` or `text-paint`) also some additional sentry-sepcific attributes: - `route` - the route name, either from the active root span (if available) or from the scope's `transactionName` - `sentry.span-start-time-source` - the data point we used as the span start time More than happy to adjust any of this logic or attribute names, based on review feedback :) closes #13675 also ref #7292 --------- Co-authored-by: Abhijeet Prasad <aprasad@sentry.io> Co-authored-by: s1gr1d <sigrid.huemer@posteo.at>
1 parent a30fc6d commit f2f8e1f

File tree

12 files changed

+835
-6
lines changed

12 files changed

+835
-6
lines changed

.size-limit.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ module.exports = [
3838
path: 'packages/browser/build/npm/esm/index.js',
3939
import: createImport('init', 'browserTracingIntegration'),
4040
gzip: true,
41-
limit: '40 KB',
41+
limit: '40.7 KB',
4242
},
4343
{
4444
name: '@sentry/browser (incl. Tracing, Replay)',
@@ -75,7 +75,7 @@ module.exports = [
7575
path: 'packages/browser/build/npm/esm/index.js',
7676
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'),
7777
gzip: true,
78-
limit: '82 KB',
78+
limit: '83 KB',
7979
},
8080
{
8181
name: '@sentry/browser (incl. Tracing, Replay, Feedback)',
@@ -135,7 +135,7 @@ module.exports = [
135135
path: 'packages/vue/build/esm/index.js',
136136
import: createImport('init', 'browserTracingIntegration'),
137137
gzip: true,
138-
limit: '41 KB',
138+
limit: '42 KB',
139139
},
140140
// Svelte SDK (ESM)
141141
{
@@ -206,7 +206,7 @@ module.exports = [
206206
import: createImport('init'),
207207
ignore: ['next/router', 'next/constants'],
208208
gzip: true,
209-
limit: '43 KB',
209+
limit: '44 KB',
210210
},
211211
// SvelteKit SDK (ESM)
212212
{
@@ -215,7 +215,7 @@ module.exports = [
215215
import: createImport('init'),
216216
ignore: ['$app/stores'],
217217
gzip: true,
218-
limit: '40 KB',
218+
limit: '41 KB',
219219
},
220220
// Node SDK (ESM)
221221
{
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
debug: true,
7+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
8+
integrations: [Sentry.browserTracingIntegration()],
9+
tracesSampleRate: 1,
10+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
const lazyDiv = document.getElementById('content-lazy');
2+
const navigationButton = document.getElementById('button1');
3+
const navigationDiv = document.getElementById('content-navigation');
4+
const clickButton = document.getElementById('button2');
5+
const clickDiv = document.getElementById('content-click');
6+
7+
navigationButton.addEventListener('click', () => {
8+
window.history.pushState({}, '', '/some-other-path');
9+
navigationDiv.innerHTML = `
10+
<img src="https://sentry-test-site.example/path/to/image-navigation.png" elementtiming="navigation-image" />
11+
<p elementtiming="navigation-text">This is navigation content</p>
12+
`;
13+
});
14+
15+
setTimeout(() => {
16+
lazyDiv.innerHTML = `
17+
<img src="https://sentry-test-site.example/path/to/image-lazy.png" elementtiming="lazy-image" />
18+
<p elementtiming="lazy-text">This is lazy loaded content</p>
19+
`;
20+
}, 1000);
21+
22+
clickButton.addEventListener('click', () => {
23+
clickDiv.innerHTML = `
24+
<img src="https://sentry-test-site.example/path/to/image-click.png" elementtiming="click-image" />
25+
<p elementtiming="click-text">This is click loaded content</p>
26+
`;
27+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<!-- eagerly loaded image (image-paint) with fast load time -->
8+
<img src="https://sentry-test-site.example/path/to/image-fast.png" elementtiming="image-fast" id="image-fast-id"/>
9+
10+
<!-- eagerly rendered text (text-paint) -->
11+
<p elementtiming="text1" id="text1-id">
12+
This is some text content
13+
<pan>with another nested span</pan>
14+
<small>and a small text</small>
15+
</p>
16+
17+
<!--
18+
eagerly rendered div with an eagerly loaded nested image with slow load time (image-paint)
19+
Although the div has an elementtiming attribute, it will not emit an entry because it's
20+
neither a text nor an image
21+
-->
22+
<div elementtiming="div1">
23+
<h1>Header with element timing</h1>
24+
<img src="https://sentry-test-site.example/path/to/image-slow.png" elementtiming="image-nested-slow" />
25+
</div>
26+
27+
<!-- eagerly loaded image (image-paint) with slow load time -->
28+
<img src="https://sentry-test-site.example/path/to/image-slow.png" elementtiming="image-slow" />
29+
30+
<!-- lazily loaded content (image-paint and text-paint) with slow load time -->
31+
<div id="content-lazy">
32+
<p>This div will be populated lazily</p>
33+
</div>
34+
35+
<!-- content loaded after navigation (image-paint and text-paint) -->
36+
<div id="content-navigation">
37+
<p>This div will be populated after a navigation</p>
38+
</div>
39+
40+
<!-- content loaded after navigation (image-paint and text-paint) -->
41+
<div id="content-click">
42+
<p>This div will be populated on click</p>
43+
</div>
44+
45+
<!-- eagerly rendered buttons-->
46+
<button id="button1" elementtiming="button1">Navigate</button>
47+
<button id="button2" elementtiming="button2">Populate on Click</button>
48+
</body>
49+
</html>
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import type { Page, Route } from '@playwright/test';
2+
import { expect } from '@playwright/test';
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers';
5+
6+
sentryTest(
7+
'adds element timing spans to pageload span tree for elements rendered during pageload',
8+
async ({ getLocalTestUrl, page, browserName }) => {
9+
if (shouldSkipTracingTest() || browserName === 'webkit') {
10+
sentryTest.skip();
11+
}
12+
13+
const pageloadEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload');
14+
15+
serveAssets(page);
16+
17+
const url = await getLocalTestUrl({ testDir: __dirname });
18+
19+
await page.goto(url);
20+
21+
const eventData = envelopeRequestParser(await pageloadEventPromise);
22+
23+
const elementTimingSpans = eventData.spans?.filter(({ op }) => op === 'ui.elementtiming');
24+
25+
expect(elementTimingSpans?.length).toEqual(8);
26+
27+
// Check image-fast span (this is served with a 100ms delay)
28+
const imageFastSpan = elementTimingSpans?.find(({ description }) => description === 'element[image-fast]');
29+
const imageFastRenderTime = imageFastSpan?.data['element.render_time'];
30+
const imageFastLoadTime = imageFastSpan?.data['element.load_time'];
31+
const duration = imageFastSpan!.timestamp! - imageFastSpan!.start_timestamp!;
32+
33+
expect(imageFastSpan).toBeDefined();
34+
expect(imageFastSpan?.data).toEqual({
35+
'sentry.op': 'ui.elementtiming',
36+
'sentry.origin': 'auto.ui.browser.elementtiming',
37+
'sentry.source': 'component',
38+
'sentry.span_start_time_source': 'load-time',
39+
'element.id': 'image-fast-id',
40+
'element.identifier': 'image-fast',
41+
'element.type': 'img',
42+
'element.size': '600x179',
43+
'element.url': 'https://sentry-test-site.example/path/to/image-fast.png',
44+
'element.render_time': expect.any(Number),
45+
'element.load_time': expect.any(Number),
46+
'element.paint_type': 'image-paint',
47+
'sentry.transaction_name': '/index.html',
48+
});
49+
expect(imageFastRenderTime).toBeGreaterThan(90);
50+
expect(imageFastRenderTime).toBeLessThan(400);
51+
expect(imageFastLoadTime).toBeGreaterThan(90);
52+
expect(imageFastLoadTime).toBeLessThan(400);
53+
expect(imageFastRenderTime).toBeGreaterThan(imageFastLoadTime as number);
54+
expect(duration).toBeGreaterThan(0);
55+
expect(duration).toBeLessThan(20);
56+
57+
// Check text1 span
58+
const text1Span = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'text1');
59+
const text1RenderTime = text1Span?.data['element.render_time'];
60+
const text1LoadTime = text1Span?.data['element.load_time'];
61+
const text1Duration = text1Span!.timestamp! - text1Span!.start_timestamp!;
62+
expect(text1Span).toBeDefined();
63+
expect(text1Span?.data).toEqual({
64+
'sentry.op': 'ui.elementtiming',
65+
'sentry.origin': 'auto.ui.browser.elementtiming',
66+
'sentry.source': 'component',
67+
'sentry.span_start_time_source': 'render-time',
68+
'element.id': 'text1-id',
69+
'element.identifier': 'text1',
70+
'element.type': 'p',
71+
'element.render_time': expect.any(Number),
72+
'element.load_time': expect.any(Number),
73+
'element.paint_type': 'text-paint',
74+
'sentry.transaction_name': '/index.html',
75+
});
76+
expect(text1RenderTime).toBeGreaterThan(0);
77+
expect(text1RenderTime).toBeLessThan(300);
78+
expect(text1LoadTime).toBe(0);
79+
expect(text1RenderTime).toBeGreaterThan(text1LoadTime as number);
80+
expect(text1Duration).toBe(0);
81+
82+
// Check button1 span (no need for a full assertion)
83+
const button1Span = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'button1');
84+
expect(button1Span).toBeDefined();
85+
expect(button1Span?.data).toMatchObject({
86+
'element.identifier': 'button1',
87+
'element.type': 'button',
88+
'element.paint_type': 'text-paint',
89+
'sentry.transaction_name': '/index.html',
90+
});
91+
92+
// Check image-slow span
93+
const imageSlowSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'image-slow');
94+
expect(imageSlowSpan).toBeDefined();
95+
expect(imageSlowSpan?.data).toEqual({
96+
'element.id': '',
97+
'element.identifier': 'image-slow',
98+
'element.type': 'img',
99+
'element.size': '600x179',
100+
'element.url': 'https://sentry-test-site.example/path/to/image-slow.png',
101+
'element.paint_type': 'image-paint',
102+
'element.render_time': expect.any(Number),
103+
'element.load_time': expect.any(Number),
104+
'sentry.op': 'ui.elementtiming',
105+
'sentry.origin': 'auto.ui.browser.elementtiming',
106+
'sentry.source': 'component',
107+
'sentry.span_start_time_source': 'load-time',
108+
'sentry.transaction_name': '/index.html',
109+
});
110+
const imageSlowRenderTime = imageSlowSpan?.data['element.render_time'];
111+
const imageSlowLoadTime = imageSlowSpan?.data['element.load_time'];
112+
const imageSlowDuration = imageSlowSpan!.timestamp! - imageSlowSpan!.start_timestamp!;
113+
expect(imageSlowRenderTime).toBeGreaterThan(1400);
114+
expect(imageSlowRenderTime).toBeLessThan(2000);
115+
expect(imageSlowLoadTime).toBeGreaterThan(1400);
116+
expect(imageSlowLoadTime).toBeLessThan(2000);
117+
expect(imageSlowDuration).toBeGreaterThan(0);
118+
expect(imageSlowDuration).toBeLessThan(20);
119+
120+
// Check lazy-image span
121+
const lazyImageSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'lazy-image');
122+
expect(lazyImageSpan).toBeDefined();
123+
expect(lazyImageSpan?.data).toEqual({
124+
'element.id': '',
125+
'element.identifier': 'lazy-image',
126+
'element.type': 'img',
127+
'element.size': '600x179',
128+
'element.url': 'https://sentry-test-site.example/path/to/image-lazy.png',
129+
'element.paint_type': 'image-paint',
130+
'element.render_time': expect.any(Number),
131+
'element.load_time': expect.any(Number),
132+
'sentry.op': 'ui.elementtiming',
133+
'sentry.origin': 'auto.ui.browser.elementtiming',
134+
'sentry.source': 'component',
135+
'sentry.span_start_time_source': 'load-time',
136+
'sentry.transaction_name': '/index.html',
137+
});
138+
const lazyImageRenderTime = lazyImageSpan?.data['element.render_time'];
139+
const lazyImageLoadTime = lazyImageSpan?.data['element.load_time'];
140+
const lazyImageDuration = lazyImageSpan!.timestamp! - lazyImageSpan!.start_timestamp!;
141+
expect(lazyImageRenderTime).toBeGreaterThan(1000);
142+
expect(lazyImageRenderTime).toBeLessThan(1500);
143+
expect(lazyImageLoadTime).toBeGreaterThan(1000);
144+
expect(lazyImageLoadTime).toBeLessThan(1500);
145+
expect(lazyImageDuration).toBeGreaterThan(0);
146+
expect(lazyImageDuration).toBeLessThan(20);
147+
148+
// Check lazy-text span
149+
const lazyTextSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'lazy-text');
150+
expect(lazyTextSpan?.data).toMatchObject({
151+
'element.id': '',
152+
'element.identifier': 'lazy-text',
153+
'element.type': 'p',
154+
'sentry.transaction_name': '/index.html',
155+
});
156+
const lazyTextRenderTime = lazyTextSpan?.data['element.render_time'];
157+
const lazyTextLoadTime = lazyTextSpan?.data['element.load_time'];
158+
const lazyTextDuration = lazyTextSpan!.timestamp! - lazyTextSpan!.start_timestamp!;
159+
expect(lazyTextRenderTime).toBeGreaterThan(1000);
160+
expect(lazyTextRenderTime).toBeLessThan(1500);
161+
expect(lazyTextLoadTime).toBe(0);
162+
expect(lazyTextDuration).toBe(0);
163+
164+
// the div1 entry does not emit an elementTiming entry because it's neither a text nor an image
165+
expect(elementTimingSpans?.find(({ description }) => description === 'element[div1]')).toBeUndefined();
166+
},
167+
);
168+
169+
sentryTest('emits element timing spans on navigation', async ({ getLocalTestUrl, page, browserName }) => {
170+
if (shouldSkipTracingTest() || browserName === 'webkit') {
171+
sentryTest.skip();
172+
}
173+
174+
serveAssets(page);
175+
176+
const url = await getLocalTestUrl({ testDir: __dirname });
177+
178+
await page.goto(url);
179+
180+
const pageloadEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload');
181+
182+
const navigationEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'navigation');
183+
184+
await pageloadEventPromise;
185+
186+
await page.locator('#button1').click();
187+
188+
const navigationTransactionEvent = envelopeRequestParser(await navigationEventPromise);
189+
const pageloadTransactionEvent = envelopeRequestParser(await pageloadEventPromise);
190+
191+
const navigationElementTimingSpans = navigationTransactionEvent.spans?.filter(({ op }) => op === 'ui.elementtiming');
192+
193+
expect(navigationElementTimingSpans?.length).toEqual(2);
194+
195+
const navigationStartTime = navigationTransactionEvent.start_timestamp!;
196+
const pageloadStartTime = pageloadTransactionEvent.start_timestamp!;
197+
198+
const imageSpan = navigationElementTimingSpans?.find(
199+
({ description }) => description === 'element[navigation-image]',
200+
);
201+
const textSpan = navigationElementTimingSpans?.find(({ description }) => description === 'element[navigation-text]');
202+
203+
// Image started loading after navigation, but render-time and load-time still start from the time origin
204+
// of the pageload. This is somewhat a limitation (though by design according to the ElementTiming spec)
205+
expect((imageSpan!.data['element.render_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan(
206+
navigationStartTime,
207+
);
208+
expect((imageSpan!.data['element.load_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan(
209+
navigationStartTime,
210+
);
211+
212+
expect(textSpan?.data['element.load_time']).toBe(0);
213+
expect((textSpan!.data['element.render_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan(
214+
navigationStartTime,
215+
);
216+
});
217+
218+
function serveAssets(page: Page) {
219+
page.route(/image-(fast|lazy|navigation|click)\.png/, async (route: Route) => {
220+
await new Promise(resolve => setTimeout(resolve, 100));
221+
return route.fulfill({
222+
path: `${__dirname}/assets/sentry-logo-600x179.png`,
223+
});
224+
});
225+
226+
page.route('**/image-slow.png', async (route: Route) => {
227+
await new Promise(resolve => setTimeout(resolve, 1500));
228+
return route.fulfill({
229+
path: `${__dirname}/assets/sentry-logo-600x179.png`,
230+
});
231+
});
232+
}

packages/browser-utils/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export {
1717
registerInpInteractionListener,
1818
} from './metrics/browserMetrics';
1919

20+
export { startTrackingElementTiming } from './metrics/elementTiming';
21+
2022
export { extractNetworkProtocol } from './metrics/utils';
2123

2224
export { addClickKeypressInstrumentationHandler } from './instrument/dom';

0 commit comments

Comments
 (0)