Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 1.8k
ref(browser): split web vitals integration#21210
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
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
d38a93d0bdd0dc1d67f4d00cd99b46421ebfe17013330c75d1b58aa7a60615aFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import * as Sentry from '@sentry/browser'; | ||
| window.Sentry = Sentry; | ||
| Sentry.init({ | ||
| dsn: 'https://public@dsn.ingest.sentry.io/1337', | ||
| integrations: [ | ||
| Sentry.browserTracingIntegration({ | ||
| idleTimeout: 1000, | ||
| }), | ||
| ], | ||
| tracesSampleRate: 1, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| <!doctype html> | ||
| <html> | ||
| <head> | ||
| <meta charset="utf-8" /> | ||
| </head> | ||
| <body> | ||
| <div>Rendered</div> | ||
| </body> | ||
| </html> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import { expect } from '@playwright/test'; | ||
| import type { Event } from '@sentry/core'; | ||
| import { sentryTest } from '../../../../utils/fixtures'; | ||
| import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; | ||
| sentryTest.beforeEach(({ browserName }) => { | ||
| if (shouldSkipTracingTest() || browserName !== 'chromium') { | ||
| sentryTest.skip(); | ||
| } | ||
| }); | ||
| // `connection.rtt` is recorded as a measurement, which is only flushed on the pageload | ||
| // transaction. It must not leak onto navigation transactions. | ||
| sentryTest( | ||
| 'records `connection.rtt` as a measurement on pageload but not on navigation transactions', | ||
| async ({ getLocalTestUrl, page }) => { | ||
| const pageloadRequestPromise = waitForTransactionRequest(page, event => event.contexts?.trace?.op === 'pageload'); | ||
| const url = await getLocalTestUrl({ testDir: __dirname }); | ||
| await page.goto(url); | ||
| const pageloadRequest = envelopeRequestParser(await pageloadRequestPromise) as Event; | ||
| const navigationRequestPromise = waitForTransactionRequest( | ||
| page, | ||
| event => event.contexts?.trace?.op === 'navigation', | ||
| ); | ||
| await page.goto(`${url}#foo`); | ||
| const navigationRequest = envelopeRequestParser(await navigationRequestPromise) as Event; | ||
| expect(pageloadRequest.contexts?.trace?.op).toBe('pageload'); | ||
| expect(navigationRequest.contexts?.trace?.op).toBe('navigation'); | ||
| expect(pageloadRequest.measurements?.['connection.rtt']?.value).toBeDefined(); | ||
| expect(navigationRequest.measurements?.['connection.rtt']).toBeUndefined(); | ||
| }, | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -123,9 +123,11 @@ export function startTrackingWebVitals({ | ||
| : undefined; | ||
| const ttfbCleanupCallback = _trackTtfb(); | ||
| const fpFcpCleanupCallback = _trackFpFcp(); | ||
| return (): void => { | ||
| ttfbCleanupCallback(); | ||
| fpFcpCleanupCallback(); | ||
| lcpCleanupCallback?.(); | ||
| clsCleanupCallback?.(); | ||
| }; | ||
| @@ -303,7 +305,47 @@ function _trackTtfb(): () => void { | ||
| }); | ||
| } | ||
| /** Starts tracking First Paint and First Contentful Paint on the current page. */ | ||
| function _trackFpFcp(): () => void { | ||
| return addPerformanceInstrumentationHandler('paint', ({ entries }) => { | ||
| const firstHidden = getVisibilityWatcher(); | ||
| for (const entry of entries) { | ||
| // Only report if the page wasn't hidden prior to the web vital. | ||
| const shouldRecord = entry.startTime < firstHidden.firstHiddenTime; | ||
| if (entry.name === 'first-paint' && shouldRecord) { | ||
| _measurements['fp'] = { value: entry.startTime, unit: 'millisecond' }; | ||
| } | ||
| if (entry.name === 'first-contentful-paint' && shouldRecord) { | ||
| _measurements['fcp'] = { value: entry.startTime, unit: 'millisecond' }; | ||
| } | ||
| } | ||
| }); | ||
| } | ||
| interface AddPerformanceEntriesOptions { | ||
| /** | ||
| * Resource spans with `op`s matching strings in the array will not be emitted. | ||
| * | ||
| * Default: [] | ||
| */ | ||
| ignoreResourceSpans: Array<'resouce.script' | 'resource.css' | 'resource.img' | 'resource.other' | string>; | ||
| /** | ||
| * Performance spans created from browser Performance APIs, | ||
| * `performance.mark(...)` nand `performance.measure(...)` | ||
| * with `name`s matching strings in the array will not be emitted. | ||
| * | ||
| * Default: [] | ||
| */ | ||
| ignorePerformanceApiSpans: Array<string | RegExp>; | ||
| /** | ||
| * Whether span streaming is enabled. | ||
| */ | ||
| spanStreamingEnabled?: boolean; | ||
| } | ||
| interface AddWebVitalsToSpanOptions { | ||
| /** | ||
| * Flag to determine if CLS should be recorded as a measurement on the pageload span or | ||
| * sent as a standalone span instead. | ||
| @@ -322,22 +364,6 @@ interface AddPerformanceEntriesOptions { | ||
| */ | ||
| recordLcpOnPageloadSpan: boolean; | ||
| /** | ||
| * Resource spans with `op`s matching strings in the array will not be emitted. | ||
| * | ||
| * Default: [] | ||
| */ | ||
| ignoreResourceSpans: Array<'resouce.script' | 'resource.css' | 'resource.img' | 'resource.other' | string>; | ||
| /** | ||
| * Performance spans created from browser Performance APIs, | ||
| * `performance.mark(...)` nand `performance.measure(...)` | ||
| * with `name`s matching strings in the array will not be emitted. | ||
| * | ||
| * Default: [] | ||
| */ | ||
| ignorePerformanceApiSpans: Array<string | RegExp>; | ||
| /** | ||
| * Whether span streaming is enabled. | ||
| */ | ||
| @@ -353,13 +379,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries | ||
| return; | ||
| } | ||
| const { | ||
| spanStreamingEnabled, | ||
| ignorePerformanceApiSpans, | ||
| ignoreResourceSpans, | ||
| recordClsOnPageloadSpan, | ||
| recordLcpOnPageloadSpan, | ||
| } = options; | ||
| const { spanStreamingEnabled, ignorePerformanceApiSpans, ignoreResourceSpans } = options; | ||
| const timeOrigin = msToSec(origin); | ||
| @@ -390,18 +410,6 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries | ||
| case 'paint': | ||
| case 'measure': { | ||
| _addMeasureSpans(span, entry, startTime, duration, timeOrigin, ignorePerformanceApiSpans); | ||
| // capture web vitals | ||
| const firstHidden = getVisibilityWatcher(); | ||
| // Only report if the page wasn't hidden prior to the web vital. | ||
| const shouldRecord = entry.startTime < firstHidden.firstHiddenTime; | ||
| if (entry.name === 'first-paint' && shouldRecord) { | ||
| _measurements['fp'] = { value: entry.startTime, unit: 'millisecond' }; | ||
| } | ||
| if (entry.name === 'first-contentful-paint' && shouldRecord) { | ||
| _measurements['fcp'] = { value: entry.startTime, unit: 'millisecond' }; | ||
| } | ||
| break; | ||
| } | ||
| case 'resource': { | ||
| @@ -423,9 +431,29 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries | ||
| _performanceCursor = Math.max(performanceEntries.length - 1, 0); | ||
| _trackNavigator(span, spanStreamingEnabled); | ||
| } | ||
| /** | ||
| * Writes the collected web vitals (LCP, CLS, INP, TTFB, FP, FCP) onto the pageload span, | ||
| * either as measurements/attributes (v1) or as web vital attributes (span streaming). | ||
| * | ||
| * This should be called when the pageload span ends, after the web vitals have been finalized. | ||
| * It is a no-op for non-pageload spans, but always resets the collected web vital state so it | ||
| * doesn't leak into a subsequent navigation. | ||
| */ | ||
| export function addWebVitalsToSpan(span: Span, options: AddWebVitalsToSpanOptions): void { | ||
| const origin = browserPerformanceTimeOrigin(); | ||
| if (!getBrowserPerformanceAPI()?.getEntries || !origin) { | ||
| // Gatekeeper if performance API not available | ||
| resetWebVitalState(); | ||
| return; | ||
| } | ||
| const { spanStreamingEnabled, recordClsOnPageloadSpan, recordLcpOnPageloadSpan } = options; | ||
| const timeOrigin = msToSec(origin); | ||
| // Measurements are only available for pageload transactions | ||
| if (op === 'pageload') { | ||
| if (spanToJSON(span).op === 'pageload') { | ||
| _addTtfbRequestTimeToMeasurements(_measurements); | ||
| if (spanStreamingEnabled) { | ||
| @@ -458,7 +486,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries | ||
| } | ||
| Object.entries(_measurements).forEach(([measurementName, measurement]) => { | ||
| setMeasurement(measurementName, measurement.value, measurement.unit); | ||
| setMeasurement(measurementName, measurement.value, measurement.unit, span); | ||
| }); | ||
| _setWebVitalAttributes(span, options); | ||
| @@ -479,6 +507,10 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries | ||
| ); | ||
| } | ||
| resetWebVitalState(); | ||
cursor[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| function resetWebVitalState(): void { | ||
| _lcpEntry = undefined; | ||
| _clsEntry = undefined; | ||
| _measurements = {}; | ||
| @@ -794,9 +826,12 @@ function _trackNavigator(span: Span, spanStreamingEnabled: boolean | undefined): | ||
| } | ||
| if (isMeasurementValue(connection.rtt)) { | ||
| _measurements['connection.rtt'] = { value: connection.rtt, unit: 'millisecond' }; | ||
| if (spanStreamingEnabled) { | ||
| span.setAttribute('network.connection.rtt', connection.rtt); | ||
| } else if (spanToJSON(span).op === 'pageload') { | ||
| // Measurements are only recorded on the pageload span, matching the historical | ||
| // behavior where `connection.rtt` was only flushed for pageload transactions. | ||
| setMeasurement('connection.rtt', connection.rtt, 'millisecond'); | ||
cursor[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| } | ||
| } | ||
| @@ -819,7 +854,7 @@ function _trackNavigator(span: Span, spanStreamingEnabled: boolean | undefined): | ||
| } | ||
| /** Add LCP / CLS data to span to allow debugging */ | ||
| function _setWebVitalAttributes(span: Span, options: AddPerformanceEntriesOptions): void { | ||
| function _setWebVitalAttributes(span: Span, options: AddWebVitalsToSpanOptions): void { | ||
| // Only add LCP attributes if LCP is being recorded on the pageload span | ||
| if (_lcpEntry && options.recordLcpOnPageloadSpan) { | ||
| // Capture Properties of the LCP element that contributes to the LCP. | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.