diff --git a/packages/tracing/src/browser/browsertracing.ts b/packages/tracing/src/browser/browsertracing.ts index 780edf9052d4..f18785783472 100644 --- a/packages/tracing/src/browser/browsertracing.ts +++ b/packages/tracing/src/browser/browsertracing.ts @@ -7,7 +7,7 @@ import { startIdleTransaction } from '../hubextensions'; import { DEFAULT_FINAL_TIMEOUT, DEFAULT_IDLE_TIMEOUT } from '../idletransaction'; import { extractTraceparentData } from '../utils'; import { registerBackgroundTabDetection } from './backgroundtab'; -import { MetricsInstrumentation } from './metrics'; +import { addPerformanceEntries, startTrackingWebVitals } from './metrics'; import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests, @@ -126,8 +126,6 @@ export class BrowserTracing implements Integration { private _getCurrentHub?: () => Hub; - private readonly _metrics: MetricsInstrumentation; - private readonly _emitOptionsWarning?: boolean; public constructor(_options?: Partial) { @@ -148,7 +146,7 @@ export class BrowserTracing implements Integration { }; const { _metricOptions } = this.options; - this._metrics = new MetricsInstrumentation(_metricOptions && _metricOptions._reportAllChanges); + startTrackingWebVitals(_metricOptions && _metricOptions._reportAllChanges); } /** @@ -235,7 +233,11 @@ export class BrowserTracing implements Integration { { location }, // for use in the tracesSampler ); idleTransaction.registerBeforeFinishCallback(transaction => { - this._metrics.addPerformanceEntries(transaction); + addPerformanceEntries(transaction); + transaction.setTag( + 'sentry_reportAllChanges', + Boolean(this.options._metricOptions && this.options._metricOptions._reportAllChanges), + ); }); return idleTransaction as Transaction; diff --git a/packages/tracing/src/browser/metrics.ts b/packages/tracing/src/browser/metrics.ts deleted file mode 100644 index 97d28806096d..000000000000 --- a/packages/tracing/src/browser/metrics.ts +++ /dev/null @@ -1,446 +0,0 @@ -/* eslint-disable max-lines */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { Measurements, SpanContext } from '@sentry/types'; -import { browserPerformanceTimeOrigin, getGlobalObject, htmlTreeAsString, isNodeEnv, logger } from '@sentry/utils'; - -import { IS_DEBUG_BUILD } from '../flags'; -import { Span } from '../span'; -import { Transaction } from '../transaction'; -import { msToSec } from '../utils'; -import { getCLS, LayoutShift } from './web-vitals/getCLS'; -import { getFID } from './web-vitals/getFID'; -import { getLCP, LargestContentfulPaint } from './web-vitals/getLCP'; -import { getVisibilityWatcher } from './web-vitals/lib/getVisibilityWatcher'; -import { NavigatorDeviceMemory, NavigatorNetworkInformation } from './web-vitals/types'; - -const global = getGlobalObject(); - -/** Class tracking metrics */ -export class MetricsInstrumentation { - private _measurements: Measurements = {}; - - private _performanceCursor: number = 0; - private _lcpEntry: LargestContentfulPaint | undefined; - private _clsEntry: LayoutShift | undefined; - - public constructor(private _reportAllChanges: boolean = false) { - if (!isNodeEnv() && global && global.performance && global.document) { - if (global.performance.mark) { - global.performance.mark('sentry-tracing-init'); - } - - this._trackCLS(); - this._trackLCP(); - this._trackFID(); - } - } - - /** Add performance related spans to a transaction */ - public addPerformanceEntries(transaction: Transaction): void { - if (!global || !global.performance || !global.performance.getEntries || !browserPerformanceTimeOrigin) { - // Gatekeeper if performance API not available - return; - } - - IS_DEBUG_BUILD && logger.log('[Tracing] Adding & adjusting spans using Performance API'); - - const timeOrigin = msToSec(browserPerformanceTimeOrigin); - - let responseStartTimestamp: number | undefined; - let requestStartTimestamp: number | undefined; - - global.performance - .getEntries() - .slice(this._performanceCursor) - .forEach((entry: Record) => { - const startTime = msToSec(entry.startTime as number); - const duration = msToSec(entry.duration as number); - - if (transaction.op === 'navigation' && timeOrigin + startTime < transaction.startTimestamp) { - return; - } - - switch (entry.entryType) { - case 'navigation': { - addNavigationSpans(transaction, entry, timeOrigin); - responseStartTimestamp = timeOrigin + msToSec(entry.responseStart as number); - requestStartTimestamp = timeOrigin + msToSec(entry.requestStart as number); - break; - } - case 'mark': - case 'paint': - case 'measure': { - const startTimestamp = addMeasureSpans(transaction, entry, startTime, duration, timeOrigin); - // 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) { - IS_DEBUG_BUILD && logger.log('[Measurements] Adding FP'); - this._measurements['fp'] = { value: entry.startTime, unit: 'millisecond' }; - this._measurements['mark.fp'] = { value: startTimestamp, unit: 'second' }; - } - - if (entry.name === 'first-contentful-paint' && shouldRecord) { - IS_DEBUG_BUILD && logger.log('[Measurements] Adding FCP'); - this._measurements['fcp'] = { value: entry.startTime, unit: 'millisecond' }; - this._measurements['mark.fcp'] = { value: startTimestamp, unit: 'second' }; - } - - break; - } - case 'resource': { - const resourceName = (entry.name as string).replace(global.location.origin, ''); - addResourceSpans(transaction, entry, resourceName, startTime, duration, timeOrigin); - break; - } - default: - // Ignore other entry types. - } - }); - - this._performanceCursor = Math.max(performance.getEntries().length - 1, 0); - - this._trackNavigator(transaction); - - // Measurements are only available for pageload transactions - if (transaction.op === 'pageload') { - // normalize applicable web vital values to be relative to transaction.startTimestamp - - const timeOrigin = msToSec(browserPerformanceTimeOrigin); - - // Generate TTFB (Time to First Byte), which measured as the time between the beginning of the transaction and the - // start of the response in milliseconds - if (typeof responseStartTimestamp === 'number') { - IS_DEBUG_BUILD && logger.log('[Measurements] Adding TTFB'); - this._measurements['ttfb'] = { - value: (responseStartTimestamp - transaction.startTimestamp) * 1000, - unit: 'millisecond', - }; - - if (typeof requestStartTimestamp === 'number' && requestStartTimestamp <= responseStartTimestamp) { - // Capture the time spent making the request and receiving the first byte of the response. - // This is the time between the start of the request and the start of the response in milliseconds. - this._measurements['ttfb.requestTime'] = { - value: (responseStartTimestamp - requestStartTimestamp) * 1000, - unit: 'second', - }; - } - } - - ['fcp', 'fp', 'lcp'].forEach(name => { - if (!this._measurements[name] || timeOrigin >= transaction.startTimestamp) { - return; - } - - // The web vitals, fcp, fp, lcp, and ttfb, all measure relative to timeOrigin. - // Unfortunately, timeOrigin is not captured within the transaction span data, so these web vitals will need - // to be adjusted to be relative to transaction.startTimestamp. - - const oldValue = this._measurements[name].value; - const measurementTimestamp = timeOrigin + msToSec(oldValue); - // normalizedValue should be in milliseconds - const normalizedValue = Math.abs((measurementTimestamp - transaction.startTimestamp) * 1000); - - const delta = normalizedValue - oldValue; - IS_DEBUG_BUILD && - logger.log(`[Measurements] Normalized ${name} from ${oldValue} to ${normalizedValue} (${delta})`); - - this._measurements[name].value = normalizedValue; - }); - - if (this._measurements['mark.fid'] && this._measurements['fid']) { - // create span for FID - - _startChild(transaction, { - description: 'first input delay', - endTimestamp: this._measurements['mark.fid'].value + msToSec(this._measurements['fid'].value), - op: 'web.vitals', - startTimestamp: this._measurements['mark.fid'].value, - }); - } - - // If FCP is not recorded we should not record the cls value - // according to the new definition of CLS. - if (!('fcp' in this._measurements)) { - delete this._measurements.cls; - } - - Object.keys(this._measurements).forEach(measurementName => { - transaction.setMeasurement( - measurementName, - this._measurements[measurementName].value, - this._measurements[measurementName].unit, - ); - }); - - tagMetricInfo(transaction, this._lcpEntry, this._clsEntry); - transaction.setTag('sentry_reportAllChanges', this._reportAllChanges); - } - } - - /** - * Capture the information of the user agent. - */ - private _trackNavigator(transaction: Transaction): void { - const navigator = global.navigator as null | (Navigator & NavigatorNetworkInformation & NavigatorDeviceMemory); - if (!navigator) { - return; - } - - // track network connectivity - const connection = navigator.connection; - if (connection) { - if (connection.effectiveType) { - transaction.setTag('effectiveConnectionType', connection.effectiveType); - } - - if (connection.type) { - transaction.setTag('connectionType', connection.type); - } - - if (isMeasurementValue(connection.rtt)) { - this._measurements['connection.rtt'] = { value: connection.rtt, unit: 'millisecond' }; - } - - if (isMeasurementValue(connection.downlink)) { - this._measurements['connection.downlink'] = { value: connection.downlink, unit: '' }; // unit is empty string for now, while relay doesn't support download speed units - } - } - - if (isMeasurementValue(navigator.deviceMemory)) { - transaction.setTag('deviceMemory', `${navigator.deviceMemory} GB`); - } - - if (isMeasurementValue(navigator.hardwareConcurrency)) { - transaction.setTag('hardwareConcurrency', String(navigator.hardwareConcurrency)); - } - } - - /** Starts tracking the Cumulative Layout Shift on the current page. */ - private _trackCLS(): void { - // See: - // https://web.dev/evolving-cls/ - // https://web.dev/cls-web-tooling/ - getCLS(metric => { - const entry = metric.entries.pop(); - if (!entry) { - return; - } - - IS_DEBUG_BUILD && logger.log('[Measurements] Adding CLS'); - this._measurements['cls'] = { value: metric.value, unit: 'millisecond' }; - this._clsEntry = entry as LayoutShift; - }); - } - - /** Starts tracking the Largest Contentful Paint on the current page. */ - private _trackLCP(): void { - getLCP(metric => { - const entry = metric.entries.pop(); - if (!entry) { - return; - } - - const timeOrigin = msToSec(browserPerformanceTimeOrigin as number); - const startTime = msToSec(entry.startTime); - IS_DEBUG_BUILD && logger.log('[Measurements] Adding LCP'); - this._measurements['lcp'] = { value: metric.value, unit: 'millisecond' }; - this._measurements['mark.lcp'] = { value: timeOrigin + startTime, unit: 'second' }; - this._lcpEntry = entry as LargestContentfulPaint; - }, this._reportAllChanges); - } - - /** Starts tracking the First Input Delay on the current page. */ - private _trackFID(): void { - getFID(metric => { - const entry = metric.entries.pop(); - if (!entry) { - return; - } - - const timeOrigin = msToSec(browserPerformanceTimeOrigin as number); - const startTime = msToSec(entry.startTime); - IS_DEBUG_BUILD && logger.log('[Measurements] Adding FID'); - this._measurements['fid'] = { value: metric.value, unit: 'millisecond' }; - this._measurements['mark.fid'] = { value: timeOrigin + startTime, unit: 'second' }; - }); - } -} - -/** Instrument navigation entries */ -function addNavigationSpans(transaction: Transaction, entry: Record, timeOrigin: number): void { - ['unloadEvent', 'redirect', 'domContentLoadedEvent', 'loadEvent', 'connect'].forEach(event => { - addPerformanceNavigationTiming(transaction, entry, event, timeOrigin); - }); - addPerformanceNavigationTiming(transaction, entry, 'secureConnection', timeOrigin, 'TLS/SSL', 'connectEnd'); - addPerformanceNavigationTiming(transaction, entry, 'fetch', timeOrigin, 'cache', 'domainLookupStart'); - addPerformanceNavigationTiming(transaction, entry, 'domainLookup', timeOrigin, 'DNS'); - addRequest(transaction, entry, timeOrigin); -} - -/** Create measure related spans */ -function addMeasureSpans( - transaction: Transaction, - entry: Record, - startTime: number, - duration: number, - timeOrigin: number, -): number { - const measureStartTimestamp = timeOrigin + startTime; - const measureEndTimestamp = measureStartTimestamp + duration; - - _startChild(transaction, { - description: entry.name as string, - endTimestamp: measureEndTimestamp, - op: entry.entryType as string, - startTimestamp: measureStartTimestamp, - }); - - return measureStartTimestamp; -} - -export interface ResourceEntry extends Record { - initiatorType?: string; - transferSize?: number; - encodedBodySize?: number; - decodedBodySize?: number; -} - -/** Create resource-related spans */ -export function addResourceSpans( - transaction: Transaction, - entry: ResourceEntry, - resourceName: string, - startTime: number, - duration: number, - timeOrigin: number, -): void { - // we already instrument based on fetch and xhr, so we don't need to - // duplicate spans here. - if (entry.initiatorType === 'xmlhttprequest' || entry.initiatorType === 'fetch') { - return; - } - - const data: Record = {}; - if ('transferSize' in entry) { - data['Transfer Size'] = entry.transferSize; - } - if ('encodedBodySize' in entry) { - data['Encoded Body Size'] = entry.encodedBodySize; - } - if ('decodedBodySize' in entry) { - data['Decoded Body Size'] = entry.decodedBodySize; - } - - const startTimestamp = timeOrigin + startTime; - const endTimestamp = startTimestamp + duration; - - _startChild(transaction, { - description: resourceName, - endTimestamp, - op: entry.initiatorType ? `resource.${entry.initiatorType}` : 'resource', - startTimestamp, - data, - }); -} - -/** Create performance navigation related spans */ -function addPerformanceNavigationTiming( - transaction: Transaction, - entry: Record, - event: string, - timeOrigin: number, - description?: string, - eventEnd?: string, -): void { - const end = eventEnd ? (entry[eventEnd] as number | undefined) : (entry[`${event}End`] as number | undefined); - const start = entry[`${event}Start`] as number | undefined; - if (!start || !end) { - return; - } - _startChild(transaction, { - op: 'browser', - description: description ?? event, - startTimestamp: timeOrigin + msToSec(start), - endTimestamp: timeOrigin + msToSec(end), - }); -} - -/** Create request and response related spans */ -function addRequest(transaction: Transaction, entry: Record, timeOrigin: number): void { - _startChild(transaction, { - op: 'browser', - description: 'request', - startTimestamp: timeOrigin + msToSec(entry.requestStart as number), - endTimestamp: timeOrigin + msToSec(entry.responseEnd as number), - }); - - _startChild(transaction, { - op: 'browser', - description: 'response', - startTimestamp: timeOrigin + msToSec(entry.responseStart as number), - endTimestamp: timeOrigin + msToSec(entry.responseEnd as number), - }); -} - -/** - * Helper function to start child on transactions. This function will make sure that the transaction will - * use the start timestamp of the created child span if it is earlier than the transactions actual - * start timestamp. - */ -export function _startChild(transaction: Transaction, { startTimestamp, ...ctx }: SpanContext): Span { - if (startTimestamp && transaction.startTimestamp > startTimestamp) { - transaction.startTimestamp = startTimestamp; - } - - return transaction.startChild({ - startTimestamp, - ...ctx, - }); -} - -/** - * Checks if a given value is a valid measurement value. - */ -function isMeasurementValue(value: unknown): value is number { - return typeof value === 'number' && isFinite(value); -} - -/** Add LCP / CLS data to transaction to allow debugging */ -function tagMetricInfo( - transaction: Transaction, - lcpEntry: MetricsInstrumentation['_lcpEntry'], - clsEntry: MetricsInstrumentation['_clsEntry'], -): void { - if (lcpEntry) { - IS_DEBUG_BUILD && logger.log('[Measurements] Adding LCP Data'); - - // Capture Properties of the LCP element that contributes to the LCP. - - if (lcpEntry.element) { - transaction.setTag('lcp.element', htmlTreeAsString(lcpEntry.element)); - } - - if (lcpEntry.id) { - transaction.setTag('lcp.id', lcpEntry.id); - } - - if (lcpEntry.url) { - // Trim URL to the first 200 characters. - transaction.setTag('lcp.url', lcpEntry.url.trim().slice(0, 200)); - } - - transaction.setTag('lcp.size', lcpEntry.size); - } - - // See: https://developer.mozilla.org/en-US/docs/Web/API/LayoutShift - if (clsEntry && clsEntry.sources) { - IS_DEBUG_BUILD && logger.log('[Measurements] Adding CLS Data'); - clsEntry.sources.forEach((source, index) => - transaction.setTag(`cls.source.${index + 1}`, htmlTreeAsString(source.node)), - ); - } -} diff --git a/packages/tracing/src/browser/metrics/index.ts b/packages/tracing/src/browser/metrics/index.ts new file mode 100644 index 000000000000..48438ab7b719 --- /dev/null +++ b/packages/tracing/src/browser/metrics/index.ts @@ -0,0 +1,421 @@ +/* eslint-disable max-lines */ +import { Measurements } from '@sentry/types'; +import { browserPerformanceTimeOrigin, getGlobalObject, htmlTreeAsString, isNodeEnv, logger } from '@sentry/utils'; + +import { IS_DEBUG_BUILD } from '../../flags'; +import { Transaction } from '../../transaction'; +import { msToSec } from '../../utils'; +import { getCLS, LayoutShift } from '../web-vitals/getCLS'; +import { getFID } from '../web-vitals/getFID'; +import { getLCP, LargestContentfulPaint } from '../web-vitals/getLCP'; +import { getVisibilityWatcher } from '../web-vitals/lib/getVisibilityWatcher'; +import { NavigatorDeviceMemory, NavigatorNetworkInformation } from '../web-vitals/types'; +import { _startChild, isMeasurementValue } from './utils'; + +const global = getGlobalObject(); + +function getBrowserPerformanceAPI(): false | Performance { + return !isNodeEnv() && global && global.document && global.performance; +} + +let _performanceCursor: number = 0; + +let _measurements: Measurements = {}; +let _lcpEntry: LargestContentfulPaint | undefined; +let _clsEntry: LayoutShift | undefined; + +/** + * Start tracking web vitals + */ +export function startTrackingWebVitals(reportAllChanges: boolean = false): void { + const performance = getBrowserPerformanceAPI(); + if (performance && browserPerformanceTimeOrigin) { + if (performance.mark) { + global.performance.mark('sentry-tracing-init'); + } + _trackCLS(); + _trackLCP(reportAllChanges); + _trackFID(); + } +} + +/** Starts tracking the Cumulative Layout Shift on the current page. */ +function _trackCLS(): void { + // See: + // https://web.dev/evolving-cls/ + // https://web.dev/cls-web-tooling/ + getCLS(metric => { + const entry = metric.entries.pop(); + if (!entry) { + return; + } + + IS_DEBUG_BUILD && logger.log('[Measurements] Adding CLS'); + _measurements['cls'] = { value: metric.value, unit: 'millisecond' }; + _clsEntry = entry as LayoutShift; + }); +} + +/** Starts tracking the Largest Contentful Paint on the current page. */ +function _trackLCP(reportAllChanges: boolean): void { + getLCP(metric => { + const entry = metric.entries.pop(); + if (!entry) { + return; + } + + const timeOrigin = msToSec(browserPerformanceTimeOrigin as number); + const startTime = msToSec(entry.startTime); + IS_DEBUG_BUILD && logger.log('[Measurements] Adding LCP'); + _measurements['lcp'] = { value: metric.value, unit: 'millisecond' }; + _measurements['mark.lcp'] = { value: timeOrigin + startTime, unit: 'second' }; + _lcpEntry = entry as LargestContentfulPaint; + }, reportAllChanges); +} + +/** Starts tracking the First Input Delay on the current page. */ +function _trackFID(): void { + getFID(metric => { + const entry = metric.entries.pop(); + if (!entry) { + return; + } + + const timeOrigin = msToSec(browserPerformanceTimeOrigin as number); + const startTime = msToSec(entry.startTime); + IS_DEBUG_BUILD && logger.log('[Measurements] Adding FID'); + _measurements['fid'] = { value: metric.value, unit: 'millisecond' }; + _measurements['mark.fid'] = { value: timeOrigin + startTime, unit: 'second' }; + }); +} + +/** Add performance related spans to a transaction */ +export function addPerformanceEntries(transaction: Transaction): void { + const performance = getBrowserPerformanceAPI(); + if (!performance || !global.performance.getEntries || !browserPerformanceTimeOrigin) { + // Gatekeeper if performance API not available + return; + } + + IS_DEBUG_BUILD && logger.log('[Tracing] Adding & adjusting spans using Performance API'); + const timeOrigin = msToSec(browserPerformanceTimeOrigin); + + const performanceEntries = performance.getEntries(); + + let responseStartTimestamp: number | undefined; + let requestStartTimestamp: number | undefined; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + performanceEntries.slice(_performanceCursor).forEach((entry: Record) => { + const startTime = msToSec(entry.startTime); + const duration = msToSec(entry.duration); + + if (transaction.op === 'navigation' && timeOrigin + startTime < transaction.startTimestamp) { + return; + } + + switch (entry.entryType) { + case 'navigation': { + _addNavigationSpans(transaction, entry, timeOrigin); + responseStartTimestamp = timeOrigin + msToSec(entry.responseStart); + requestStartTimestamp = timeOrigin + msToSec(entry.requestStart); + break; + } + case 'mark': + case 'paint': + case 'measure': { + const startTimestamp = _addMeasureSpans(transaction, entry, startTime, duration, timeOrigin); + + // 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) { + IS_DEBUG_BUILD && logger.log('[Measurements] Adding FP'); + _measurements['fp'] = { value: entry.startTime, unit: 'millisecond' }; + _measurements['mark.fp'] = { value: startTimestamp, unit: 'second' }; + } + if (entry.name === 'first-contentful-paint' && shouldRecord) { + IS_DEBUG_BUILD && logger.log('[Measurements] Adding FCP'); + _measurements['fcp'] = { value: entry.startTime, unit: 'millisecond' }; + _measurements['mark.fcp'] = { value: startTimestamp, unit: 'second' }; + } + break; + } + case 'resource': { + const resourceName = (entry.name as string).replace(global.location.origin, ''); + _addResourceSpans(transaction, entry, resourceName, startTime, duration, timeOrigin); + break; + } + default: + // Ignore other entry types. + } + }); + + _performanceCursor = Math.max(performanceEntries.length - 1, 0); + + _trackNavigator(transaction); + + // Measurements are only available for pageload transactions + if (transaction.op === 'pageload') { + // Generate TTFB (Time to First Byte), which measured as the time between the beginning of the transaction and the + // start of the response in milliseconds + if (typeof responseStartTimestamp === 'number') { + IS_DEBUG_BUILD && logger.log('[Measurements] Adding TTFB'); + _measurements['ttfb'] = { + value: (responseStartTimestamp - transaction.startTimestamp) * 1000, + unit: 'millisecond', + }; + + if (typeof requestStartTimestamp === 'number' && requestStartTimestamp <= responseStartTimestamp) { + // Capture the time spent making the request and receiving the first byte of the response. + // This is the time between the start of the request and the start of the response in milliseconds. + _measurements['ttfb.requestTime'] = { + value: (responseStartTimestamp - requestStartTimestamp) * 1000, + unit: 'second', + }; + } + } + + ['fcp', 'fp', 'lcp'].forEach(name => { + if (!_measurements[name] || timeOrigin >= transaction.startTimestamp) { + return; + } + // The web vitals, fcp, fp, lcp, and ttfb, all measure relative to timeOrigin. + // Unfortunately, timeOrigin is not captured within the transaction span data, so these web vitals will need + // to be adjusted to be relative to transaction.startTimestamp. + const oldValue = _measurements[name].value; + const measurementTimestamp = timeOrigin + msToSec(oldValue); + + // normalizedValue should be in milliseconds + const normalizedValue = Math.abs((measurementTimestamp - transaction.startTimestamp) * 1000); + const delta = normalizedValue - oldValue; + + IS_DEBUG_BUILD && + logger.log(`[Measurements] Normalized ${name} from ${oldValue} to ${normalizedValue} (${delta})`); + _measurements[name].value = normalizedValue; + }); + + if (_measurements['mark.fid'] && _measurements['fid']) { + // create span for FID + _startChild(transaction, { + description: 'first input delay', + endTimestamp: _measurements['mark.fid'].value + msToSec(_measurements['fid'].value), + op: 'web.vitals', + startTimestamp: _measurements['mark.fid'].value, + }); + } + + // If FCP is not recorded we should not record the cls value + // according to the new definition of CLS. + if (!('fcp' in _measurements)) { + delete _measurements.cls; + } + + Object.keys(_measurements).forEach(measurementName => { + transaction.setMeasurement( + measurementName, + _measurements[measurementName].value, + _measurements[measurementName].unit, + ); + }); + + _tagMetricInfo(transaction); + } + + _lcpEntry = undefined; + _clsEntry = undefined; + _measurements = {}; +} + +/** Create measure related spans */ +export function _addMeasureSpans( + transaction: Transaction, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + entry: Record, + startTime: number, + duration: number, + timeOrigin: number, +): number { + const measureStartTimestamp = timeOrigin + startTime; + const measureEndTimestamp = measureStartTimestamp + duration; + + _startChild(transaction, { + description: entry.name as string, + endTimestamp: measureEndTimestamp, + op: entry.entryType as string, + startTimestamp: measureStartTimestamp, + }); + + return measureStartTimestamp; +} + +/** Instrument navigation entries */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function _addNavigationSpans(transaction: Transaction, entry: Record, timeOrigin: number): void { + ['unloadEvent', 'redirect', 'domContentLoadedEvent', 'loadEvent', 'connect'].forEach(event => { + _addPerformanceNavigationTiming(transaction, entry, event, timeOrigin); + }); + _addPerformanceNavigationTiming(transaction, entry, 'secureConnection', timeOrigin, 'TLS/SSL', 'connectEnd'); + _addPerformanceNavigationTiming(transaction, entry, 'fetch', timeOrigin, 'cache', 'domainLookupStart'); + _addPerformanceNavigationTiming(transaction, entry, 'domainLookup', timeOrigin, 'DNS'); + _addRequest(transaction, entry, timeOrigin); +} + +/** Create performance navigation related spans */ +function _addPerformanceNavigationTiming( + transaction: Transaction, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + entry: Record, + event: string, + timeOrigin: number, + description?: string, + eventEnd?: string, +): void { + const end = eventEnd ? (entry[eventEnd] as number | undefined) : (entry[`${event}End`] as number | undefined); + const start = entry[`${event}Start`] as number | undefined; + if (!start || !end) { + return; + } + _startChild(transaction, { + op: 'browser', + description: description ?? event, + startTimestamp: timeOrigin + msToSec(start), + endTimestamp: timeOrigin + msToSec(end), + }); +} + +/** Create request and response related spans */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function _addRequest(transaction: Transaction, entry: Record, timeOrigin: number): void { + _startChild(transaction, { + op: 'browser', + description: 'request', + startTimestamp: timeOrigin + msToSec(entry.requestStart as number), + endTimestamp: timeOrigin + msToSec(entry.responseEnd as number), + }); + + _startChild(transaction, { + op: 'browser', + description: 'response', + startTimestamp: timeOrigin + msToSec(entry.responseStart as number), + endTimestamp: timeOrigin + msToSec(entry.responseEnd as number), + }); +} + +export interface ResourceEntry extends Record { + initiatorType?: string; + transferSize?: number; + encodedBodySize?: number; + decodedBodySize?: number; +} + +/** Create resource-related spans */ +export function _addResourceSpans( + transaction: Transaction, + entry: ResourceEntry, + resourceName: string, + startTime: number, + duration: number, + timeOrigin: number, +): void { + // we already instrument based on fetch and xhr, so we don't need to + // duplicate spans here. + if (entry.initiatorType === 'xmlhttprequest' || entry.initiatorType === 'fetch') { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data: Record = {}; + if ('transferSize' in entry) { + data['Transfer Size'] = entry.transferSize; + } + if ('encodedBodySize' in entry) { + data['Encoded Body Size'] = entry.encodedBodySize; + } + if ('decodedBodySize' in entry) { + data['Decoded Body Size'] = entry.decodedBodySize; + } + + const startTimestamp = timeOrigin + startTime; + const endTimestamp = startTimestamp + duration; + + _startChild(transaction, { + description: resourceName, + endTimestamp, + op: entry.initiatorType ? `resource.${entry.initiatorType}` : 'resource', + startTimestamp, + data, + }); +} + +/** + * Capture the information of the user agent. + */ +function _trackNavigator(transaction: Transaction): void { + const navigator = global.navigator as null | (Navigator & NavigatorNetworkInformation & NavigatorDeviceMemory); + if (!navigator) { + return; + } + + // track network connectivity + const connection = navigator.connection; + if (connection) { + if (connection.effectiveType) { + transaction.setTag('effectiveConnectionType', connection.effectiveType); + } + + if (connection.type) { + transaction.setTag('connectionType', connection.type); + } + + if (isMeasurementValue(connection.rtt)) { + _measurements['connection.rtt'] = { value: connection.rtt, unit: 'millisecond' }; + } + + if (isMeasurementValue(connection.downlink)) { + _measurements['connection.downlink'] = { value: connection.downlink, unit: '' }; // unit is empty string for now, while relay doesn't support download speed units + } + } + + if (isMeasurementValue(navigator.deviceMemory)) { + transaction.setTag('deviceMemory', `${navigator.deviceMemory} GB`); + } + + if (isMeasurementValue(navigator.hardwareConcurrency)) { + transaction.setTag('hardwareConcurrency', String(navigator.hardwareConcurrency)); + } +} + +/** Add LCP / CLS data to transaction to allow debugging */ +function _tagMetricInfo(transaction: Transaction): void { + if (_lcpEntry) { + IS_DEBUG_BUILD && logger.log('[Measurements] Adding LCP Data'); + + // Capture Properties of the LCP element that contributes to the LCP. + + if (_lcpEntry.element) { + transaction.setTag('lcp.element', htmlTreeAsString(_lcpEntry.element)); + } + + if (_lcpEntry.id) { + transaction.setTag('lcp.id', _lcpEntry.id); + } + + if (_lcpEntry.url) { + // Trim URL to the first 200 characters. + transaction.setTag('lcp.url', _lcpEntry.url.trim().slice(0, 200)); + } + + transaction.setTag('lcp.size', _lcpEntry.size); + } + + // See: https://developer.mozilla.org/en-US/docs/Web/API/LayoutShift + if (_clsEntry && _clsEntry.sources) { + IS_DEBUG_BUILD && logger.log('[Measurements] Adding CLS Data'); + _clsEntry.sources.forEach((source, index) => + transaction.setTag(`cls.source.${index + 1}`, htmlTreeAsString(source.node)), + ); + } +} diff --git a/packages/tracing/src/browser/metrics/utils.ts b/packages/tracing/src/browser/metrics/utils.ts new file mode 100644 index 000000000000..4894dbd3ec8e --- /dev/null +++ b/packages/tracing/src/browser/metrics/utils.ts @@ -0,0 +1,26 @@ +import { Span, SpanContext } from '@sentry/types'; + +import { Transaction } from '../../transaction'; + +/** + * Checks if a given value is a valid measurement value. + */ +export function isMeasurementValue(value: unknown): value is number { + return typeof value === 'number' && isFinite(value); +} + +/** + * Helper function to start child on transactions. This function will make sure that the transaction will + * use the start timestamp of the created child span if it is earlier than the transactions actual + * start timestamp. + */ +export function _startChild(transaction: Transaction, { startTimestamp, ...ctx }: SpanContext): Span { + if (startTimestamp && transaction.startTimestamp > startTimestamp) { + transaction.startTimestamp = startTimestamp; + } + + return transaction.startChild({ + startTimestamp, + ...ctx, + }); +} diff --git a/packages/tracing/test/browser/browsertracing.test.ts b/packages/tracing/test/browser/browsertracing.test.ts index 4bfc093fbe2b..be18cc389650 100644 --- a/packages/tracing/test/browser/browsertracing.test.ts +++ b/packages/tracing/test/browser/browsertracing.test.ts @@ -9,7 +9,6 @@ import { getHeaderContext, getMetaContent, } from '../../src/browser/browsertracing'; -import { MetricsInstrumentation } from '../../src/browser/metrics'; import { defaultRequestInstrumentationOptions } from '../../src/browser/request'; import { instrumentRoutingWithDefaults } from '../../src/browser/router'; import * as hubExtensions from '../../src/hubextensions'; @@ -466,29 +465,4 @@ describe('BrowserTracing', () => { ); }); }); - - describe('metrics', () => { - beforeEach(() => { - // @ts-ignore mock clear - MetricsInstrumentation.mockClear(); - }); - - it('creates metrics instrumentation', () => { - createBrowserTracing(true, {}); - - expect(MetricsInstrumentation).toHaveBeenCalledTimes(1); - expect(MetricsInstrumentation).toHaveBeenLastCalledWith(undefined); - }); - - it('creates metrics instrumentation with custom options', () => { - createBrowserTracing(true, { - _metricOptions: { - _reportAllChanges: true, - }, - }); - - expect(MetricsInstrumentation).toHaveBeenCalledTimes(1); - expect(MetricsInstrumentation).toHaveBeenLastCalledWith(true); - }); - }); }); diff --git a/packages/tracing/test/browser/metrics.test.ts b/packages/tracing/test/browser/metrics.test.ts deleted file mode 100644 index eb79ff30df93..000000000000 --- a/packages/tracing/test/browser/metrics.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { Span, Transaction } from '../../src'; -import { _startChild, addResourceSpans, MetricsInstrumentation, ResourceEntry } from '../../src/browser/metrics'; -import { addDOMPropertiesToGlobal } from '../testutils'; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any, no-var -declare var global: any; - -describe('_startChild()', () => { - it('creates a span with given properties', () => { - const transaction = new Transaction({ name: 'test' }); - const span = _startChild(transaction, { - description: 'evaluation', - op: 'script', - }); - - expect(span).toBeInstanceOf(Span); - expect(span.description).toBe('evaluation'); - expect(span.op).toBe('script'); - }); - - it('adjusts the start timestamp if child span starts before transaction', () => { - const transaction = new Transaction({ name: 'test', startTimestamp: 123 }); - const span = _startChild(transaction, { - description: 'script.js', - op: 'resource', - startTimestamp: 100, - }); - - expect(transaction.startTimestamp).toEqual(span.startTimestamp); - expect(transaction.startTimestamp).toEqual(100); - }); - - it('does not adjust start timestamp if child span starts after transaction', () => { - const transaction = new Transaction({ name: 'test', startTimestamp: 123 }); - const span = _startChild(transaction, { - description: 'script.js', - op: 'resource', - startTimestamp: 150, - }); - - expect(transaction.startTimestamp).not.toEqual(span.startTimestamp); - expect(transaction.startTimestamp).toEqual(123); - }); -}); - -describe('addResourceSpans', () => { - const transaction = new Transaction({ name: 'hello' }); - beforeEach(() => { - transaction.startChild = jest.fn(); - }); - - // We already track xhr, we don't need to use - it('does not create spans for xmlhttprequest', () => { - const entry: ResourceEntry = { - initiatorType: 'xmlhttprequest', - transferSize: 256, - encodedBodySize: 256, - decodedBodySize: 256, - }; - addResourceSpans(transaction, entry, '/assets/to/me', 123, 456, 100); - - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(transaction.startChild).toHaveBeenCalledTimes(0); - }); - - it('does not create spans for fetch', () => { - const entry: ResourceEntry = { - initiatorType: 'fetch', - transferSize: 256, - encodedBodySize: 256, - decodedBodySize: 256, - }; - addResourceSpans(transaction, entry, '/assets/to/me', 123, 456, 100); - - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(transaction.startChild).toHaveBeenCalledTimes(0); - }); - - it('creates spans for resource spans', () => { - const entry: ResourceEntry = { - initiatorType: 'css', - transferSize: 256, - encodedBodySize: 456, - decodedBodySize: 593, - }; - - const timeOrigin = 100; - const startTime = 23; - const duration = 356; - - addResourceSpans(transaction, entry, '/assets/to/css', startTime, duration, timeOrigin); - - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(transaction.startChild).toHaveBeenCalledTimes(1); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(transaction.startChild).toHaveBeenLastCalledWith({ - data: { - ['Decoded Body Size']: entry.decodedBodySize, - ['Encoded Body Size']: entry.encodedBodySize, - ['Transfer Size']: entry.transferSize, - }, - description: '/assets/to/css', - endTimestamp: timeOrigin + startTime + duration, - op: 'resource.css', - startTimestamp: timeOrigin + startTime, - }); - }); - - it('creates a variety of resource spans', () => { - const table = [ - { - initiatorType: undefined, - op: 'resource', - }, - { - initiatorType: '', - op: 'resource', - }, - { - initiatorType: 'css', - op: 'resource.css', - }, - { - initiatorType: 'image', - op: 'resource.image', - }, - { - initiatorType: 'script', - op: 'resource.script', - }, - ]; - - for (const { initiatorType, op } of table) { - const entry: ResourceEntry = { - initiatorType, - }; - addResourceSpans(transaction, entry, '/assets/to/me', 123, 234, 465); - - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(transaction.startChild).toHaveBeenLastCalledWith( - expect.objectContaining({ - op, - }), - ); - } - }); - - it('allows for enter size of 0', () => { - const entry: ResourceEntry = { - initiatorType: 'css', - transferSize: 0, - encodedBodySize: 0, - decodedBodySize: 0, - }; - - addResourceSpans(transaction, entry, '/assets/to/css', 100, 23, 345); - - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(transaction.startChild).toHaveBeenCalledTimes(1); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(transaction.startChild).toHaveBeenLastCalledWith( - expect.objectContaining({ - data: { - ['Decoded Body Size']: entry.decodedBodySize, - ['Encoded Body Size']: entry.encodedBodySize, - ['Transfer Size']: entry.transferSize, - }, - }), - ); - }); -}); - -describe('MetricsInstrumentation', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('does not initialize trackers when on node', () => { - const trackers = ['_trackCLS', '_trackLCP', '_trackFID'].map(tracker => - jest.spyOn(MetricsInstrumentation.prototype as any, tracker), - ); - - new MetricsInstrumentation(); - - trackers.forEach(tracker => expect(tracker).not.toBeCalled()); - }); - - it('initializes trackers when not on node and `global.performance` and `global.document` are available.', () => { - addDOMPropertiesToGlobal(['performance', 'document', 'addEventListener', 'window']); - - const backup = global.process; - global.process = undefined; - - const trackers = ['_trackCLS', '_trackLCP', '_trackFID'].map(tracker => - jest.spyOn(MetricsInstrumentation.prototype as any, tracker), - ); - new MetricsInstrumentation(); - global.process = backup; - - trackers.forEach(tracker => expect(tracker).toBeCalled()); - }); - - it('does not initialize trackers when not on node but `global.document` is not available (in worker)', () => { - // window not necessary for this test, but it is here to exercise that it is absence of document that is checked - addDOMPropertiesToGlobal(['performance', 'addEventListener', 'window']); - - const processBackup = global.process; - global.process = undefined; - const documentBackup = global.document; - global.document = undefined; - - const trackers = ['_trackCLS', '_trackLCP', '_trackFID'].map(tracker => - jest.spyOn(MetricsInstrumentation.prototype as any, tracker), - ); - new MetricsInstrumentation(); - global.process = processBackup; - global.document = documentBackup; - - trackers.forEach(tracker => expect(tracker).not.toBeCalled()); - }); -}); diff --git a/packages/tracing/test/browser/metrics/index.test.ts b/packages/tracing/test/browser/metrics/index.test.ts new file mode 100644 index 000000000000..b47ebd92a8f3 --- /dev/null +++ b/packages/tracing/test/browser/metrics/index.test.ts @@ -0,0 +1,162 @@ +import { Transaction } from '../../../src'; +import { _addMeasureSpans, _addResourceSpans, ResourceEntry } from '../../../src/browser/metrics'; + +describe('_addMeasureSpans', () => { + const transaction = new Transaction({ op: 'pageload', name: '/' }); + beforeEach(() => { + transaction.startChild = jest.fn(); + }); + + it('adds measure spans to a transaction', () => { + const entry: Omit = { + entryType: 'measure', + name: 'measure-1', + duration: 10, + startTime: 12, + }; + + const timeOrigin = 100; + const startTime = 23; + const duration = 356; + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(transaction.startChild).toHaveBeenCalledTimes(0); + _addMeasureSpans(transaction, entry, startTime, duration, timeOrigin); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(transaction.startChild).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(transaction.startChild).toHaveBeenLastCalledWith({ + description: 'measure-1', + startTimestamp: timeOrigin + startTime, + endTimestamp: timeOrigin + startTime + duration, + op: 'measure', + }); + }); +}); + +describe('_addResourceSpans', () => { + const transaction = new Transaction({ op: 'pageload', name: '/' }); + beforeEach(() => { + transaction.startChild = jest.fn(); + }); + + // We already track xhr, we don't need to use + it('does not create spans for xmlhttprequest', () => { + const entry: ResourceEntry = { + initiatorType: 'xmlhttprequest', + transferSize: 256, + encodedBodySize: 256, + decodedBodySize: 256, + }; + _addResourceSpans(transaction, entry, '/assets/to/me', 123, 456, 100); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(transaction.startChild).toHaveBeenCalledTimes(0); + }); + + it('does not create spans for fetch', () => { + const entry: ResourceEntry = { + initiatorType: 'fetch', + transferSize: 256, + encodedBodySize: 256, + decodedBodySize: 256, + }; + _addResourceSpans(transaction, entry, '/assets/to/me', 123, 456, 100); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(transaction.startChild).toHaveBeenCalledTimes(0); + }); + + it('creates spans for resource spans', () => { + const entry: ResourceEntry = { + initiatorType: 'css', + transferSize: 256, + encodedBodySize: 456, + decodedBodySize: 593, + }; + + const timeOrigin = 100; + const startTime = 23; + const duration = 356; + + _addResourceSpans(transaction, entry, '/assets/to/css', startTime, duration, timeOrigin); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(transaction.startChild).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(transaction.startChild).toHaveBeenLastCalledWith({ + data: { + ['Decoded Body Size']: entry.decodedBodySize, + ['Encoded Body Size']: entry.encodedBodySize, + ['Transfer Size']: entry.transferSize, + }, + description: '/assets/to/css', + endTimestamp: timeOrigin + startTime + duration, + op: 'resource.css', + startTimestamp: timeOrigin + startTime, + }); + }); + + it('creates a variety of resource spans', () => { + const table = [ + { + initiatorType: undefined, + op: 'resource', + }, + { + initiatorType: '', + op: 'resource', + }, + { + initiatorType: 'css', + op: 'resource.css', + }, + { + initiatorType: 'image', + op: 'resource.image', + }, + { + initiatorType: 'script', + op: 'resource.script', + }, + ]; + + for (const { initiatorType, op } of table) { + const entry: ResourceEntry = { + initiatorType, + }; + _addResourceSpans(transaction, entry, '/assets/to/me', 123, 234, 465); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(transaction.startChild).toHaveBeenLastCalledWith( + expect.objectContaining({ + op, + }), + ); + } + }); + + it('allows for enter size of 0', () => { + const entry: ResourceEntry = { + initiatorType: 'css', + transferSize: 0, + encodedBodySize: 0, + decodedBodySize: 0, + }; + + _addResourceSpans(transaction, entry, '/assets/to/css', 100, 23, 345); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(transaction.startChild).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(transaction.startChild).toHaveBeenLastCalledWith( + expect.objectContaining({ + data: { + ['Decoded Body Size']: entry.decodedBodySize, + ['Encoded Body Size']: entry.encodedBodySize, + ['Transfer Size']: entry.transferSize, + }, + }), + ); + }); +}); diff --git a/packages/tracing/test/browser/metrics/utils.test.ts b/packages/tracing/test/browser/metrics/utils.test.ts new file mode 100644 index 000000000000..25f03c11af0b --- /dev/null +++ b/packages/tracing/test/browser/metrics/utils.test.ts @@ -0,0 +1,40 @@ +import { Span, Transaction } from '../../../src'; +import { _startChild } from '../../../src/browser/metrics/utils'; + +describe('_startChild()', () => { + it('creates a span with given properties', () => { + const transaction = new Transaction({ name: 'test' }); + const span = _startChild(transaction, { + description: 'evaluation', + op: 'script', + }); + + expect(span).toBeInstanceOf(Span); + expect(span.description).toBe('evaluation'); + expect(span.op).toBe('script'); + }); + + it('adjusts the start timestamp if child span starts before transaction', () => { + const transaction = new Transaction({ name: 'test', startTimestamp: 123 }); + const span = _startChild(transaction, { + description: 'script.js', + op: 'resource', + startTimestamp: 100, + }); + + expect(transaction.startTimestamp).toEqual(span.startTimestamp); + expect(transaction.startTimestamp).toEqual(100); + }); + + it('does not adjust start timestamp if child span starts after transaction', () => { + const transaction = new Transaction({ name: 'test', startTimestamp: 123 }); + const span = _startChild(transaction, { + description: 'script.js', + op: 'resource', + startTimestamp: 150, + }); + + expect(transaction.startTimestamp).not.toEqual(span.startTimestamp); + expect(transaction.startTimestamp).toEqual(123); + }); +});