From 7c1f6aaa50c005dc1e51519f8fbcc708612d30df Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 5 Aug 2026 12:36:03 -0400 Subject: [PATCH] ref(browser): Restructure browser-utils by domain Reorganize browser-utils/src away from the catch-all metrics/ folder into clear domains: - instrumentation/ for event-source hooks (dom, history, location, xhr) plus the PerformanceObserver layer, renamed from metrics/instrument.ts to instrumentation/performanceObserver.ts to stop colliding with the folder - web-vitals/ for web-vital tracking, spans, inp, lcp, reportEvents, and the browser helpers merged into a single utils.ts - performance/ for generic performance-entry enrichment: entries, element/ user/resource timing, and shared utils browserMetrics.ts is split into web-vitals/tracking.ts and performance/ entries.ts, and utils.ts into web-vitals/reportEvents.ts and performance/ utils.ts. Public API (index.ts re-exports) is unchanged; only internal paths and the test tree move. --- packages/browser-utils/src/index.ts | 29 +- .../{instrument => instrumentation}/dom.ts | 0 .../history.ts | 0 .../location.ts | 0 .../performanceObserver.ts} | 0 .../{instrument => instrumentation}/xhr.ts | 0 .../web-vitals-helpers/getActivationStart.ts | 22 -- .../web-vitals-helpers/getNavigationEntry.ts | 38 --- .../web-vitals-helpers/globalListeners.ts | 20 -- .../metrics/web-vitals-helpers/onHidden.ts | 42 --- .../src/metrics/web-vitals-helpers/runOnce.ts | 25 -- .../web-vitals-helpers/whenIdleOrHidden.ts | 43 --- .../{metrics => performance}/elementTiming.ts | 2 +- .../entries.ts} | 282 +----------------- .../resourceTiming.ts | 0 .../{metrics => performance}/userTiming.ts | 2 +- .../src/{metrics => performance}/utils.ts | 53 +--- .../src/{metrics => web-vitals}/inp.ts | 2 +- .../src/{metrics => web-vitals}/lcp.ts | 0 .../src/web-vitals/reportEvents.ts | 51 ++++ .../webVitalSpans.ts => web-vitals/spans.ts} | 15 +- .../browser-utils/src/web-vitals/tracking.ts | 275 +++++++++++++++++ .../utils.ts} | 98 +++++- .../browser-utils/test/browser/utils.test.ts | 2 +- .../dom.test.ts | 2 +- .../history.test.ts | 2 +- .../location.test.ts | 2 +- .../xhr.test.ts | 2 +- .../browserMetrics.test.ts | 9 +- .../elementTiming.test.ts | 6 +- .../resourceTiming.test.ts | 4 +- .../userTiming.test.ts | 4 +- .../test/{metrics => web-vitals}/lcp.test.ts | 2 +- .../spans.test.ts} | 8 +- 34 files changed, 475 insertions(+), 567 deletions(-) rename packages/browser-utils/src/{instrument => instrumentation}/dom.ts (100%) rename packages/browser-utils/src/{instrument => instrumentation}/history.ts (100%) rename packages/browser-utils/src/{instrument => instrumentation}/location.ts (100%) rename packages/browser-utils/src/{metrics/instrument.ts => instrumentation/performanceObserver.ts} (100%) rename packages/browser-utils/src/{instrument => instrumentation}/xhr.ts (100%) delete mode 100644 packages/browser-utils/src/metrics/web-vitals-helpers/getActivationStart.ts delete mode 100644 packages/browser-utils/src/metrics/web-vitals-helpers/getNavigationEntry.ts delete mode 100644 packages/browser-utils/src/metrics/web-vitals-helpers/globalListeners.ts delete mode 100644 packages/browser-utils/src/metrics/web-vitals-helpers/onHidden.ts delete mode 100644 packages/browser-utils/src/metrics/web-vitals-helpers/runOnce.ts delete mode 100644 packages/browser-utils/src/metrics/web-vitals-helpers/whenIdleOrHidden.ts rename packages/browser-utils/src/{metrics => performance}/elementTiming.ts (97%) rename packages/browser-utils/src/{metrics/browserMetrics.ts => performance/entries.ts} (65%) rename packages/browser-utils/src/{metrics => performance}/resourceTiming.ts (100%) rename packages/browser-utils/src/{metrics => performance}/userTiming.ts (98%) rename packages/browser-utils/src/{metrics => performance}/utils.ts (57%) rename packages/browser-utils/src/{metrics => web-vitals}/inp.ts (98%) rename packages/browser-utils/src/{metrics => web-vitals}/lcp.ts (100%) create mode 100644 packages/browser-utils/src/web-vitals/reportEvents.ts rename packages/browser-utils/src/{metrics/webVitalSpans.ts => web-vitals/spans.ts} (95%) create mode 100644 packages/browser-utils/src/web-vitals/tracking.ts rename packages/browser-utils/src/{metrics/web-vitals-helpers/getVisibilityWatcher.ts => web-vitals/utils.ts} (54%) rename packages/browser-utils/test/{instrument => instrumentation}/dom.test.ts (83%) rename packages/browser-utils/test/{instrument => instrumentation}/history.test.ts (98%) rename packages/browser-utils/test/{instrument => instrumentation}/location.test.ts (91%) rename packages/browser-utils/test/{instrument => instrumentation}/xhr.test.ts (99%) rename packages/browser-utils/test/{browser => performance}/browserMetrics.test.ts (99%) rename packages/browser-utils/test/{metrics => performance}/elementTiming.test.ts (95%) rename packages/browser-utils/test/{metrics => performance}/resourceTiming.test.ts (99%) rename packages/browser-utils/test/{metrics => performance}/userTiming.test.ts (98%) rename packages/browser-utils/test/{metrics => web-vitals}/lcp.test.ts (96%) rename packages/browser-utils/test/{metrics/webVitalSpans.test.ts => web-vitals/spans.test.ts} (98%) diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 0dcff8bc2940..3105a48181b9 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -4,46 +4,49 @@ export { addTtfbInstrumentationHandler, addLcpInstrumentationHandler, addInpInstrumentationHandler, -} from './metrics/instrument'; +} from './instrumentation/performanceObserver'; export { addPerformanceEntries, - addWebVitalsToSpan, startTrackingInteractions, startTrackingLongTasks, startTrackingLongAnimationFrames, +} from './performance/entries'; + +export { + addWebVitalsToSpan, // eslint-disable-next-line typescript/no-deprecated startTrackingWebVitals, registerInpInteractionListener, -} from './metrics/browserMetrics'; +} from './web-vitals/tracking'; // eslint-disable-next-line typescript/no-deprecated -export { elementTimingIntegration, startTrackingElementTiming } from './metrics/elementTiming'; +export { elementTimingIntegration, startTrackingElementTiming } from './performance/elementTiming'; -export { userTimingIntegration } from './metrics/userTiming'; +export { userTimingIntegration } from './performance/userTiming'; -export { extractNetworkProtocol } from './metrics/utils'; +export { extractNetworkProtocol } from './performance/utils'; -export { trackClsAsSpan, trackInpAsSpan, trackLcpAsSpan } from './metrics/webVitalSpans'; +export { trackClsAsSpan, trackInpAsSpan, trackLcpAsSpan } from './web-vitals/spans'; -export { whenIdleOrHidden } from './metrics/web-vitals-helpers/whenIdleOrHidden'; +export { whenIdleOrHidden } from './web-vitals/utils'; -export { addClickKeypressInstrumentationHandler } from './instrument/dom'; +export { addClickKeypressInstrumentationHandler } from './instrumentation/dom'; -export { addHistoryInstrumentationHandler } from './instrument/history'; +export { addHistoryInstrumentationHandler } from './instrumentation/history'; export { fetch, setTimeout, clearCachedImplementation, getNativeImplementation } from './getNativeImplementation'; -export { addXhrInstrumentationHandler, SENTRY_XHR_DATA_KEY } from './instrument/xhr'; +export { addXhrInstrumentationHandler, SENTRY_XHR_DATA_KEY } from './instrumentation/xhr'; export { getBodyString, getFetchRequestArgBody, serializeFormData, parseXhrResponseHeaders } from './networkUtils'; -export { resourceTimingToSpanAttributes } from './metrics/resourceTiming'; +export { resourceTimingToSpanAttributes } from './performance/resourceTiming'; export { htmlTreeAsString } from './htmlTreeAsString'; export { isElement } from './is'; -export { getAbsoluteUrl } from './instrument/location'; +export { getAbsoluteUrl } from './instrumentation/location'; export type { FetchHint, NetworkMetaWarning, XhrHint } from './types'; diff --git a/packages/browser-utils/src/instrument/dom.ts b/packages/browser-utils/src/instrumentation/dom.ts similarity index 100% rename from packages/browser-utils/src/instrument/dom.ts rename to packages/browser-utils/src/instrumentation/dom.ts diff --git a/packages/browser-utils/src/instrument/history.ts b/packages/browser-utils/src/instrumentation/history.ts similarity index 100% rename from packages/browser-utils/src/instrument/history.ts rename to packages/browser-utils/src/instrumentation/history.ts diff --git a/packages/browser-utils/src/instrument/location.ts b/packages/browser-utils/src/instrumentation/location.ts similarity index 100% rename from packages/browser-utils/src/instrument/location.ts rename to packages/browser-utils/src/instrumentation/location.ts diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/instrumentation/performanceObserver.ts similarity index 100% rename from packages/browser-utils/src/metrics/instrument.ts rename to packages/browser-utils/src/instrumentation/performanceObserver.ts diff --git a/packages/browser-utils/src/instrument/xhr.ts b/packages/browser-utils/src/instrumentation/xhr.ts similarity index 100% rename from packages/browser-utils/src/instrument/xhr.ts rename to packages/browser-utils/src/instrumentation/xhr.ts diff --git a/packages/browser-utils/src/metrics/web-vitals-helpers/getActivationStart.ts b/packages/browser-utils/src/metrics/web-vitals-helpers/getActivationStart.ts deleted file mode 100644 index 33677466faf9..000000000000 --- a/packages/browser-utils/src/metrics/web-vitals-helpers/getActivationStart.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2022 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { getNavigationEntry } from './getNavigationEntry'; - -export const getActivationStart = (): number => { - const navEntry = getNavigationEntry(); - return navEntry?.activationStart ?? 0; -}; diff --git a/packages/browser-utils/src/metrics/web-vitals-helpers/getNavigationEntry.ts b/packages/browser-utils/src/metrics/web-vitals-helpers/getNavigationEntry.ts deleted file mode 100644 index a49cbe55ed0b..000000000000 --- a/packages/browser-utils/src/metrics/web-vitals-helpers/getNavigationEntry.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2022 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { WINDOW } from '../../types'; - -// sentry-specific change: -// add optional param to not check for responseStart (see comment below) -export const getNavigationEntry = (checkResponseStart = true): PerformanceNavigationTiming | void => { - const navigationEntry = WINDOW.performance?.getEntriesByType?.('navigation')[0]; - // Check to ensure the `responseStart` property is present and valid. - // In some cases a zero value is reported by the browser (for - // privacy/security reasons), and in other cases (bugs) the value is - // negative or is larger than the current page time. Ignore these cases: - // - https://github.com/GoogleChrome/web-vitals/issues/137 - // - https://github.com/GoogleChrome/web-vitals/issues/162 - // - https://github.com/GoogleChrome/web-vitals/issues/275 - if ( - // sentry-specific change: - // We don't want to check for responseStart for our own use of `getNavigationEntry` - !checkResponseStart || - (navigationEntry && navigationEntry.responseStart > 0 && navigationEntry.responseStart < performance.now()) - ) { - return navigationEntry; - } -}; diff --git a/packages/browser-utils/src/metrics/web-vitals-helpers/globalListeners.ts b/packages/browser-utils/src/metrics/web-vitals-helpers/globalListeners.ts deleted file mode 100644 index 8f324341a229..000000000000 --- a/packages/browser-utils/src/metrics/web-vitals-helpers/globalListeners.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { WINDOW } from '../../types'; - -/** - * web-vitals 5.1.0 switched listeners to be added on the window rather than the document. - * Instead of having to check for window/document every time we add a listener, we can use this function. - */ -export function addPageListener(type: string, listener: EventListener, options?: boolean | AddEventListenerOptions) { - if (WINDOW.document) { - WINDOW.addEventListener(type, listener, options); - } -} -/** - * web-vitals 5.1.0 switched listeners to be removed from the window rather than the document. - * Instead of having to check for window/document every time we remove a listener, we can use this function. - */ -export function removePageListener(type: string, listener: EventListener, options?: boolean | AddEventListenerOptions) { - if (WINDOW.document) { - WINDOW.removeEventListener(type, listener, options); - } -} diff --git a/packages/browser-utils/src/metrics/web-vitals-helpers/onHidden.ts b/packages/browser-utils/src/metrics/web-vitals-helpers/onHidden.ts deleted file mode 100644 index cc1a1ae3d3ee..000000000000 --- a/packages/browser-utils/src/metrics/web-vitals-helpers/onHidden.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { WINDOW } from '../../types'; -import { addPageListener } from './globalListeners'; - -export interface OnHiddenCallback { - (event: Event): void; -} - -/** - * Calls the passed callback when the page transitions to hidden. - * - * Uses the `visibilitychange` event exclusively, which is well-supported - * across all modern browsers. - * - * @param {OnHiddenCallback} cb - Callback to be executed when the page is hidden. - * - * @deprecated use `whenIdleOrHidden` or `addPageListener('visibilitychange')` instead - */ -export const onHidden = (cb: OnHiddenCallback) => { - const onHiddenCallback = (event: Event) => { - if (WINDOW.document?.visibilityState === 'hidden') { - cb(event); - } - }; - - addPageListener('visibilitychange', onHiddenCallback, { capture: true }); -}; diff --git a/packages/browser-utils/src/metrics/web-vitals-helpers/runOnce.ts b/packages/browser-utils/src/metrics/web-vitals-helpers/runOnce.ts deleted file mode 100644 index f2de2eadd2d9..000000000000 --- a/packages/browser-utils/src/metrics/web-vitals-helpers/runOnce.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2022 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export const runOnce = (cb: () => void) => { - let called = false; - return () => { - if (!called) { - cb(); - called = true; - } - }; -}; diff --git a/packages/browser-utils/src/metrics/web-vitals-helpers/whenIdleOrHidden.ts b/packages/browser-utils/src/metrics/web-vitals-helpers/whenIdleOrHidden.ts deleted file mode 100644 index c6af66611441..000000000000 --- a/packages/browser-utils/src/metrics/web-vitals-helpers/whenIdleOrHidden.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2024 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { WINDOW } from '../../types'; -import { addPageListener, removePageListener } from './globalListeners'; -import { runOnce } from './runOnce'; - -/** - * Runs the passed callback during the next idle period, or immediately - * if the browser's visibility state is (or becomes) hidden. - */ -export const whenIdleOrHidden = (cb: () => void) => { - const rIC = WINDOW.requestIdleCallback || WINDOW.setTimeout; - - // If the document is hidden, run the callback immediately, otherwise - // race an idle callback with the next `visibilitychange` event. - if (WINDOW.document?.visibilityState === 'hidden') { - cb(); - } else { - // eslint-disable-next-line no-param-reassign - cb = runOnce(cb); - addPageListener('visibilitychange', cb, { once: true, capture: true }); - rIC(() => { - cb(); - // Remove the above event listener since no longer required. - // See: https://github.com/GoogleChrome/web-vitals/issues/622 - removePageListener('visibilitychange', cb, { capture: true }); - }); - } -}; diff --git a/packages/browser-utils/src/metrics/elementTiming.ts b/packages/browser-utils/src/performance/elementTiming.ts similarity index 97% rename from packages/browser-utils/src/metrics/elementTiming.ts rename to packages/browser-utils/src/performance/elementTiming.ts index 16aced700844..6e2ac6e4e52f 100644 --- a/packages/browser-utils/src/metrics/elementTiming.ts +++ b/packages/browser-utils/src/performance/elementTiming.ts @@ -1,6 +1,6 @@ import type { IntegrationFn } from '@sentry/core'; import { browserPerformanceTimeOrigin, defineIntegration, metrics } from '@sentry/core'; -import { addPerformanceInstrumentationHandler } from './instrument'; +import { addPerformanceInstrumentationHandler } from '../instrumentation/performanceObserver'; import { getBrowserPerformanceAPI } from './utils'; // ElementTiming interface based on the W3C spec diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/performance/entries.ts similarity index 65% rename from packages/browser-utils/src/metrics/browserMetrics.ts rename to packages/browser-utils/src/performance/entries.ts index 7b722c296547..0247d8aabb70 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/performance/entries.ts @@ -1,8 +1,7 @@ /* eslint-disable max-lines */ -import type { Client, Measurements, Span, SpanAttributes, StartSpanOptions } from '@sentry/core'; +import type { Span, SpanAttributes, StartSpanOptions } from '@sentry/core'; import { browserPerformanceTimeOrigin, - debug, getActiveSpan, getComponentName, parseUrl, @@ -10,24 +9,17 @@ import { setMeasurement, spanToJSON, } from '@sentry/core'; +import { SENTRY_OP, URL_FULL } from '@sentry/conventions/attributes'; +import { BROWSER_BROWSER_PAINT_SPAN_OP } from '@sentry/conventions/op'; import { htmlTreeAsString } from '../htmlTreeAsString'; -import { WINDOW } from '../types'; import { - addClsInstrumentationHandler, - addLcpInstrumentationHandler, addPerformanceInstrumentationHandler, - addTtfbInstrumentationHandler, type PerformanceLongAnimationFrameTiming, -} from './instrument'; -import { isValidLcpMetric } from './lcp'; +} from '../instrumentation/performanceObserver'; +import { WINDOW } from '../types'; import { resourceTimingToSpanAttributes } from './resourceTiming'; import { getBrowserPerformanceAPI, isMeasurementValue, msToSec, startAndEndSpan } from './utils'; -import { getActivationStart } from './web-vitals-helpers/getActivationStart'; -import { getNavigationEntry } from './web-vitals-helpers/getNavigationEntry'; -import { getVisibilityWatcher } from './web-vitals-helpers/getVisibilityWatcher'; -import { DEBUG_BUILD } from '../debug-build'; -import { SENTRY_OP, URL_FULL } from '@sentry/conventions/attributes'; -import { BROWSER_BROWSER_PAINT_SPAN_OP } from '@sentry/conventions/op'; + interface NavigatorNetworkInformation { readonly connection?: NetworkInformation; } @@ -70,41 +62,6 @@ const MAX_INT_AS_BYTES = 2147483647; let _performanceCursor: number = 0; -let _measurements: Measurements = {}; -let _lcpEntry: LargestContentfulPaint | undefined; -let _clsEntry: LayoutShift | undefined; - -interface StartTrackingWebVitalsOptions { - trackCls: boolean; - trackLcp: boolean; - client: Client; -} - -/** - * Start tracking web vitals. - * The callback returned by this function can be used to stop tracking & ensure all measurements are final & captured. - * - * @returns A function that forces web vitals collection - */ -export function startTrackingWebVitals({ trackCls, trackLcp }: StartTrackingWebVitalsOptions): () => void { - const performance = getBrowserPerformanceAPI(); - if (performance && browserPerformanceTimeOrigin()) { - const lcpCleanupCallback = trackLcp ? _trackLCP() : undefined; - const clsCleanupCallback = trackCls ? _trackCLS() : undefined; - const ttfbCleanupCallback = _trackTtfb(); - const fpFcpCleanupCallback = _trackFpFcp(); - - return (): void => { - ttfbCleanupCallback(); - fpFcpCleanupCallback(); - lcpCleanupCallback?.(); - clsCleanupCallback?.(); - }; - } - - return () => undefined; -} - /** * Start tracking long tasks. */ @@ -233,64 +190,6 @@ export function startTrackingInteractions(): void { }); } -export { registerInpInteractionListener } from './inp'; - -/** - * Starts tracking the Cumulative Layout Shift on the current page and collects the value and last entry - * to the `_measurements` object which ultimately is applied to the pageload span's measurements. - */ -function _trackCLS(): () => void { - return addClsInstrumentationHandler(({ metric }) => { - const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined; - if (!entry) { - return; - } - _measurements['cls'] = { value: metric.value, unit: '' }; - _clsEntry = entry; - }, true); -} - -/** Starts tracking the Largest Contentful Paint on the current page. */ -function _trackLCP(): () => void { - return addLcpInstrumentationHandler(({ metric }) => { - const entry = metric.entries[metric.entries.length - 1]; - if (!entry || !isValidLcpMetric(metric.value)) { - return; - } - - _measurements['lcp'] = { value: metric.value, unit: 'millisecond' }; - _lcpEntry = entry as LargestContentfulPaint; - }, true); -} - -function _trackTtfb(): () => void { - return addTtfbInstrumentationHandler(({ metric }) => { - const entry = metric.entries[metric.entries.length - 1]; - if (!entry) { - return; - } - - _measurements['ttfb'] = { value: metric.value, unit: 'millisecond' }; - }); -} - -/** 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. @@ -305,31 +204,6 @@ interface AddPerformanceEntriesOptions { 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. - * Sending it as a standalone span will yield more accurate LCP values. - * - * Default: `false` for backwards compatibility. - */ - recordClsOnPageloadSpan: boolean; - - /** - * Flag to determine if LCP should be recorded as a measurement on the pageload span or - * sent as a standalone span instead. - * Sending it as a standalone span will yield more accurate LCP values. - * - * Default: `false` for backwards compatibility. - */ - recordLcpOnPageloadSpan: boolean; - - /** - * Whether span streaming is enabled. - */ - spanStreamingEnabled?: boolean; -} - /** Add performance related spans to a transaction */ export function addPerformanceEntries(span: Span, options: AddPerformanceEntriesOptions): void { const performance = getBrowserPerformanceAPI(); @@ -391,87 +265,6 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries _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 (spanToJSON(span).op === 'pageload') { - _addTtfbRequestTimeToMeasurements(_measurements); - - if (spanStreamingEnabled) { - const setAttr = (shortWebVitalName: string, value: number, customAttrName?: string) => { - const attrKey = customAttrName ?? `browser.web_vital.${shortWebVitalName}.value`; - span.setAttribute(attrKey, value); - DEBUG_BUILD && debug.log('Setting web vital attribute', { [attrKey]: value }, 'on pageload span'); - }; - // for streamed pageload spans, we add the web vital measurements as attributes. - // We omit LCP, CLS and INP because they're tracked separately as spans - ['ttfb', 'fp', 'fcp'].forEach(measurementName => { - if (_measurements[measurementName]) { - setAttr(measurementName, _measurements[measurementName].value); - } - }); - if (_measurements['ttfb.requestTime']) { - setAttr('ttfb.requestTime', _measurements['ttfb.requestTime'].value, 'browser.web_vital.ttfb.request_time'); - } - } else { - // If CLS is tracked as a span (span streaming), don't record CLS as a measurement - if (!recordClsOnPageloadSpan) { - delete _measurements.cls; - } - - // If LCP is tracked as a span (span streaming), don't record LCP as a measurement - if (!recordLcpOnPageloadSpan) { - delete _measurements.lcp; - } - - Object.entries(_measurements).forEach(([measurementName, measurement]) => { - setMeasurement(measurementName, measurement.value, measurement.unit, span); - }); - - _setWebVitalAttributes(span, options); - } - - // Set timeOrigin which denotes the timestamp which to base the LCP/FCP/FP/TTFB measurements on - span.setAttribute(spanStreamingEnabled ? 'browser.performance.time_origin' : 'performance.timeOrigin', timeOrigin); - - // In prerendering scenarios, where a page might be prefetched and pre-rendered before the user clicks the link, - // the navigation starts earlier than when the user clicks it. Web Vitals should always be based on the - // user-perceived time, so they are not reported from the actual start of the navigation, but rather from the - // time where the user actively started the navigation, for example by clicking a link. - // This is user action is called "activation" and the time between navigation and activation is stored in - // the `activationStart` attribute of the "navigation" PerformanceEntry. - span.setAttribute( - spanStreamingEnabled ? 'browser.performance.navigation.activation_start' : 'performance.activationStart', - getActivationStart(), - ); - } - - resetWebVitalState(); -} - -function resetWebVitalState(): void { - _lcpEntry = undefined; - _clsEntry = undefined; - _measurements = {}; -} - /** Create a span for a browser paint performance entry. */ function _addPaintSpan( span: Span, @@ -708,48 +501,6 @@ function _trackNavigator(span: Span, spanStreamingEnabled: boolean | undefined): } } -/** Add LCP / CLS data to span to allow debugging */ -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. - - if (_lcpEntry.element) { - span.setAttribute('lcp.element', htmlTreeAsString(_lcpEntry.element)); - } - - if (_lcpEntry.id) { - span.setAttribute('lcp.id', _lcpEntry.id); - } - - if (_lcpEntry.url) { - // Trim URL to the first 200 characters. - span.setAttribute('lcp.url', _lcpEntry.url.trim().slice(0, 200)); - } - - if (_lcpEntry.loadTime != null) { - // loadTime is the time of LCP that's related to receiving the LCP element response.. - span.setAttribute('lcp.loadTime', _lcpEntry.loadTime); - } - - if (_lcpEntry.renderTime != null) { - // renderTime is loadTime + rendering time - // it's 0 if the LCP element is loaded from a 3rd party origin that doesn't send the - // `Timing-Allow-Origin` header. - span.setAttribute('lcp.renderTime', _lcpEntry.renderTime); - } - - span.setAttribute('lcp.size', _lcpEntry.size); - } - - // Only add CLS attributes if CLS is being recorded on the pageload span - if (_clsEntry?.sources && options.recordClsOnPageloadSpan) { - _clsEntry.sources.forEach((source, index) => - span.setAttribute(`cls.source.${index + 1}`, htmlTreeAsString(source.node)), - ); - } -} - type ExperimentalResourceTimingProperty = | 'renderBlockingStatus' | 'deliveryType' @@ -782,24 +533,3 @@ export function _setResourceRequestAttributes( } }); } - -/** - * Add ttfb request time information to measurements. - * - * ttfb information is added via vendored web vitals library. - */ -function _addTtfbRequestTimeToMeasurements(_measurements: Measurements): void { - const navEntry = getNavigationEntry(false); - if (!navEntry) { - return; - } - - const { responseStart, requestStart } = navEntry; - - if (requestStart <= responseStart) { - _measurements['ttfb.requestTime'] = { - value: responseStart - requestStart, - unit: 'millisecond', - }; - } -} diff --git a/packages/browser-utils/src/metrics/resourceTiming.ts b/packages/browser-utils/src/performance/resourceTiming.ts similarity index 100% rename from packages/browser-utils/src/metrics/resourceTiming.ts rename to packages/browser-utils/src/performance/resourceTiming.ts diff --git a/packages/browser-utils/src/metrics/userTiming.ts b/packages/browser-utils/src/performance/userTiming.ts similarity index 98% rename from packages/browser-utils/src/metrics/userTiming.ts rename to packages/browser-utils/src/performance/userTiming.ts index 153111381d8f..28c697cd60cc 100644 --- a/packages/browser-utils/src/metrics/userTiming.ts +++ b/packages/browser-utils/src/performance/userTiming.ts @@ -8,7 +8,7 @@ import { stringMatchesSomePattern, } from '@sentry/core'; import { getBrowserPerformanceAPI, msToSec, startAndEndSpan } from './utils'; -import { getNavigationEntry } from './web-vitals-helpers/getNavigationEntry'; +import { getNavigationEntry } from '../web-vitals/utils'; interface UserTimingOptions { /** diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/performance/utils.ts similarity index 57% rename from packages/browser-utils/src/metrics/utils.ts rename to packages/browser-utils/src/performance/utils.ts index e674a2ec798e..ad3d3e9370e1 100644 --- a/packages/browser-utils/src/metrics/utils.ts +++ b/packages/browser-utils/src/performance/utils.ts @@ -1,9 +1,6 @@ -import type { Client, SentrySpan, Span, SpanTimeInput, StartSpanOptions } from '@sentry/core'; +import type { SentrySpan, Span, SpanTimeInput, StartSpanOptions } from '@sentry/core'; import { spanToJSON, startInactiveSpan, withActiveSpan } from '@sentry/core'; import { WINDOW } from '../types'; -import { onHidden } from './web-vitals-helpers/onHidden'; - -export type WebVitalReportEvent = 'pagehide' | 'navigation'; /** * Checks if a given value is a valid measurement value. @@ -101,51 +98,3 @@ export function supportsWebVital(entryType: 'layout-shift' | 'largest-contentful return false; } } - -/** - * Listens for events on which we want to collect a previously accumulated web vital value. - * Currently, this includes: - * - * - pagehide (i.e. user minimizes browser window, hides tab, etc) - * - soft navigation (we only care about the vital of the initially loaded route) - * - * As a "side-effect", this function will also collect the span id of the pageload span. - * - * @param collectorCallback the callback to be called when the first of these events is triggered. Parameters: - * - event: the event that triggered the reporting of the web vital value. - * - pageloadSpanId: the span id of the pageload span. This is used to link the web vital span to the pageload span. - * - pageloadSpan: the pageload span instance. This is used for full access to the pageload span for span streaming. - */ -export function listenForWebVitalReportEvents( - client: Client, - collectorCallback: (event: WebVitalReportEvent, pageloadSpanId: string, pageloadSpan?: Span) => void, -) { - let pageloadSpan: Span | undefined; - - let collected = false; - function _runCollectorCallbackOnce(event: WebVitalReportEvent) { - if (!collected && pageloadSpan) { - collectorCallback(event, pageloadSpan.spanContext().spanId, pageloadSpan); - } - collected = true; - } - - // eslint-disable-next-line typescript/no-deprecated - onHidden(() => { - _runCollectorCallbackOnce('pagehide'); - }); - - const unsubscribeStartNavigation = client.on('beforeStartNavigationSpan', (_, options) => { - // we only want to collect LCP if we actually navigate. Redirects should be ignored. - if (!options?.isRedirect) { - _runCollectorCallbackOnce('navigation'); - unsubscribeStartNavigation(); - unsubscribeAfterStartPageLoadSpan(); - } - }); - - const unsubscribeAfterStartPageLoadSpan = client.on('afterStartPageLoadSpan', span => { - pageloadSpan = span; - unsubscribeAfterStartPageLoadSpan(); - }); -} diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/web-vitals/inp.ts similarity index 98% rename from packages/browser-utils/src/metrics/inp.ts rename to packages/browser-utils/src/web-vitals/inp.ts index d1d62cfc5177..95982eb5e1af 100644 --- a/packages/browser-utils/src/metrics/inp.ts +++ b/packages/browser-utils/src/web-vitals/inp.ts @@ -2,7 +2,7 @@ import type { Span } from '@sentry/core'; import { getActiveSpan, getRootSpan, isBrowser } from '@sentry/core'; import { htmlTreeAsString } from '../htmlTreeAsString'; import { WINDOW } from '../types'; -import { addPerformanceInstrumentationHandler, isPerformanceEventTiming } from './instrument'; +import { addPerformanceInstrumentationHandler, isPerformanceEventTiming } from '../instrumentation/performanceObserver'; interface InteractionContext { span: Span | undefined; diff --git a/packages/browser-utils/src/metrics/lcp.ts b/packages/browser-utils/src/web-vitals/lcp.ts similarity index 100% rename from packages/browser-utils/src/metrics/lcp.ts rename to packages/browser-utils/src/web-vitals/lcp.ts diff --git a/packages/browser-utils/src/web-vitals/reportEvents.ts b/packages/browser-utils/src/web-vitals/reportEvents.ts new file mode 100644 index 000000000000..00ce84bf9a8d --- /dev/null +++ b/packages/browser-utils/src/web-vitals/reportEvents.ts @@ -0,0 +1,51 @@ +import type { Client, Span } from '@sentry/core'; +import { onHidden } from './utils'; + +export type WebVitalReportEvent = 'pagehide' | 'navigation'; + +/** + * Listens for events on which we want to collect a previously accumulated web vital value. + * Currently, this includes: + * + * - pagehide (i.e. user minimizes browser window, hides tab, etc) + * - soft navigation (we only care about the vital of the initially loaded route) + * + * As a "side-effect", this function will also collect the span id of the pageload span. + * + * @param collectorCallback the callback to be called when the first of these events is triggered. Parameters: + * - event: the event that triggered the reporting of the web vital value. + * - pageloadSpanId: the span id of the pageload span. This is used to link the web vital span to the pageload span. + * - pageloadSpan: the pageload span instance. This is used for full access to the pageload span for span streaming. + */ +export function listenForWebVitalReportEvents( + client: Client, + collectorCallback: (event: WebVitalReportEvent, pageloadSpanId: string, pageloadSpan?: Span) => void, +) { + let pageloadSpan: Span | undefined; + + let collected = false; + function _runCollectorCallbackOnce(event: WebVitalReportEvent) { + if (!collected && pageloadSpan) { + collectorCallback(event, pageloadSpan.spanContext().spanId, pageloadSpan); + } + collected = true; + } + + onHidden(() => { + _runCollectorCallbackOnce('pagehide'); + }); + + const unsubscribeStartNavigation = client.on('beforeStartNavigationSpan', (_, options) => { + // we only want to collect LCP if we actually navigate. Redirects should be ignored. + if (!options?.isRedirect) { + _runCollectorCallbackOnce('navigation'); + unsubscribeStartNavigation(); + unsubscribeAfterStartPageLoadSpan(); + } + }); + + const unsubscribeAfterStartPageLoadSpan = client.on('afterStartPageLoadSpan', span => { + pageloadSpan = span; + unsubscribeAfterStartPageLoadSpan(); + }); +} diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/web-vitals/spans.ts similarity index 95% rename from packages/browser-utils/src/metrics/webVitalSpans.ts rename to packages/browser-utils/src/web-vitals/spans.ts index 9c4f82249874..e6963142cbb4 100644 --- a/packages/browser-utils/src/metrics/webVitalSpans.ts +++ b/packages/browser-utils/src/web-vitals/spans.ts @@ -18,12 +18,17 @@ import { DEBUG_BUILD } from '../debug-build'; import { htmlTreeAsString } from '../htmlTreeAsString'; import { WINDOW } from '../types'; import { getCachedInteractionContext, INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION } from './inp'; -import type { InstrumentationHandlerCallback } from './instrument'; -import { addClsInstrumentationHandler, addInpInstrumentationHandler, addLcpInstrumentationHandler } from './instrument'; +import type { InstrumentationHandlerCallback } from '../instrumentation/performanceObserver'; +import { + addClsInstrumentationHandler, + addInpInstrumentationHandler, + addLcpInstrumentationHandler, +} from '../instrumentation/performanceObserver'; import { isValidLcpMetric } from './lcp'; -import type { WebVitalReportEvent } from './utils'; -import { getBrowserPerformanceAPI, listenForWebVitalReportEvents, msToSec, supportsWebVital } from './utils'; -import type { PerformanceEventTiming } from './instrument'; +import type { WebVitalReportEvent } from './reportEvents'; +import { listenForWebVitalReportEvents } from './reportEvents'; +import { getBrowserPerformanceAPI, msToSec, supportsWebVital } from '../performance/utils'; +import type { PerformanceEventTiming } from '../instrumentation/performanceObserver'; import { SENTRY_SEGMENT_NAME, SENTRY_TRANSACTION } from '@sentry/conventions/attributes'; // Locally-defined interfaces to avoid leaking bare global type references into the diff --git a/packages/browser-utils/src/web-vitals/tracking.ts b/packages/browser-utils/src/web-vitals/tracking.ts new file mode 100644 index 000000000000..824953e9d4c4 --- /dev/null +++ b/packages/browser-utils/src/web-vitals/tracking.ts @@ -0,0 +1,275 @@ +import type { Client, Measurements, Span } from '@sentry/core'; +import { browserPerformanceTimeOrigin, debug, setMeasurement, spanToJSON } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import { htmlTreeAsString } from '../htmlTreeAsString'; +import { + addClsInstrumentationHandler, + addLcpInstrumentationHandler, + addPerformanceInstrumentationHandler, + addTtfbInstrumentationHandler, +} from '../instrumentation/performanceObserver'; +import { getBrowserPerformanceAPI, msToSec } from '../performance/utils'; +import { isValidLcpMetric } from './lcp'; +import { getActivationStart, getNavigationEntry, getVisibilityWatcher } from './utils'; + +let _measurements: Measurements = {}; +let _lcpEntry: LargestContentfulPaint | undefined; +let _clsEntry: LayoutShift | undefined; + +interface StartTrackingWebVitalsOptions { + trackCls: boolean; + trackLcp: boolean; + client: Client; +} + +/** + * Start tracking web vitals. + * The callback returned by this function can be used to stop tracking & ensure all measurements are final & captured. + * + * @returns A function that forces web vitals collection + */ +export function startTrackingWebVitals({ trackCls, trackLcp }: StartTrackingWebVitalsOptions): () => void { + const performance = getBrowserPerformanceAPI(); + if (performance && browserPerformanceTimeOrigin()) { + const lcpCleanupCallback = trackLcp ? _trackLCP() : undefined; + const clsCleanupCallback = trackCls ? _trackCLS() : undefined; + const ttfbCleanupCallback = _trackTtfb(); + const fpFcpCleanupCallback = _trackFpFcp(); + + return (): void => { + ttfbCleanupCallback(); + fpFcpCleanupCallback(); + lcpCleanupCallback?.(); + clsCleanupCallback?.(); + }; + } + + return () => undefined; +} + +export { registerInpInteractionListener } from './inp'; + +/** + * Starts tracking the Cumulative Layout Shift on the current page and collects the value and last entry + * to the `_measurements` object which ultimately is applied to the pageload span's measurements. + */ +function _trackCLS(): () => void { + return addClsInstrumentationHandler(({ metric }) => { + const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined; + if (!entry) { + return; + } + _measurements['cls'] = { value: metric.value, unit: '' }; + _clsEntry = entry; + }, true); +} + +/** Starts tracking the Largest Contentful Paint on the current page. */ +function _trackLCP(): () => void { + return addLcpInstrumentationHandler(({ metric }) => { + const entry = metric.entries[metric.entries.length - 1]; + if (!entry || !isValidLcpMetric(metric.value)) { + return; + } + + _measurements['lcp'] = { value: metric.value, unit: 'millisecond' }; + _lcpEntry = entry as LargestContentfulPaint; + }, true); +} + +function _trackTtfb(): () => void { + return addTtfbInstrumentationHandler(({ metric }) => { + const entry = metric.entries[metric.entries.length - 1]; + if (!entry) { + return; + } + + _measurements['ttfb'] = { value: metric.value, unit: 'millisecond' }; + }); +} + +/** 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 AddWebVitalsToSpanOptions { + /** + * Flag to determine if CLS should be recorded as a measurement on the pageload span or + * sent as a standalone span instead. + * Sending it as a standalone span will yield more accurate LCP values. + * + * Default: `false` for backwards compatibility. + */ + recordClsOnPageloadSpan: boolean; + + /** + * Flag to determine if LCP should be recorded as a measurement on the pageload span or + * sent as a standalone span instead. + * Sending it as a standalone span will yield more accurate LCP values. + * + * Default: `false` for backwards compatibility. + */ + recordLcpOnPageloadSpan: boolean; + + /** + * Whether span streaming is enabled. + */ + spanStreamingEnabled?: boolean; +} + +/** + * 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 (spanToJSON(span).op === 'pageload') { + _addTtfbRequestTimeToMeasurements(_measurements); + + if (spanStreamingEnabled) { + const setAttr = (shortWebVitalName: string, value: number, customAttrName?: string) => { + const attrKey = customAttrName ?? `browser.web_vital.${shortWebVitalName}.value`; + span.setAttribute(attrKey, value); + DEBUG_BUILD && debug.log('Setting web vital attribute', { [attrKey]: value }, 'on pageload span'); + }; + // for streamed pageload spans, we add the web vital measurements as attributes. + // We omit LCP, CLS and INP because they're tracked separately as spans + ['ttfb', 'fp', 'fcp'].forEach(measurementName => { + if (_measurements[measurementName]) { + setAttr(measurementName, _measurements[measurementName].value); + } + }); + if (_measurements['ttfb.requestTime']) { + setAttr('ttfb.requestTime', _measurements['ttfb.requestTime'].value, 'browser.web_vital.ttfb.request_time'); + } + } else { + // If CLS is tracked as a span (span streaming), don't record CLS as a measurement + if (!recordClsOnPageloadSpan) { + delete _measurements.cls; + } + + // If LCP is tracked as a span (span streaming), don't record LCP as a measurement + if (!recordLcpOnPageloadSpan) { + delete _measurements.lcp; + } + + Object.entries(_measurements).forEach(([measurementName, measurement]) => { + setMeasurement(measurementName, measurement.value, measurement.unit, span); + }); + + _setWebVitalAttributes(span, options); + } + + // Set timeOrigin which denotes the timestamp which to base the LCP/FCP/FP/TTFB measurements on + span.setAttribute(spanStreamingEnabled ? 'browser.performance.time_origin' : 'performance.timeOrigin', timeOrigin); + + // In prerendering scenarios, where a page might be prefetched and pre-rendered before the user clicks the link, + // the navigation starts earlier than when the user clicks it. Web Vitals should always be based on the + // user-perceived time, so they are not reported from the actual start of the navigation, but rather from the + // time where the user actively started the navigation, for example by clicking a link. + // This is user action is called "activation" and the time between navigation and activation is stored in + // the `activationStart` attribute of the "navigation" PerformanceEntry. + span.setAttribute( + spanStreamingEnabled ? 'browser.performance.navigation.activation_start' : 'performance.activationStart', + getActivationStart(), + ); + } + + resetWebVitalState(); +} + +function resetWebVitalState(): void { + _lcpEntry = undefined; + _clsEntry = undefined; + _measurements = {}; +} + +/** Add LCP / CLS data to span to allow debugging */ +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. + + if (_lcpEntry.element) { + span.setAttribute('lcp.element', htmlTreeAsString(_lcpEntry.element)); + } + + if (_lcpEntry.id) { + span.setAttribute('lcp.id', _lcpEntry.id); + } + + if (_lcpEntry.url) { + // Trim URL to the first 200 characters. + span.setAttribute('lcp.url', _lcpEntry.url.trim().slice(0, 200)); + } + + if (_lcpEntry.loadTime != null) { + // loadTime is the time of LCP that's related to receiving the LCP element response.. + span.setAttribute('lcp.loadTime', _lcpEntry.loadTime); + } + + if (_lcpEntry.renderTime != null) { + // renderTime is loadTime + rendering time + // it's 0 if the LCP element is loaded from a 3rd party origin that doesn't send the + // `Timing-Allow-Origin` header. + span.setAttribute('lcp.renderTime', _lcpEntry.renderTime); + } + + span.setAttribute('lcp.size', _lcpEntry.size); + } + + // Only add CLS attributes if CLS is being recorded on the pageload span + if (_clsEntry?.sources && options.recordClsOnPageloadSpan) { + _clsEntry.sources.forEach((source, index) => + span.setAttribute(`cls.source.${index + 1}`, htmlTreeAsString(source.node)), + ); + } +} + +/** + * Add ttfb request time information to measurements. + * + * ttfb information is added via the web vitals library. + */ +function _addTtfbRequestTimeToMeasurements(_measurements: Measurements): void { + const navEntry = getNavigationEntry(false); + if (!navEntry) { + return; + } + + const { responseStart, requestStart } = navEntry; + + if (requestStart <= responseStart) { + _measurements['ttfb.requestTime'] = { + value: responseStart - requestStart, + unit: 'millisecond', + }; + } +} diff --git a/packages/browser-utils/src/metrics/web-vitals-helpers/getVisibilityWatcher.ts b/packages/browser-utils/src/web-vitals/utils.ts similarity index 54% rename from packages/browser-utils/src/metrics/web-vitals-helpers/getVisibilityWatcher.ts rename to packages/browser-utils/src/web-vitals/utils.ts index f94a3c7314d1..dff12f342600 100644 --- a/packages/browser-utils/src/metrics/web-vitals-helpers/getVisibilityWatcher.ts +++ b/packages/browser-utils/src/web-vitals/utils.ts @@ -1,5 +1,7 @@ /* - * Copyright 2020 Google LLC + * Portions of this file are derived from Google's web-vitals library. + * + * Copyright 2020-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,9 +16,67 @@ * limitations under the License. */ -import { WINDOW } from '../../types'; -import { getActivationStart } from './getActivationStart'; -import { addPageListener, removePageListener } from './globalListeners'; +import { WINDOW } from '../types'; + +/** + * web-vitals 5.1.0 switched listeners to be added on the window rather than the document. + * Instead of having to check for window/document every time we add a listener, we can use this function. + */ +export function addPageListener(type: string, listener: EventListener, options?: boolean | AddEventListenerOptions) { + if (WINDOW.document) { + WINDOW.addEventListener(type, listener, options); + } +} + +/** + * web-vitals 5.1.0 switched listeners to be removed from the window rather than the document. + * Instead of having to check for window/document every time we remove a listener, we can use this function. + */ +export function removePageListener(type: string, listener: EventListener, options?: boolean | AddEventListenerOptions) { + if (WINDOW.document) { + WINDOW.removeEventListener(type, listener, options); + } +} + +// sentry-specific change: +// add optional param to not check for responseStart (see comment below) +export const getNavigationEntry = (checkResponseStart = true): PerformanceNavigationTiming | void => { + const navigationEntry = WINDOW.performance?.getEntriesByType?.('navigation')[0]; + // Check to ensure the `responseStart` property is present and valid. + // In some cases a zero value is reported by the browser (for + // privacy/security reasons), and in other cases (bugs) the value is + // negative or is larger than the current page time. Ignore these cases: + // - https://github.com/GoogleChrome/web-vitals/issues/137 + // - https://github.com/GoogleChrome/web-vitals/issues/162 + // - https://github.com/GoogleChrome/web-vitals/issues/275 + if ( + // sentry-specific change: + // We don't want to check for responseStart for our own use of `getNavigationEntry` + !checkResponseStart || + (navigationEntry && navigationEntry.responseStart > 0 && navigationEntry.responseStart < performance.now()) + ) { + return navigationEntry; + } +}; + +export const getActivationStart = (): number => { + const navEntry = getNavigationEntry(); + return navEntry?.activationStart ?? 0; +}; + +export interface OnHiddenCallback { + (event: Event): void; +} + +export const onHidden = (cb: OnHiddenCallback) => { + const onHiddenCallback = (event: Event) => { + if (WINDOW.document?.visibilityState === 'hidden') { + cb(event); + } + }; + + addPageListener('visibilitychange', onHiddenCallback, { capture: true }); +}; let firstHiddenTime = -1; const onHiddenFunctions: Set<() => void> = new Set(); @@ -96,3 +156,33 @@ export const getVisibilityWatcher = () => { }, }; }; + +/** + * Runs the passed callback during the next idle period, or immediately + * if the browser's visibility state is (or becomes) hidden. + */ +export const whenIdleOrHidden = (cb: () => void) => { + const rIC = WINDOW.requestIdleCallback || WINDOW.setTimeout; + + // If the document is hidden, run the callback immediately, otherwise + // race an idle callback with the next `visibilitychange` event. + if (WINDOW.document?.visibilityState === 'hidden') { + cb(); + } else { + // Ensure the callback only runs once, whichever of the two racers wins. + let called = false; + const runOnce = () => { + if (!called) { + cb(); + called = true; + } + }; + addPageListener('visibilitychange', runOnce, { once: true, capture: true }); + rIC(() => { + runOnce(); + // Remove the above event listener since no longer required. + // See: https://github.com/GoogleChrome/web-vitals/issues/622 + removePageListener('visibilitychange', runOnce, { capture: true }); + }); + } +}; diff --git a/packages/browser-utils/test/browser/utils.test.ts b/packages/browser-utils/test/browser/utils.test.ts index c620f19c8ab9..ec0457512cf7 100644 --- a/packages/browser-utils/test/browser/utils.test.ts +++ b/packages/browser-utils/test/browser/utils.test.ts @@ -1,6 +1,6 @@ import { getCurrentScope, getIsolationScope, SentrySpan, setCurrentClient, spanToJSON } from '@sentry/core'; import { beforeEach, describe, expect, it, test } from 'vitest'; -import { extractNetworkProtocol, startAndEndSpan } from '../../src/metrics/utils'; +import { extractNetworkProtocol, startAndEndSpan } from '../../src/performance/utils'; import { getDefaultClientOptions, TestClient } from '../utils/TestClient'; describe('startAndEndSpan()', () => { diff --git a/packages/browser-utils/test/instrument/dom.test.ts b/packages/browser-utils/test/instrumentation/dom.test.ts similarity index 83% rename from packages/browser-utils/test/instrument/dom.test.ts rename to packages/browser-utils/test/instrumentation/dom.test.ts index ed98828db9a6..23681014150b 100644 --- a/packages/browser-utils/test/instrument/dom.test.ts +++ b/packages/browser-utils/test/instrumentation/dom.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { instrumentDOM } from '../../src/instrument/dom'; +import { instrumentDOM } from '../../src/instrumentation/dom'; import { WINDOW } from '../../src/types'; // @ts-expect-error - idk diff --git a/packages/browser-utils/test/instrument/history.test.ts b/packages/browser-utils/test/instrumentation/history.test.ts similarity index 98% rename from packages/browser-utils/test/instrument/history.test.ts rename to packages/browser-utils/test/instrumentation/history.test.ts index 03054194472f..b26e7826eeb2 100644 --- a/packages/browser-utils/test/instrument/history.test.ts +++ b/packages/browser-utils/test/instrumentation/history.test.ts @@ -1,7 +1,7 @@ import * as instrumentHandlersModule from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { WINDOW } from '../../src/types'; -import { instrumentHistory } from './../../src/instrument/history'; +import { instrumentHistory } from './../../src/instrumentation/history'; describe('instrumentHistory', () => { const originalHistory = WINDOW.history; diff --git a/packages/browser-utils/test/instrument/location.test.ts b/packages/browser-utils/test/instrumentation/location.test.ts similarity index 91% rename from packages/browser-utils/test/instrument/location.test.ts rename to packages/browser-utils/test/instrumentation/location.test.ts index 32f834f65dac..c67820d33fff 100644 --- a/packages/browser-utils/test/instrument/location.test.ts +++ b/packages/browser-utils/test/instrumentation/location.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { getAbsoluteUrl } from '../../src/instrument/location'; +import { getAbsoluteUrl } from '../../src/instrumentation/location'; describe('getAbsoluteUrl', () => { beforeEach(() => { diff --git a/packages/browser-utils/test/instrument/xhr.test.ts b/packages/browser-utils/test/instrumentation/xhr.test.ts similarity index 99% rename from packages/browser-utils/test/instrument/xhr.test.ts rename to packages/browser-utils/test/instrumentation/xhr.test.ts index e486e211f244..81734d3abf89 100644 --- a/packages/browser-utils/test/instrument/xhr.test.ts +++ b/packages/browser-utils/test/instrumentation/xhr.test.ts @@ -1,6 +1,6 @@ import type { HandlerDataXhr } from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { addXhrInstrumentationHandler, instrumentXHR } from '../../src/instrument/xhr'; +import { addXhrInstrumentationHandler, instrumentXHR } from '../../src/instrumentation/xhr'; import { WINDOW } from '../../src/types'; const win = WINDOW as typeof WINDOW & { XMLHttpRequest?: typeof XMLHttpRequest }; diff --git a/packages/browser-utils/test/browser/browserMetrics.test.ts b/packages/browser-utils/test/performance/browserMetrics.test.ts similarity index 99% rename from packages/browser-utils/test/browser/browserMetrics.test.ts rename to packages/browser-utils/test/performance/browserMetrics.test.ts index fd1ae31d178a..5d526667d498 100644 --- a/packages/browser-utils/test/browser/browserMetrics.test.ts +++ b/packages/browser-utils/test/performance/browserMetrics.test.ts @@ -10,13 +10,8 @@ import { spanToJSON, } from '@sentry/core'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - _addNavigationSpans, - _addResourceSpans, - _setResourceRequestAttributes, - addWebVitalsToSpan, - startTrackingWebVitals, -} from '../../src/metrics/browserMetrics'; +import { _addNavigationSpans, _addResourceSpans, _setResourceRequestAttributes } from '../../src/performance/entries'; +import { addWebVitalsToSpan, startTrackingWebVitals } from '../../src/web-vitals/tracking'; import { WINDOW } from '../../src/types'; import { getDefaultClientOptions, TestClient } from '../utils/TestClient'; diff --git a/packages/browser-utils/test/metrics/elementTiming.test.ts b/packages/browser-utils/test/performance/elementTiming.test.ts similarity index 95% rename from packages/browser-utils/test/metrics/elementTiming.test.ts rename to packages/browser-utils/test/performance/elementTiming.test.ts index c58a4faf6d45..26ca3c8ee178 100644 --- a/packages/browser-utils/test/metrics/elementTiming.test.ts +++ b/packages/browser-utils/test/performance/elementTiming.test.ts @@ -1,8 +1,8 @@ import * as sentryCore from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { elementTimingIntegration, startTrackingElementTiming } from '../../src/metrics/elementTiming'; -import * as browserMetricsInstrumentation from '../../src/metrics/instrument'; -import * as browserMetricsUtils from '../../src/metrics/utils'; +import { elementTimingIntegration, startTrackingElementTiming } from '../../src/performance/elementTiming'; +import * as browserMetricsInstrumentation from '../../src/instrumentation/performanceObserver'; +import * as browserMetricsUtils from '../../src/performance/utils'; describe('elementTimingIntegration', () => { const distributionSpy = vi.spyOn(sentryCore.metrics, 'distribution'); diff --git a/packages/browser-utils/test/metrics/resourceTiming.test.ts b/packages/browser-utils/test/performance/resourceTiming.test.ts similarity index 99% rename from packages/browser-utils/test/metrics/resourceTiming.test.ts rename to packages/browser-utils/test/performance/resourceTiming.test.ts index 5e35097423d1..c6749c6455aa 100644 --- a/packages/browser-utils/test/metrics/resourceTiming.test.ts +++ b/packages/browser-utils/test/performance/resourceTiming.test.ts @@ -1,8 +1,8 @@ import * as utils from '@sentry/core'; import type { MockInstance } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { resourceTimingToSpanAttributes } from '../../src/metrics/resourceTiming'; -import * as browserMetricsUtils from '../../src/metrics/utils'; +import { resourceTimingToSpanAttributes } from '../../src/performance/resourceTiming'; +import * as browserMetricsUtils from '../../src/performance/utils'; describe('resourceTimingToSpanAttributes', () => { let browserPerformanceTimeOriginSpy: MockInstance; diff --git a/packages/browser-utils/test/metrics/userTiming.test.ts b/packages/browser-utils/test/performance/userTiming.test.ts similarity index 98% rename from packages/browser-utils/test/metrics/userTiming.test.ts rename to packages/browser-utils/test/performance/userTiming.test.ts index 73e007f286ee..0e9a8d64eec4 100644 --- a/packages/browser-utils/test/metrics/userTiming.test.ts +++ b/packages/browser-utils/test/performance/userTiming.test.ts @@ -1,8 +1,8 @@ import type { Span } from '@sentry/core'; import { getCurrentScope, getIsolationScope, SentrySpan, setCurrentClient, spanToJSON } from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { _addUserTimingSpan, userTimingIntegration } from '../../src/metrics/userTiming'; -import * as utils from '../../src/metrics/utils'; +import { _addUserTimingSpan, userTimingIntegration } from '../../src/performance/userTiming'; +import * as utils from '../../src/performance/utils'; import { getDefaultClientOptions, TestClient } from '../utils/TestClient'; describe('userTimingIntegration', () => { diff --git a/packages/browser-utils/test/metrics/lcp.test.ts b/packages/browser-utils/test/web-vitals/lcp.test.ts similarity index 96% rename from packages/browser-utils/test/metrics/lcp.test.ts rename to packages/browser-utils/test/web-vitals/lcp.test.ts index 9315f7032b59..c53cd68662b5 100644 --- a/packages/browser-utils/test/metrics/lcp.test.ts +++ b/packages/browser-utils/test/web-vitals/lcp.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isValidLcpMetric, MAX_PLAUSIBLE_LCP_DURATION } from '../../src/metrics/lcp'; +import { isValidLcpMetric, MAX_PLAUSIBLE_LCP_DURATION } from '../../src/web-vitals/lcp'; describe('isValidLcpMetric', () => { it('returns true for plausible lcp values', () => { diff --git a/packages/browser-utils/test/metrics/webVitalSpans.test.ts b/packages/browser-utils/test/web-vitals/spans.test.ts similarity index 98% rename from packages/browser-utils/test/metrics/webVitalSpans.test.ts rename to packages/browser-utils/test/web-vitals/spans.test.ts index ef938d7017f0..ffb6e08d5b71 100644 --- a/packages/browser-utils/test/metrics/webVitalSpans.test.ts +++ b/packages/browser-utils/test/web-vitals/spans.test.ts @@ -1,16 +1,16 @@ import * as SentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { htmlTreeAsString } from '../../src/htmlTreeAsString'; -import * as inpModule from '../../src/metrics/inp'; -import * as instrument from '../../src/metrics/instrument'; -import { MAX_PLAUSIBLE_LCP_DURATION } from '../../src/metrics/lcp'; +import * as inpModule from '../../src/web-vitals/inp'; +import * as instrument from '../../src/instrumentation/performanceObserver'; +import { MAX_PLAUSIBLE_LCP_DURATION } from '../../src/web-vitals/lcp'; import { _emitWebVitalSpan, _sendClsSpan, _sendInpSpan, _sendLcpSpan, trackInpAsSpan, -} from '../../src/metrics/webVitalSpans'; +} from '../../src/web-vitals/spans'; vi.mock('@sentry/core', async () => { const actual = await vi.importActual('@sentry/core');