Skip to content

Commit 7680005

Browse files
committed
feat(browser): Add ElementTiming instrumentation and spans
1 parent 2e4d243 commit 7680005

File tree

11 files changed

+875
-1
lines changed

11 files changed

+875
-1
lines changed
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" />
9+
10+
<!-- eagerly rendered text (text-paint) -->
11+
<p elementtiming="text1">
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: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
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 }) => {
9+
if (shouldSkipTracingTest()) {
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.identifier': 'image-fast',
40+
'element.type': 'img',
41+
'element.size': '600x179',
42+
'element.url': 'https://sentry-test-site.example/path/to/image-fast.png',
43+
'element.render-time': expect.any(Number),
44+
'element.load-time': expect.any(Number),
45+
'element.paint-type': 'image-paint',
46+
route: '/index.html',
47+
});
48+
expect(imageFastRenderTime).toBeGreaterThan(90);
49+
expect(imageFastRenderTime).toBeLessThan(200);
50+
expect(imageFastLoadTime).toBeGreaterThan(90);
51+
expect(imageFastLoadTime).toBeLessThan(200);
52+
expect(imageFastRenderTime).toBeGreaterThan(imageFastLoadTime as number);
53+
expect(duration).toBeGreaterThan(0);
54+
expect(duration).toBeLessThan(20);
55+
56+
// Check text1 span
57+
const text1Span = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'text1');
58+
const text1RenderTime = text1Span?.data['element.render-time'];
59+
const text1LoadTime = text1Span?.data['element.load-time'];
60+
const text1Duration = text1Span!.timestamp! - text1Span!.start_timestamp!;
61+
expect(text1Span).toBeDefined();
62+
expect(text1Span?.data).toEqual({
63+
'sentry.op': 'ui.elementtiming',
64+
'sentry.origin': 'auto.ui.browser.elementtiming',
65+
'sentry.source': 'component',
66+
'sentry.span-start-time-source': 'render-time',
67+
'element.identifier': 'text1',
68+
'element.type': 'p',
69+
'element.render-time': expect.any(Number),
70+
'element.load-time': expect.any(Number),
71+
'element.paint-type': 'text-paint',
72+
route: '/index.html',
73+
});
74+
expect(text1RenderTime).toBeGreaterThan(0);
75+
expect(text1RenderTime).toBeLessThan(100);
76+
expect(text1LoadTime).toBe(0);
77+
expect(text1RenderTime).toBeGreaterThan(text1LoadTime as number);
78+
expect(text1Duration).toBe(0);
79+
80+
// Check button1 span (no need for a full assertion)
81+
const button1Span = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'button1');
82+
expect(button1Span).toBeDefined();
83+
expect(button1Span?.data).toMatchObject({
84+
'element.identifier': 'button1',
85+
'element.type': 'button',
86+
'element.paint-type': 'text-paint',
87+
route: '/index.html',
88+
});
89+
90+
// Check image-slow span
91+
const imageSlowSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'image-slow');
92+
expect(imageSlowSpan).toBeDefined();
93+
expect(imageSlowSpan?.data).toEqual({
94+
'element.identifier': 'image-slow',
95+
'element.type': 'img',
96+
'element.size': '600x179',
97+
'element.url': 'https://sentry-test-site.example/path/to/image-slow.png',
98+
'element.paint-type': 'image-paint',
99+
'element.render-time': expect.any(Number),
100+
'element.load-time': expect.any(Number),
101+
'sentry.op': 'ui.elementtiming',
102+
'sentry.origin': 'auto.ui.browser.elementtiming',
103+
'sentry.source': 'component',
104+
'sentry.span-start-time-source': 'load-time',
105+
route: '/index.html',
106+
});
107+
const imageSlowRenderTime = imageSlowSpan?.data['element.render-time'];
108+
const imageSlowLoadTime = imageSlowSpan?.data['element.load-time'];
109+
const imageSlowDuration = imageSlowSpan!.timestamp! - imageSlowSpan!.start_timestamp!;
110+
expect(imageSlowRenderTime).toBeGreaterThan(1400);
111+
expect(imageSlowRenderTime).toBeLessThan(2000);
112+
expect(imageSlowLoadTime).toBeGreaterThan(1400);
113+
expect(imageSlowLoadTime).toBeLessThan(2000);
114+
expect(imageSlowDuration).toBeGreaterThan(0);
115+
expect(imageSlowDuration).toBeLessThan(20);
116+
117+
// Check lazy-image span
118+
const lazyImageSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'lazy-image');
119+
expect(lazyImageSpan).toBeDefined();
120+
expect(lazyImageSpan?.data).toEqual({
121+
'element.identifier': 'lazy-image',
122+
'element.type': 'img',
123+
'element.size': '600x179',
124+
'element.url': 'https://sentry-test-site.example/path/to/image-lazy.png',
125+
'element.paint-type': 'image-paint',
126+
'element.render-time': expect.any(Number),
127+
'element.load-time': expect.any(Number),
128+
'sentry.op': 'ui.elementtiming',
129+
'sentry.origin': 'auto.ui.browser.elementtiming',
130+
'sentry.source': 'component',
131+
'sentry.span-start-time-source': 'load-time',
132+
route: '/index.html',
133+
});
134+
const lazyImageRenderTime = lazyImageSpan?.data['element.render-time'];
135+
const lazyImageLoadTime = lazyImageSpan?.data['element.load-time'];
136+
const lazyImageDuration = lazyImageSpan!.timestamp! - lazyImageSpan!.start_timestamp!;
137+
expect(lazyImageRenderTime).toBeGreaterThan(1000);
138+
expect(lazyImageRenderTime).toBeLessThan(1500);
139+
expect(lazyImageLoadTime).toBeGreaterThan(1000);
140+
expect(lazyImageLoadTime).toBeLessThan(1500);
141+
expect(lazyImageDuration).toBeGreaterThan(0);
142+
expect(lazyImageDuration).toBeLessThan(20);
143+
144+
// Check lazy-text span
145+
const lazyTextSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'lazy-text');
146+
expect(lazyTextSpan?.data).toMatchObject({
147+
'element.identifier': 'lazy-text',
148+
'element.type': 'p',
149+
route: '/index.html',
150+
});
151+
const lazyTextRenderTime = lazyTextSpan?.data['element.render-time'];
152+
const lazyTextLoadTime = lazyTextSpan?.data['element.load-time'];
153+
const lazyTextDuration = lazyTextSpan!.timestamp! - lazyTextSpan!.start_timestamp!;
154+
expect(lazyTextRenderTime).toBeGreaterThan(1000);
155+
expect(lazyTextRenderTime).toBeLessThan(1500);
156+
expect(lazyTextLoadTime).toBe(0);
157+
expect(lazyTextDuration).toBe(0);
158+
159+
// the div1 entry does not emit an elementTiming entry because it's neither a text nor an image
160+
expect(elementTimingSpans?.find(({ description }) => description === 'element[div1]')).toBeUndefined();
161+
},
162+
);
163+
164+
sentryTest('emits element timing spans on navigation', async ({ getLocalTestUrl, page }) => {
165+
if (shouldSkipTracingTest()) {
166+
sentryTest.skip();
167+
}
168+
169+
serveAssets(page);
170+
171+
const url = await getLocalTestUrl({ testDir: __dirname });
172+
173+
await page.goto(url);
174+
175+
const pageloadEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload');
176+
177+
const navigationEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'navigation');
178+
179+
await pageloadEventPromise;
180+
181+
await page.locator('#button1').click();
182+
183+
const navigationTransactionEvent = envelopeRequestParser(await navigationEventPromise);
184+
const pageloadTransactionEvent = envelopeRequestParser(await pageloadEventPromise);
185+
186+
const navigationElementTimingSpans = navigationTransactionEvent.spans?.filter(({ op }) => op === 'ui.elementtiming');
187+
188+
expect(navigationElementTimingSpans?.length).toEqual(2);
189+
190+
const navigationStartTime = navigationTransactionEvent.start_timestamp!;
191+
const pageloadStartTime = pageloadTransactionEvent.start_timestamp!;
192+
193+
const imageSpan = navigationElementTimingSpans?.find(
194+
({ description }) => description === 'element[navigation-image]',
195+
);
196+
const textSpan = navigationElementTimingSpans?.find(({ description }) => description === 'element[navigation-text]');
197+
198+
// Image started loading after navigation, but render-time and load-time still start from the time origin
199+
// of the pageload. This is somewhat a limitation (though by design according to the ElementTiming spec)
200+
expect((imageSpan!.data['element.render-time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan(
201+
navigationStartTime,
202+
);
203+
expect((imageSpan!.data['element.load-time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan(
204+
navigationStartTime,
205+
);
206+
207+
expect(textSpan?.data['element.load-time']).toBe(0);
208+
expect((textSpan!.data['element.render-time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan(
209+
navigationStartTime,
210+
);
211+
});
212+
213+
// For element timing, we're fine with just always emitting a transaction,
214+
// regardless of a parent span being present or not (as in this case)
215+
sentryTest('emits element timing spans if no parent span is active', async ({ getLocalTestUrl, page }) => {
216+
if (shouldSkipTracingTest()) {
217+
sentryTest.skip();
218+
}
219+
220+
serveAssets(page);
221+
222+
const pageloadEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload');
223+
224+
const elementTimingTransactionPromise1 = waitForTransactionRequest(
225+
page,
226+
evt => evt.contexts?.trace?.op === 'ui.elementtiming' && evt.transaction === 'element[click-image]',
227+
);
228+
229+
const elementTimingTransactionPromise2 = waitForTransactionRequest(
230+
page,
231+
evt => evt.contexts?.trace?.op === 'ui.elementtiming' && evt.transaction === 'element[click-text]',
232+
);
233+
234+
const url = await getLocalTestUrl({ testDir: __dirname });
235+
236+
await page.goto(url);
237+
238+
await pageloadEventPromise;
239+
240+
await page.locator('#button2').click();
241+
242+
const imageElementTimingTransaction = envelopeRequestParser(await elementTimingTransactionPromise1);
243+
const textElementTimingTransaction = envelopeRequestParser(await elementTimingTransactionPromise2);
244+
245+
expect(imageElementTimingTransaction.spans?.length).toEqual(0);
246+
expect(textElementTimingTransaction.spans?.length).toEqual(0);
247+
248+
const imageETattributes = imageElementTimingTransaction.contexts?.trace?.data;
249+
const textETattributes = textElementTimingTransaction.contexts?.trace?.data;
250+
251+
expect(imageETattributes).toEqual({
252+
'element.identifier': 'click-image',
253+
'element.paint-type': 'image-paint',
254+
'element.load-time': expect.any(Number),
255+
'element.render-time': expect.any(Number),
256+
'element.size': '600x179',
257+
'element.type': 'img',
258+
'element.url': 'https://sentry-test-site.example/path/to/image-click.png',
259+
'sentry.span-start-time-source': 'load-time',
260+
'sentry.op': 'ui.elementtiming',
261+
'sentry.origin': 'auto.ui.browser.elementtiming',
262+
'sentry.source': 'component',
263+
route: '/index.html',
264+
});
265+
266+
expect(textETattributes).toEqual({
267+
'element.identifier': 'click-text',
268+
'element.load-time': 0,
269+
'element.render-time': expect.any(Number),
270+
'element.paint-type': 'text-paint',
271+
'element.type': 'p',
272+
'sentry.span-start-time-source': 'render-time',
273+
'sentry.op': 'ui.elementtiming',
274+
'sentry.origin': 'auto.ui.browser.elementtiming',
275+
'sentry.source': 'component',
276+
route: '/index.html',
277+
});
278+
});
279+
280+
function serveAssets(page: Page) {
281+
page.route(/image-(fast|lazy|navigation|click)\.png/, async (route: Route) => {
282+
await new Promise(resolve => setTimeout(resolve, 100));
283+
return route.fulfill({
284+
path: `${__dirname}/assets/sentry-logo-600x179.png`,
285+
});
286+
});
287+
288+
page.route('**/image-slow.png', async (route: Route) => {
289+
await new Promise(resolve => setTimeout(resolve, 1500));
290+
return route.fulfill({
291+
path: `${__dirname}/assets/sentry-logo-600x179.png`,
292+
});
293+
});
294+
}

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)