index.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. import { getActiveTransaction, spanToJSON, setMeasurement } from '@sentry/core';
  2. import { browserPerformanceTimeOrigin, htmlTreeAsString, getComponentName, logger, parseUrl } from '@sentry/utils';
  3. import { DEBUG_BUILD } from '../../common/debug-build.js';
  4. import { addPerformanceInstrumentationHandler, addClsInstrumentationHandler, addLcpInstrumentationHandler, addFidInstrumentationHandler } from '../instrument.js';
  5. import { WINDOW } from '../types.js';
  6. import { getVisibilityWatcher } from '../web-vitals/lib/getVisibilityWatcher.js';
  7. import { _startChild, isMeasurementValue } from './utils.js';
  8. const MAX_INT_AS_BYTES = 2147483647;
  9. /**
  10. * Converts from milliseconds to seconds
  11. * @param time time in ms
  12. */
  13. function msToSec(time) {
  14. return time / 1000;
  15. }
  16. function getBrowserPerformanceAPI() {
  17. // @ts-expect-error we want to make sure all of these are available, even if TS is sure they are
  18. return WINDOW && WINDOW.addEventListener && WINDOW.performance;
  19. }
  20. let _performanceCursor = 0;
  21. let _measurements = {};
  22. let _lcpEntry;
  23. let _clsEntry;
  24. /**
  25. * Start tracking web vitals.
  26. * The callback returned by this function can be used to stop tracking & ensure all measurements are final & captured.
  27. *
  28. * @returns A function that forces web vitals collection
  29. */
  30. function startTrackingWebVitals() {
  31. const performance = getBrowserPerformanceAPI();
  32. if (performance && browserPerformanceTimeOrigin) {
  33. // @ts-expect-error we want to make sure all of these are available, even if TS is sure they are
  34. if (performance.mark) {
  35. WINDOW.performance.mark('sentry-tracing-init');
  36. }
  37. const fidCallback = _trackFID();
  38. const clsCallback = _trackCLS();
  39. const lcpCallback = _trackLCP();
  40. return () => {
  41. fidCallback();
  42. clsCallback();
  43. lcpCallback();
  44. };
  45. }
  46. return () => undefined;
  47. }
  48. /**
  49. * Start tracking long tasks.
  50. */
  51. function startTrackingLongTasks() {
  52. addPerformanceInstrumentationHandler('longtask', ({ entries }) => {
  53. for (const entry of entries) {
  54. // eslint-disable-next-line deprecation/deprecation
  55. const transaction = getActiveTransaction() ;
  56. if (!transaction) {
  57. return;
  58. }
  59. const startTime = msToSec((browserPerformanceTimeOrigin ) + entry.startTime);
  60. const duration = msToSec(entry.duration);
  61. // eslint-disable-next-line deprecation/deprecation
  62. transaction.startChild({
  63. description: 'Main UI thread blocked',
  64. op: 'ui.long-task',
  65. origin: 'auto.ui.browser.metrics',
  66. startTimestamp: startTime,
  67. endTimestamp: startTime + duration,
  68. });
  69. }
  70. });
  71. }
  72. /**
  73. * Start tracking interaction events.
  74. */
  75. function startTrackingInteractions() {
  76. addPerformanceInstrumentationHandler('event', ({ entries }) => {
  77. for (const entry of entries) {
  78. // eslint-disable-next-line deprecation/deprecation
  79. const transaction = getActiveTransaction() ;
  80. if (!transaction) {
  81. return;
  82. }
  83. if (entry.name === 'click') {
  84. const startTime = msToSec((browserPerformanceTimeOrigin ) + entry.startTime);
  85. const duration = msToSec(entry.duration);
  86. const span = {
  87. description: htmlTreeAsString(entry.target),
  88. op: `ui.interaction.${entry.name}`,
  89. origin: 'auto.ui.browser.metrics',
  90. startTimestamp: startTime,
  91. endTimestamp: startTime + duration,
  92. };
  93. const componentName = getComponentName(entry.target);
  94. if (componentName) {
  95. span.attributes = { 'ui.component_name': componentName };
  96. }
  97. // eslint-disable-next-line deprecation/deprecation
  98. transaction.startChild(span);
  99. }
  100. }
  101. });
  102. }
  103. /** Starts tracking the Cumulative Layout Shift on the current page. */
  104. function _trackCLS() {
  105. return addClsInstrumentationHandler(({ metric }) => {
  106. const entry = metric.entries[metric.entries.length - 1];
  107. if (!entry) {
  108. return;
  109. }
  110. DEBUG_BUILD && logger.log('[Measurements] Adding CLS');
  111. _measurements['cls'] = { value: metric.value, unit: '' };
  112. _clsEntry = entry ;
  113. }, true);
  114. }
  115. /** Starts tracking the Largest Contentful Paint on the current page. */
  116. function _trackLCP() {
  117. return addLcpInstrumentationHandler(({ metric }) => {
  118. const entry = metric.entries[metric.entries.length - 1];
  119. if (!entry) {
  120. return;
  121. }
  122. DEBUG_BUILD && logger.log('[Measurements] Adding LCP');
  123. _measurements['lcp'] = { value: metric.value, unit: 'millisecond' };
  124. _lcpEntry = entry ;
  125. }, true);
  126. }
  127. /** Starts tracking the First Input Delay on the current page. */
  128. function _trackFID() {
  129. return addFidInstrumentationHandler(({ metric }) => {
  130. const entry = metric.entries[metric.entries.length - 1];
  131. if (!entry) {
  132. return;
  133. }
  134. const timeOrigin = msToSec(browserPerformanceTimeOrigin );
  135. const startTime = msToSec(entry.startTime);
  136. DEBUG_BUILD && logger.log('[Measurements] Adding FID');
  137. _measurements['fid'] = { value: metric.value, unit: 'millisecond' };
  138. _measurements['mark.fid'] = { value: timeOrigin + startTime, unit: 'second' };
  139. });
  140. }
  141. /** Add performance related spans to a transaction */
  142. function addPerformanceEntries(transaction) {
  143. const performance = getBrowserPerformanceAPI();
  144. if (!performance || !WINDOW.performance.getEntries || !browserPerformanceTimeOrigin) {
  145. // Gatekeeper if performance API not available
  146. return;
  147. }
  148. DEBUG_BUILD && logger.log('[Tracing] Adding & adjusting spans using Performance API');
  149. const timeOrigin = msToSec(browserPerformanceTimeOrigin);
  150. const performanceEntries = performance.getEntries();
  151. let responseStartTimestamp;
  152. let requestStartTimestamp;
  153. const { op, start_timestamp: transactionStartTime } = spanToJSON(transaction);
  154. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  155. performanceEntries.slice(_performanceCursor).forEach((entry) => {
  156. const startTime = msToSec(entry.startTime);
  157. const duration = msToSec(entry.duration);
  158. // eslint-disable-next-line deprecation/deprecation
  159. if (transaction.op === 'navigation' && transactionStartTime && timeOrigin + startTime < transactionStartTime) {
  160. return;
  161. }
  162. switch (entry.entryType) {
  163. case 'navigation': {
  164. _addNavigationSpans(transaction, entry, timeOrigin);
  165. responseStartTimestamp = timeOrigin + msToSec(entry.responseStart);
  166. requestStartTimestamp = timeOrigin + msToSec(entry.requestStart);
  167. break;
  168. }
  169. case 'mark':
  170. case 'paint':
  171. case 'measure': {
  172. _addMeasureSpans(transaction, entry, startTime, duration, timeOrigin);
  173. // capture web vitals
  174. const firstHidden = getVisibilityWatcher();
  175. // Only report if the page wasn't hidden prior to the web vital.
  176. const shouldRecord = entry.startTime < firstHidden.firstHiddenTime;
  177. if (entry.name === 'first-paint' && shouldRecord) {
  178. DEBUG_BUILD && logger.log('[Measurements] Adding FP');
  179. _measurements['fp'] = { value: entry.startTime, unit: 'millisecond' };
  180. }
  181. if (entry.name === 'first-contentful-paint' && shouldRecord) {
  182. DEBUG_BUILD && logger.log('[Measurements] Adding FCP');
  183. _measurements['fcp'] = { value: entry.startTime, unit: 'millisecond' };
  184. }
  185. break;
  186. }
  187. case 'resource': {
  188. _addResourceSpans(transaction, entry, entry.name , startTime, duration, timeOrigin);
  189. break;
  190. }
  191. // Ignore other entry types.
  192. }
  193. });
  194. _performanceCursor = Math.max(performanceEntries.length - 1, 0);
  195. _trackNavigator(transaction);
  196. // Measurements are only available for pageload transactions
  197. if (op === 'pageload') {
  198. _addTtfbToMeasurements(_measurements, responseStartTimestamp, requestStartTimestamp, transactionStartTime);
  199. ['fcp', 'fp', 'lcp'].forEach(name => {
  200. if (!_measurements[name] || !transactionStartTime || timeOrigin >= transactionStartTime) {
  201. return;
  202. }
  203. // The web vitals, fcp, fp, lcp, and ttfb, all measure relative to timeOrigin.
  204. // Unfortunately, timeOrigin is not captured within the transaction span data, so these web vitals will need
  205. // to be adjusted to be relative to transaction.startTimestamp.
  206. const oldValue = _measurements[name].value;
  207. const measurementTimestamp = timeOrigin + msToSec(oldValue);
  208. // normalizedValue should be in milliseconds
  209. const normalizedValue = Math.abs((measurementTimestamp - transactionStartTime) * 1000);
  210. const delta = normalizedValue - oldValue;
  211. DEBUG_BUILD && logger.log(`[Measurements] Normalized ${name} from ${oldValue} to ${normalizedValue} (${delta})`);
  212. _measurements[name].value = normalizedValue;
  213. });
  214. const fidMark = _measurements['mark.fid'];
  215. if (fidMark && _measurements['fid']) {
  216. // create span for FID
  217. _startChild(transaction, {
  218. description: 'first input delay',
  219. endTimestamp: fidMark.value + msToSec(_measurements['fid'].value),
  220. op: 'ui.action',
  221. origin: 'auto.ui.browser.metrics',
  222. startTimestamp: fidMark.value,
  223. });
  224. // Delete mark.fid as we don't want it to be part of final payload
  225. delete _measurements['mark.fid'];
  226. }
  227. // If FCP is not recorded we should not record the cls value
  228. // according to the new definition of CLS.
  229. if (!('fcp' in _measurements)) {
  230. delete _measurements.cls;
  231. }
  232. Object.keys(_measurements).forEach(measurementName => {
  233. setMeasurement(measurementName, _measurements[measurementName].value, _measurements[measurementName].unit);
  234. });
  235. _tagMetricInfo(transaction);
  236. }
  237. _lcpEntry = undefined;
  238. _clsEntry = undefined;
  239. _measurements = {};
  240. }
  241. /** Create measure related spans */
  242. function _addMeasureSpans(
  243. transaction,
  244. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  245. entry,
  246. startTime,
  247. duration,
  248. timeOrigin,
  249. ) {
  250. const measureStartTimestamp = timeOrigin + startTime;
  251. const measureEndTimestamp = measureStartTimestamp + duration;
  252. _startChild(transaction, {
  253. description: entry.name ,
  254. endTimestamp: measureEndTimestamp,
  255. op: entry.entryType ,
  256. origin: 'auto.resource.browser.metrics',
  257. startTimestamp: measureStartTimestamp,
  258. });
  259. return measureStartTimestamp;
  260. }
  261. /** Instrument navigation entries */
  262. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  263. function _addNavigationSpans(transaction, entry, timeOrigin) {
  264. ['unloadEvent', 'redirect', 'domContentLoadedEvent', 'loadEvent', 'connect'].forEach(event => {
  265. _addPerformanceNavigationTiming(transaction, entry, event, timeOrigin);
  266. });
  267. _addPerformanceNavigationTiming(transaction, entry, 'secureConnection', timeOrigin, 'TLS/SSL', 'connectEnd');
  268. _addPerformanceNavigationTiming(transaction, entry, 'fetch', timeOrigin, 'cache', 'domainLookupStart');
  269. _addPerformanceNavigationTiming(transaction, entry, 'domainLookup', timeOrigin, 'DNS');
  270. _addRequest(transaction, entry, timeOrigin);
  271. }
  272. /** Create performance navigation related spans */
  273. function _addPerformanceNavigationTiming(
  274. transaction,
  275. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  276. entry,
  277. event,
  278. timeOrigin,
  279. description,
  280. eventEnd,
  281. ) {
  282. const end = eventEnd ? (entry[eventEnd] ) : (entry[`${event}End`] );
  283. const start = entry[`${event}Start`] ;
  284. if (!start || !end) {
  285. return;
  286. }
  287. _startChild(transaction, {
  288. op: 'browser',
  289. origin: 'auto.browser.browser.metrics',
  290. description: description || event,
  291. startTimestamp: timeOrigin + msToSec(start),
  292. endTimestamp: timeOrigin + msToSec(end),
  293. });
  294. }
  295. /** Create request and response related spans */
  296. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  297. function _addRequest(transaction, entry, timeOrigin) {
  298. if (entry.responseEnd) {
  299. // It is possible that we are collecting these metrics when the page hasn't finished loading yet, for example when the HTML slowly streams in.
  300. // In this case, ie. when the document request hasn't finished yet, `entry.responseEnd` will be 0.
  301. // In order not to produce faulty spans, where the end timestamp is before the start timestamp, we will only collect
  302. // these spans when the responseEnd value is available. The backend (Relay) would drop the entire transaction if it contained faulty spans.
  303. _startChild(transaction, {
  304. op: 'browser',
  305. origin: 'auto.browser.browser.metrics',
  306. description: 'request',
  307. startTimestamp: timeOrigin + msToSec(entry.requestStart ),
  308. endTimestamp: timeOrigin + msToSec(entry.responseEnd ),
  309. });
  310. _startChild(transaction, {
  311. op: 'browser',
  312. origin: 'auto.browser.browser.metrics',
  313. description: 'response',
  314. startTimestamp: timeOrigin + msToSec(entry.responseStart ),
  315. endTimestamp: timeOrigin + msToSec(entry.responseEnd ),
  316. });
  317. }
  318. }
  319. /** Create resource-related spans */
  320. function _addResourceSpans(
  321. transaction,
  322. entry,
  323. resourceUrl,
  324. startTime,
  325. duration,
  326. timeOrigin,
  327. ) {
  328. // we already instrument based on fetch and xhr, so we don't need to
  329. // duplicate spans here.
  330. if (entry.initiatorType === 'xmlhttprequest' || entry.initiatorType === 'fetch') {
  331. return;
  332. }
  333. const parsedUrl = parseUrl(resourceUrl);
  334. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  335. const data = {};
  336. setResourceEntrySizeData(data, entry, 'transferSize', 'http.response_transfer_size');
  337. setResourceEntrySizeData(data, entry, 'encodedBodySize', 'http.response_content_length');
  338. setResourceEntrySizeData(data, entry, 'decodedBodySize', 'http.decoded_response_content_length');
  339. if ('renderBlockingStatus' in entry) {
  340. data['resource.render_blocking_status'] = entry.renderBlockingStatus;
  341. }
  342. if (parsedUrl.protocol) {
  343. data['url.scheme'] = parsedUrl.protocol.split(':').pop(); // the protocol returned by parseUrl includes a :, but OTEL spec does not, so we remove it.
  344. }
  345. if (parsedUrl.host) {
  346. data['server.address'] = parsedUrl.host;
  347. }
  348. data['url.same_origin'] = resourceUrl.includes(WINDOW.location.origin);
  349. const startTimestamp = timeOrigin + startTime;
  350. const endTimestamp = startTimestamp + duration;
  351. _startChild(transaction, {
  352. description: resourceUrl.replace(WINDOW.location.origin, ''),
  353. endTimestamp,
  354. op: entry.initiatorType ? `resource.${entry.initiatorType}` : 'resource.other',
  355. origin: 'auto.resource.browser.metrics',
  356. startTimestamp,
  357. data,
  358. });
  359. }
  360. /**
  361. * Capture the information of the user agent.
  362. */
  363. function _trackNavigator(transaction) {
  364. const navigator = WINDOW.navigator ;
  365. if (!navigator) {
  366. return;
  367. }
  368. // track network connectivity
  369. const connection = navigator.connection;
  370. if (connection) {
  371. if (connection.effectiveType) {
  372. // TODO: Can we rewrite this to an attribute?
  373. // eslint-disable-next-line deprecation/deprecation
  374. transaction.setTag('effectiveConnectionType', connection.effectiveType);
  375. }
  376. if (connection.type) {
  377. // TODO: Can we rewrite this to an attribute?
  378. // eslint-disable-next-line deprecation/deprecation
  379. transaction.setTag('connectionType', connection.type);
  380. }
  381. if (isMeasurementValue(connection.rtt)) {
  382. _measurements['connection.rtt'] = { value: connection.rtt, unit: 'millisecond' };
  383. }
  384. }
  385. if (isMeasurementValue(navigator.deviceMemory)) {
  386. // TODO: Can we rewrite this to an attribute?
  387. // eslint-disable-next-line deprecation/deprecation
  388. transaction.setTag('deviceMemory', `${navigator.deviceMemory} GB`);
  389. }
  390. if (isMeasurementValue(navigator.hardwareConcurrency)) {
  391. // TODO: Can we rewrite this to an attribute?
  392. // eslint-disable-next-line deprecation/deprecation
  393. transaction.setTag('hardwareConcurrency', String(navigator.hardwareConcurrency));
  394. }
  395. }
  396. /** Add LCP / CLS data to transaction to allow debugging */
  397. function _tagMetricInfo(transaction) {
  398. if (_lcpEntry) {
  399. DEBUG_BUILD && logger.log('[Measurements] Adding LCP Data');
  400. // Capture Properties of the LCP element that contributes to the LCP.
  401. if (_lcpEntry.element) {
  402. // TODO: Can we rewrite this to an attribute?
  403. // eslint-disable-next-line deprecation/deprecation
  404. transaction.setTag('lcp.element', htmlTreeAsString(_lcpEntry.element));
  405. }
  406. if (_lcpEntry.id) {
  407. // TODO: Can we rewrite this to an attribute?
  408. // eslint-disable-next-line deprecation/deprecation
  409. transaction.setTag('lcp.id', _lcpEntry.id);
  410. }
  411. if (_lcpEntry.url) {
  412. // Trim URL to the first 200 characters.
  413. // TODO: Can we rewrite this to an attribute?
  414. // eslint-disable-next-line deprecation/deprecation
  415. transaction.setTag('lcp.url', _lcpEntry.url.trim().slice(0, 200));
  416. }
  417. // TODO: Can we rewrite this to an attribute?
  418. // eslint-disable-next-line deprecation/deprecation
  419. transaction.setTag('lcp.size', _lcpEntry.size);
  420. }
  421. // See: https://developer.mozilla.org/en-US/docs/Web/API/LayoutShift
  422. if (_clsEntry && _clsEntry.sources) {
  423. DEBUG_BUILD && logger.log('[Measurements] Adding CLS Data');
  424. _clsEntry.sources.forEach((source, index) =>
  425. // TODO: Can we rewrite this to an attribute?
  426. // eslint-disable-next-line deprecation/deprecation
  427. transaction.setTag(`cls.source.${index + 1}`, htmlTreeAsString(source.node)),
  428. );
  429. }
  430. }
  431. function setResourceEntrySizeData(
  432. data,
  433. entry,
  434. key,
  435. dataKey,
  436. ) {
  437. const entryVal = entry[key];
  438. if (entryVal != null && entryVal < MAX_INT_AS_BYTES) {
  439. data[dataKey] = entryVal;
  440. }
  441. }
  442. /**
  443. * Add ttfb information to measurements
  444. *
  445. * Exported for tests
  446. */
  447. function _addTtfbToMeasurements(
  448. _measurements,
  449. responseStartTimestamp,
  450. requestStartTimestamp,
  451. transactionStartTime,
  452. ) {
  453. // Generate TTFB (Time to First Byte), which measured as the time between the beginning of the transaction and the
  454. // start of the response in milliseconds
  455. if (typeof responseStartTimestamp === 'number' && transactionStartTime) {
  456. DEBUG_BUILD && logger.log('[Measurements] Adding TTFB');
  457. _measurements['ttfb'] = {
  458. // As per https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStart,
  459. // responseStart can be 0 if the request is coming straight from the cache.
  460. // This might lead us to calculate a negative ttfb if we don't use Math.max here.
  461. //
  462. // This logic is the same as what is in the web-vitals library to calculate ttfb
  463. // https://github.com/GoogleChrome/web-vitals/blob/2301de5015e82b09925238a228a0893635854587/src/onTTFB.ts#L92
  464. // TODO(abhi): We should use the web-vitals library instead of this custom calculation.
  465. value: Math.max(responseStartTimestamp - transactionStartTime, 0) * 1000,
  466. unit: 'millisecond',
  467. };
  468. if (typeof requestStartTimestamp === 'number' && requestStartTimestamp <= responseStartTimestamp) {
  469. // Capture the time spent making the request and receiving the first byte of the response.
  470. // This is the time between the start of the request and the start of the response in milliseconds.
  471. _measurements['ttfb.requestTime'] = {
  472. value: (responseStartTimestamp - requestStartTimestamp) * 1000,
  473. unit: 'millisecond',
  474. };
  475. }
  476. }
  477. }
  478. export { _addMeasureSpans, _addResourceSpans, _addTtfbToMeasurements, addPerformanceEntries, startTrackingInteractions, startTrackingLongTasks, startTrackingWebVitals };
  479. //# sourceMappingURL=index.js.map