|
| 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 | +} |
0 commit comments