performanceForSentry.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. import {Fragment, Profiler, ReactNode, useEffect, useRef} from 'react';
  2. import {captureException, captureMessage, setExtra, setTag} from '@sentry/react';
  3. import * as Sentry from '@sentry/react';
  4. import {IdleTransaction} from '@sentry/tracing';
  5. import {Transaction, TransactionEvent} from '@sentry/types';
  6. import {
  7. _browserPerformanceTimeOriginMode,
  8. browserPerformanceTimeOrigin,
  9. timestampWithMs,
  10. } from '@sentry/utils';
  11. import getCurrentSentryReactTransaction from './getCurrentSentryReactTransaction';
  12. const MIN_UPDATE_SPAN_TIME = 16; // Frame boundary @ 60fps
  13. const WAIT_POST_INTERACTION = 50; // Leave a small amount of time for observers and onRenderCallback to log since they come in after they occur and not during.
  14. const INTERACTION_TIMEOUT = 2 * 60_000; // 2min. Wrap interactions up after this time since we don't want transactions sticking around forever.
  15. /**
  16. * It depends on where it is called but the way we fetch transactions can be empty despite an ongoing transaction existing.
  17. * This will return an interaction-type transaction held onto by a class static if one exists.
  18. */
  19. export function getPerformanceTransaction(): IdleTransaction | Transaction | undefined {
  20. return PerformanceInteraction.getTransaction() ?? getCurrentSentryReactTransaction();
  21. }
  22. /**
  23. * Callback for React Profiler https://reactjs.org/docs/profiler.html
  24. */
  25. export function onRenderCallback(
  26. id: string,
  27. phase: 'mount' | 'update',
  28. actualDuration: number
  29. ) {
  30. try {
  31. const transaction: Transaction | undefined = getPerformanceTransaction();
  32. if (transaction && actualDuration > MIN_UPDATE_SPAN_TIME) {
  33. const now = timestampWithMs();
  34. transaction.startChild({
  35. description: `<${id}>`,
  36. op: `ui.react.${phase}`,
  37. startTimestamp: now - actualDuration / 1000,
  38. endTimestamp: now,
  39. });
  40. }
  41. } catch (_) {
  42. // Add defensive catch since this wraps all of App
  43. }
  44. }
  45. export class PerformanceInteraction {
  46. private static interactionTransaction: Transaction | null = null;
  47. private static interactionTimeoutId: number | undefined = undefined;
  48. static getTransaction() {
  49. return PerformanceInteraction.interactionTransaction;
  50. }
  51. static startInteraction(name: string, timeout = INTERACTION_TIMEOUT, immediate = true) {
  52. try {
  53. const currentIdleTransaction = getCurrentSentryReactTransaction();
  54. if (currentIdleTransaction) {
  55. // If interaction is started while idle still exists.
  56. currentIdleTransaction.setTag('finishReason', 'sentry.interactionStarted'); // Override finish reason so we can capture if this has effects on idle timeout.
  57. currentIdleTransaction.finish();
  58. }
  59. PerformanceInteraction.finishInteraction(immediate);
  60. const txn = Sentry?.startTransaction({
  61. name: `ui.${name}`,
  62. op: 'interaction',
  63. });
  64. PerformanceInteraction.interactionTransaction = txn;
  65. // Auto interaction timeout
  66. PerformanceInteraction.interactionTimeoutId = window.setTimeout(() => {
  67. if (!PerformanceInteraction.interactionTransaction) {
  68. return;
  69. }
  70. PerformanceInteraction.interactionTransaction.setTag(
  71. 'ui.interaction.finish',
  72. 'timeout'
  73. );
  74. PerformanceInteraction.finishInteraction(true);
  75. }, timeout);
  76. } catch (e) {
  77. captureMessage(e);
  78. }
  79. }
  80. static async finishInteraction(immediate = false) {
  81. try {
  82. if (!PerformanceInteraction.interactionTransaction) {
  83. return;
  84. }
  85. clearTimeout(PerformanceInteraction.interactionTimeoutId);
  86. if (immediate) {
  87. PerformanceInteraction.interactionTransaction?.finish();
  88. PerformanceInteraction.interactionTransaction = null;
  89. return;
  90. }
  91. // Add a slight wait if this isn't called as the result of another transaction starting.
  92. await new Promise(resolve => setTimeout(resolve, WAIT_POST_INTERACTION));
  93. PerformanceInteraction.interactionTransaction?.finish();
  94. PerformanceInteraction.interactionTransaction = null;
  95. return;
  96. } catch (e) {
  97. captureMessage(e);
  98. }
  99. }
  100. }
  101. export class LongTaskObserver {
  102. private static observer: PerformanceObserver;
  103. private static longTaskCount = 0;
  104. private static longTaskDuration = 0;
  105. private static lastTransaction: IdleTransaction | Transaction | undefined;
  106. static setLongTaskData(t: IdleTransaction | Transaction) {
  107. const group =
  108. [
  109. 1, 2, 5, 10, 25, 50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 800, 900, 1001,
  110. ].find(n => LongTaskObserver.longTaskCount <= n) || -1;
  111. t.setTag('ui.longTaskCount.grouped', group < 1001 ? `<=${group}` : `>1000`);
  112. t.setMeasurement('longTaskCount', LongTaskObserver.longTaskCount, '');
  113. t.setMeasurement('longTaskDuration', LongTaskObserver.longTaskDuration, '');
  114. }
  115. static startPerformanceObserver(): PerformanceObserver | null {
  116. try {
  117. if (LongTaskObserver.observer) {
  118. LongTaskObserver.observer.disconnect();
  119. try {
  120. LongTaskObserver.observer.observe({entryTypes: ['longtask']});
  121. } catch (_) {
  122. // Safari doesn't support longtask, ignore this error.
  123. }
  124. return LongTaskObserver.observer;
  125. }
  126. if (!window.PerformanceObserver || !browserPerformanceTimeOrigin) {
  127. return null;
  128. }
  129. const observer = new PerformanceObserver(function () {
  130. try {
  131. const transaction = getPerformanceTransaction();
  132. if (!transaction) {
  133. return;
  134. }
  135. if (transaction !== LongTaskObserver.lastTransaction) {
  136. // If long tasks observer is active and is called while the transaction has changed.
  137. if (LongTaskObserver.lastTransaction) {
  138. LongTaskObserver.setLongTaskData(LongTaskObserver.lastTransaction);
  139. }
  140. LongTaskObserver.longTaskCount = 0;
  141. LongTaskObserver.longTaskDuration = 0;
  142. LongTaskObserver.lastTransaction = transaction;
  143. }
  144. LongTaskObserver.setLongTaskData(transaction);
  145. } catch (_) {
  146. // Defensive catch.
  147. }
  148. });
  149. if (!observer || !observer.observe) {
  150. return null;
  151. }
  152. LongTaskObserver.observer = observer;
  153. try {
  154. LongTaskObserver.observer.observe({entryTypes: ['longtask']});
  155. } catch (_) {
  156. // Safari doesn't support longtask, ignore this error.
  157. }
  158. return LongTaskObserver.observer;
  159. } catch (e) {
  160. captureException(e);
  161. // Defensive try catch.
  162. }
  163. return null;
  164. }
  165. }
  166. export const CustomerProfiler = ({id, children}: {children: ReactNode; id: string}) => {
  167. return (
  168. <Profiler id={id} onRender={onRenderCallback}>
  169. {children}
  170. </Profiler>
  171. );
  172. };
  173. /**
  174. * This component wraps the main component on a page with a measurement checking for visual completedness.
  175. * It uses the data check to make sure endpoints have resolved and the component is meaningfully rendering
  176. * which sets it apart from simply checking LCP, which makes it a good back up check the LCP heuristic performance.
  177. *
  178. * Since this component is guaranteed to be part of the -real- critical path, it also wraps the component with the custom profiler.
  179. */
  180. export const VisuallyCompleteWithData = ({
  181. id,
  182. hasData,
  183. children,
  184. }: {
  185. children: ReactNode;
  186. hasData: boolean;
  187. id: string;
  188. }) => {
  189. const isDataCompleteSet = useRef(false);
  190. const longTaskCount = useRef(0);
  191. useEffect(() => {
  192. let observer;
  193. try {
  194. if (!window.PerformanceObserver || !browserPerformanceTimeOrigin) {
  195. return () => {};
  196. }
  197. observer = LongTaskObserver.startPerformanceObserver();
  198. } catch (_) {
  199. // Defensive since this is auxiliary code.
  200. }
  201. return () => {
  202. if (observer && observer.disconnect) {
  203. observer.disconnect();
  204. }
  205. };
  206. }, []);
  207. const num = useRef(1);
  208. const isVCDSet = useRef(false);
  209. if (isVCDSet && hasData && performance && performance.mark) {
  210. performance.mark(`${id}-vcsd-start`);
  211. isVCDSet.current = true;
  212. }
  213. useEffect(() => {
  214. try {
  215. const transaction: any = getCurrentSentryReactTransaction(); // Using any to override types for private api.
  216. if (!transaction) {
  217. return;
  218. }
  219. if (!isDataCompleteSet.current && hasData) {
  220. isDataCompleteSet.current = true;
  221. performance.mark(`${id}-vcsd-end-pre-timeout`);
  222. window.setTimeout(() => {
  223. if (!browserPerformanceTimeOrigin) {
  224. return;
  225. }
  226. performance.mark(`${id}-vcsd-end`);
  227. const measureName = `VCD [${id}] #${num.current}`;
  228. performance.measure(
  229. `VCD [${id}] #${num.current}`,
  230. `${id}-vcsd-start`,
  231. `${id}-vcsd-end`
  232. );
  233. num.current = num.current++;
  234. const entries = performance.getEntriesByName(measureName);
  235. const [measureEntry] = entries;
  236. if (!measureEntry) {
  237. return;
  238. }
  239. transaction.registerBeforeFinishCallback((t: Transaction) => {
  240. if (!browserPerformanceTimeOrigin) {
  241. return;
  242. }
  243. // Should be called after performance entries finish callback.
  244. const lcp = (t as any)._measurements.lcp?.value;
  245. // Adjust to be relative to transaction.startTimestamp
  246. const entryStartSeconds =
  247. browserPerformanceTimeOrigin / 1000 + measureEntry.startTime / 1000;
  248. const time = (entryStartSeconds - transaction.startTimestamp) * 1000;
  249. if (lcp) {
  250. t.setMeasurement('lcpDiffVCD', lcp - time, 'millisecond');
  251. }
  252. t.setTag('singlePerfEntry', entries.length === 1);
  253. t.setTag('longTaskCount', longTaskCount.current);
  254. t.setTag('browserOriginMode', _browserPerformanceTimeOriginMode);
  255. t.setMeasurement('visuallyCompleteData', time, 'millisecond');
  256. });
  257. }, 0);
  258. }
  259. } catch (_) {
  260. // Defensive catch since this code is auxiliary.
  261. }
  262. }, [hasData, id]);
  263. return (
  264. <Profiler id={id} onRender={onRenderCallback}>
  265. <Fragment>{children}</Fragment>
  266. </Profiler>
  267. );
  268. };
  269. interface OpAssetMeasurementDefinition {
  270. key: string;
  271. }
  272. const OP_ASSET_MEASUREMENT_MAP: Record<string, OpAssetMeasurementDefinition> = {
  273. 'resource.script': {
  274. key: 'script',
  275. },
  276. };
  277. const ASSET_MEASUREMENT_ALL = 'allResources';
  278. const SENTRY_ASSET_DOMAINS = ['sentry-cdn.com'];
  279. const measureAssetsOnTransaction = (transaction: TransactionEvent) => {
  280. const spans = transaction.spans;
  281. if (!spans) {
  282. return;
  283. }
  284. let allTransfered = 0;
  285. let allEncoded = 0;
  286. let allCount = 0;
  287. let hasAssetTimings = false;
  288. for (const [op, _] of Object.entries(OP_ASSET_MEASUREMENT_MAP)) {
  289. const filtered = spans.filter(
  290. s =>
  291. s.op === op &&
  292. SENTRY_ASSET_DOMAINS.every(
  293. domain => !s.description || s.description.includes(domain)
  294. )
  295. );
  296. const count = filtered.length;
  297. const transfered = filtered.reduce(
  298. (acc, curr) => acc + (curr.data['Transfer Size'] ?? 0),
  299. 0
  300. );
  301. const encoded = filtered.reduce(
  302. (acc, curr) => acc + (curr.data['Encoded Body Size'] ?? 0),
  303. 0
  304. );
  305. if (encoded > 0) {
  306. hasAssetTimings = true;
  307. }
  308. allCount += count;
  309. allTransfered += transfered;
  310. allEncoded += encoded;
  311. }
  312. if (!transaction.measurements || !transaction.tags) {
  313. return;
  314. }
  315. transaction.measurements[`${ASSET_MEASUREMENT_ALL}.encoded`] = {
  316. value: allEncoded,
  317. unit: 'byte',
  318. };
  319. transaction.measurements[`${ASSET_MEASUREMENT_ALL}.transfer`] = {
  320. value: allTransfered,
  321. unit: 'byte',
  322. };
  323. transaction.measurements[`${ASSET_MEASUREMENT_ALL}.count`] = {
  324. value: allCount,
  325. unit: 'none',
  326. };
  327. transaction.tags.hasAnyAssetTimings = hasAssetTimings;
  328. };
  329. const additionalMeasurements = (transaction: TransactionEvent) => {
  330. if (
  331. !transaction.measurements ||
  332. !browserPerformanceTimeOrigin ||
  333. !transaction.start_timestamp
  334. ) {
  335. return;
  336. }
  337. const ttfb = Object.entries(transaction.measurements).find(([key]) =>
  338. key.toLowerCase().includes('ttfb')
  339. );
  340. if (!ttfb || !ttfb[1]) {
  341. return;
  342. }
  343. const headMark = performance.getEntriesByName('head-start')[0];
  344. if (!headMark) {
  345. return;
  346. }
  347. const ttfbValue = ttfb[1].value;
  348. const entryStartSeconds =
  349. browserPerformanceTimeOrigin / 1000 + headMark.startTime / 1000;
  350. const time = (entryStartSeconds - transaction.start_timestamp) * 1000 - ttfbValue;
  351. transaction.measurements.pre_bundle_load = {
  352. value: time,
  353. unit: 'millisecond',
  354. };
  355. };
  356. export const addExtraMeasurements = (transaction: TransactionEvent) => {
  357. try {
  358. measureAssetsOnTransaction(transaction);
  359. additionalMeasurements(transaction);
  360. } catch (_) {
  361. // Defensive catch since this code is auxiliary.
  362. }
  363. };
  364. /**
  365. * A util function to help create some broad buckets to group entity counts without exploding cardinality.
  366. *
  367. * @param tagName - Name for the tag, will create `<tagName>` in data and `<tagname>.grouped` as a tag
  368. * @param max - The approximate maximum value for the tag, A bucket between max and Infinity is also captured so it's fine if it's not precise, the data won't be entirely lost.
  369. * @param n - The value to be grouped, should represent `n` entities.
  370. * @param [buckets=[1,2,5]] - An optional param to specify the bucket progression. Default is 1,2,5 (10,20,50 etc).
  371. */
  372. export const setGroupedEntityTag = (
  373. tagName: string,
  374. max: number,
  375. n: number,
  376. buckets = [1, 2, 5]
  377. ) => {
  378. setExtra(tagName, n);
  379. let groups = [0];
  380. loop: for (let m = 1, mag = 0; m <= max; m *= 10, mag++) {
  381. for (const i of buckets) {
  382. const group = i * 10 ** mag;
  383. if (group > max) {
  384. break loop;
  385. }
  386. groups = [...groups, group];
  387. }
  388. }
  389. groups = [...groups, +Infinity];
  390. setTag(`${tagName}.grouped`, `<=${groups.find(g => n <= g)}`);
  391. };