import { getActiveTransaction } from '@sentry/core'; import { browserPerformanceTimeOrigin, logger, htmlTreeAsString } from '@sentry/utils'; import { WINDOW } from '../types.js'; import { onCLS } from '../web-vitals/getCLS.js'; import { onFID } from '../web-vitals/getFID.js'; import { onLCP } from '../web-vitals/getLCP.js'; import { getVisibilityWatcher } from '../web-vitals/lib/getVisibilityWatcher.js'; import { observe } from '../web-vitals/lib/observe.js'; import { _startChild, isMeasurementValue } from './utils.js'; /** * Converts from milliseconds to seconds * @param time time in ms */ function msToSec(time) { return time / 1000; } function getBrowserPerformanceAPI() { // @ts-ignore we want to make sure all of these are available, even if TS is sure they are return WINDOW && WINDOW.addEventListener && WINDOW.performance; } let _performanceCursor = 0; let _measurements = {}; let _lcpEntry; let _clsEntry; /** * Start tracking web vitals * * @returns A function that forces web vitals collection */ function startTrackingWebVitals() { const performance = getBrowserPerformanceAPI(); if (performance && browserPerformanceTimeOrigin) { // @ts-ignore we want to make sure all of these are available, even if TS is sure they are if (performance.mark) { WINDOW.performance.mark('sentry-tracing-init'); } _trackFID(); const clsCallback = _trackCLS(); const lcpCallback = _trackLCP(); return () => { if (clsCallback) { clsCallback(); } if (lcpCallback) { lcpCallback(); } }; } return () => undefined; } /** * Start tracking long tasks. */ function startTrackingLongTasks() { const entryHandler = (entries) => { for (const entry of entries) { const transaction = getActiveTransaction() ; if (!transaction) { return; } const startTime = msToSec((browserPerformanceTimeOrigin ) + entry.startTime); const duration = msToSec(entry.duration); transaction.startChild({ description: 'Main UI thread blocked', op: 'ui.long-task', startTimestamp: startTime, endTimestamp: startTime + duration, }); } }; observe('longtask', entryHandler); } /** * Start tracking interaction events. */ function startTrackingInteractions() { const entryHandler = (entries) => { for (const entry of entries) { const transaction = getActiveTransaction() ; if (!transaction) { return; } if (entry.name === 'click') { const startTime = msToSec((browserPerformanceTimeOrigin ) + entry.startTime); const duration = msToSec(entry.duration); transaction.startChild({ description: htmlTreeAsString(entry.target), op: `ui.interaction.${entry.name}`, startTimestamp: startTime, endTimestamp: startTime + duration, }); } } }; observe('event', entryHandler, { durationThreshold: 0 }); } /** Starts tracking the Cumulative Layout Shift on the current page. */ function _trackCLS() { // See: // https://web.dev/evolving-cls/ // https://web.dev/cls-web-tooling/ return onCLS(metric => { const entry = metric.entries.pop(); if (!entry) { return; } (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log('[Measurements] Adding CLS'); _measurements['cls'] = { value: metric.value, unit: '' }; _clsEntry = entry ; }); } /** Starts tracking the Largest Contentful Paint on the current page. */ function _trackLCP() { return onLCP(metric => { const entry = metric.entries.pop(); if (!entry) { return; } (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log('[Measurements] Adding LCP'); _measurements['lcp'] = { value: metric.value, unit: 'millisecond' }; _lcpEntry = entry ; }); } /** Starts tracking the First Input Delay on the current page. */ function _trackFID() { onFID(metric => { const entry = metric.entries.pop(); if (!entry) { return; } const timeOrigin = msToSec(browserPerformanceTimeOrigin ); const startTime = msToSec(entry.startTime); (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && 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 */ function addPerformanceEntries(transaction) { const performance = getBrowserPerformanceAPI(); if (!performance || !WINDOW.performance.getEntries || !browserPerformanceTimeOrigin) { // Gatekeeper if performance API not available return; } (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log('[Tracing] Adding & adjusting spans using Performance API'); const timeOrigin = msToSec(browserPerformanceTimeOrigin); const performanceEntries = performance.getEntries(); let responseStartTimestamp; let requestStartTimestamp; // eslint-disable-next-line @typescript-eslint/no-explicit-any performanceEntries.slice(_performanceCursor).forEach((entry) => { 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': { _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) { (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log('[Measurements] Adding FP'); _measurements['fp'] = { value: entry.startTime, unit: 'millisecond' }; } if (entry.name === 'first-contentful-paint' && shouldRecord) { (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log('[Measurements] Adding FCP'); _measurements['fcp'] = { value: entry.startTime, unit: 'millisecond' }; } break; } case 'resource': { const resourceName = (entry.name ).replace(WINDOW.location.origin, ''); _addResourceSpans(transaction, entry, resourceName, startTime, duration, timeOrigin); break; } // 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') { (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && 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: 'millisecond', }; } } ['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; (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log(`[Measurements] Normalized ${name} from ${oldValue} to ${normalizedValue} (${delta})`); _measurements[name].value = normalizedValue; }); const fidMark = _measurements['mark.fid']; if (fidMark && _measurements['fid']) { // create span for FID _startChild(transaction, { description: 'first input delay', endTimestamp: fidMark.value + msToSec(_measurements['fid'].value), op: 'ui.action', startTimestamp: fidMark.value, }); // Delete mark.fid as we don't want it to be part of final payload delete _measurements['mark.fid']; } // 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 */ function _addMeasureSpans( transaction, // eslint-disable-next-line @typescript-eslint/no-explicit-any entry, startTime, duration, timeOrigin, ) { const measureStartTimestamp = timeOrigin + startTime; const measureEndTimestamp = measureStartTimestamp + duration; _startChild(transaction, { description: entry.name , endTimestamp: measureEndTimestamp, op: entry.entryType , startTimestamp: measureStartTimestamp, }); return measureStartTimestamp; } /** Instrument navigation entries */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function _addNavigationSpans(transaction, entry, timeOrigin) { ['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, // eslint-disable-next-line @typescript-eslint/no-explicit-any entry, event, timeOrigin, description, eventEnd, ) { const end = eventEnd ? (entry[eventEnd] ) : (entry[`${event}End`] ); const start = entry[`${event}Start`] ; 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, entry, timeOrigin) { _startChild(transaction, { op: 'browser', description: 'request', startTimestamp: timeOrigin + msToSec(entry.requestStart ), endTimestamp: timeOrigin + msToSec(entry.responseEnd ), }); _startChild(transaction, { op: 'browser', description: 'response', startTimestamp: timeOrigin + msToSec(entry.responseStart ), endTimestamp: timeOrigin + msToSec(entry.responseEnd ), }); } /** Create resource-related spans */ function _addResourceSpans( transaction, entry, resourceName, startTime, duration, timeOrigin, ) { // 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 = {}; if ('transferSize' in entry) { data['http.response_transfer_size'] = entry.transferSize; } if ('encodedBodySize' in entry) { data['http.response_content_length'] = entry.encodedBodySize; } if ('decodedBodySize' in entry) { data['http.decoded_response_content_length'] = entry.decodedBodySize; } if ('renderBlockingStatus' in entry) { data['resource.render_blocking_status'] = entry.renderBlockingStatus; } const startTimestamp = timeOrigin + startTime; const endTimestamp = startTimestamp + duration; _startChild(transaction, { description: resourceName, endTimestamp, op: entry.initiatorType ? `resource.${entry.initiatorType}` : 'resource.other', startTimestamp, data, }); } /** * Capture the information of the user agent. */ function _trackNavigator(transaction) { const navigator = WINDOW.navigator ; 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(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) { if (_lcpEntry) { (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && 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) { (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log('[Measurements] Adding CLS Data'); _clsEntry.sources.forEach((source, index) => transaction.setTag(`cls.source.${index + 1}`, htmlTreeAsString(source.node)), ); } } export { _addMeasureSpans, _addResourceSpans, addPerformanceEntries, startTrackingInteractions, startTrackingLongTasks, startTrackingWebVitals }; //# sourceMappingURL=index.js.map