performanceForSentry.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. import {Fragment, Profiler, ReactNode, useEffect, useRef} from 'react';
  2. import {captureException, captureMessage} from '@sentry/react';
  3. import * as Sentry from '@sentry/react';
  4. import {IdleTransaction} from '@sentry/tracing';
  5. import {Transaction} from '@sentry/types';
  6. import {browserPerformanceTimeOrigin, timestampWithMs} from '@sentry/utils';
  7. import getCurrentSentryReactTransaction from './getCurrentSentryReactTransaction';
  8. const MIN_UPDATE_SPAN_TIME = 16; // Frame boundary @ 60fps
  9. 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.
  10. const INTERACTION_TIMEOUT = 2 * 60_000; // 2min. Wrap interactions up after this time since we don't want transactions sticking around forever.
  11. /**
  12. * It depends on where it is called but the way we fetch transactions can be empty despite an ongoing transaction existing.
  13. * This will return an interaction-type transaction held onto by a class static if one exists.
  14. */
  15. export function getPerformanceTransaction(): IdleTransaction | Transaction | undefined {
  16. return PerformanceInteraction.getTransaction() ?? getCurrentSentryReactTransaction();
  17. }
  18. /**
  19. * Callback for React Profiler https://reactjs.org/docs/profiler.html
  20. */
  21. export function onRenderCallback(
  22. id: string,
  23. phase: 'mount' | 'update',
  24. actualDuration: number
  25. ) {
  26. try {
  27. const transaction: Transaction | undefined = getPerformanceTransaction();
  28. if (transaction && actualDuration > MIN_UPDATE_SPAN_TIME) {
  29. const now = timestampWithMs();
  30. transaction.startChild({
  31. description: `<${id}>`,
  32. op: `ui.react.${phase}`,
  33. startTimestamp: now - actualDuration / 1000,
  34. endTimestamp: now,
  35. });
  36. }
  37. } catch (_) {
  38. // Add defensive catch since this wraps all of App
  39. }
  40. }
  41. export class PerformanceInteraction {
  42. private static interactionTransaction: Transaction | null = null;
  43. private static interactionTimeoutId: number | undefined = undefined;
  44. static getTransaction() {
  45. return PerformanceInteraction.interactionTransaction;
  46. }
  47. static async startInteraction(
  48. name: string,
  49. timeout = INTERACTION_TIMEOUT,
  50. immediate = true
  51. ) {
  52. try {
  53. const currentIdleTransaction = getCurrentSentryReactTransaction();
  54. if (currentIdleTransaction) {
  55. // If interaction is started while idle still exists.
  56. LongTaskObserver.setLongTaskTags(currentIdleTransaction);
  57. currentIdleTransaction.setTag('finishReason', 'sentry.interactionStarted'); // Override finish reason so we can capture if this has effects on idle timeout.
  58. currentIdleTransaction.finish();
  59. }
  60. PerformanceInteraction.finishInteraction(immediate);
  61. const txn = Sentry?.startTransaction({
  62. name: `ui.${name}`,
  63. op: 'interaction',
  64. });
  65. PerformanceInteraction.interactionTransaction = txn;
  66. // Auto interaction timeout
  67. PerformanceInteraction.interactionTimeoutId = window.setTimeout(() => {
  68. if (!PerformanceInteraction.interactionTransaction) {
  69. return;
  70. }
  71. PerformanceInteraction.interactionTransaction.setTag(
  72. 'ui.interaction.finish',
  73. 'timeout'
  74. );
  75. PerformanceInteraction.finishInteraction(true);
  76. }, timeout);
  77. } catch (e) {
  78. captureMessage(e);
  79. }
  80. }
  81. static async finishInteraction(immediate = false) {
  82. try {
  83. if (!PerformanceInteraction.interactionTransaction) {
  84. return;
  85. }
  86. clearTimeout(PerformanceInteraction.interactionTimeoutId);
  87. LongTaskObserver.setLongTaskTags(PerformanceInteraction.interactionTransaction);
  88. if (immediate) {
  89. PerformanceInteraction.interactionTransaction?.finish();
  90. PerformanceInteraction.interactionTransaction = null;
  91. return;
  92. }
  93. // Add a slight wait if this isn't called as the result of another transaction starting.
  94. await new Promise(resolve => setTimeout(resolve, WAIT_POST_INTERACTION));
  95. PerformanceInteraction.interactionTransaction?.finish();
  96. PerformanceInteraction.interactionTransaction = null;
  97. return;
  98. } catch (e) {
  99. captureMessage(e);
  100. }
  101. }
  102. }
  103. class LongTaskObserver {
  104. private static observer: PerformanceObserver;
  105. private static longTaskCount = 0;
  106. private static lastTransaction: IdleTransaction | Transaction | undefined;
  107. private static currentId: string;
  108. static setLongTaskTags(t: IdleTransaction | Transaction) {
  109. t.setTag('ui.longTaskCount', LongTaskObserver.longTaskCount);
  110. const group =
  111. [
  112. 1, 2, 5, 10, 25, 50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 800, 900, 1001,
  113. ].find(n => LongTaskObserver.longTaskCount <= n) || -1;
  114. t.setTag('ui.longTaskCount.grouped', group < 1001 ? `<=${group}` : `>1000`);
  115. }
  116. static getPerformanceObserver(id: string): PerformanceObserver | null {
  117. try {
  118. LongTaskObserver.currentId = id;
  119. if (LongTaskObserver.observer) {
  120. LongTaskObserver.observer.disconnect();
  121. try {
  122. LongTaskObserver.observer.observe({entryTypes: ['longtask']});
  123. } catch (_) {
  124. // Safari doesn't support longtask, ignore this error.
  125. }
  126. return LongTaskObserver.observer;
  127. }
  128. if (!window.PerformanceObserver || !browserPerformanceTimeOrigin) {
  129. return null;
  130. }
  131. const timeOrigin = browserPerformanceTimeOrigin / 1000;
  132. const observer = new PerformanceObserver(function (list) {
  133. try {
  134. const transaction = getPerformanceTransaction();
  135. const perfEntries = list.getEntries();
  136. if (!transaction) {
  137. return;
  138. }
  139. if (transaction !== LongTaskObserver.lastTransaction) {
  140. // If long tasks observer is active and is called while the transaction has changed.
  141. if (LongTaskObserver.lastTransaction) {
  142. LongTaskObserver.setLongTaskTags(LongTaskObserver.lastTransaction);
  143. }
  144. LongTaskObserver.longTaskCount = 0;
  145. LongTaskObserver.lastTransaction = transaction;
  146. }
  147. perfEntries.forEach(entry => {
  148. const startSeconds = timeOrigin + entry.startTime / 1000;
  149. LongTaskObserver.longTaskCount++;
  150. transaction.startChild({
  151. description: `Long Task - ${LongTaskObserver.currentId}`,
  152. op: `ui.sentry.long-task`,
  153. startTimestamp: startSeconds,
  154. endTimestamp: startSeconds + entry.duration / 1000,
  155. });
  156. });
  157. LongTaskObserver.setLongTaskTags(transaction);
  158. } catch (_) {
  159. // Defensive catch.
  160. }
  161. });
  162. if (!observer || !observer.observe) {
  163. return null;
  164. }
  165. LongTaskObserver.observer = observer;
  166. try {
  167. LongTaskObserver.observer.observe({entryTypes: ['longtask']});
  168. } catch (_) {
  169. // Safari doesn't support longtask, ignore this error.
  170. }
  171. return LongTaskObserver.observer;
  172. } catch (e) {
  173. captureException(e);
  174. // Defensive try catch.
  175. }
  176. return null;
  177. }
  178. }
  179. export const ProfilerWithTasks = ({id, children}: {children: ReactNode; id: string}) => {
  180. useEffect(() => {
  181. let observer;
  182. try {
  183. if (!window.PerformanceObserver || !browserPerformanceTimeOrigin) {
  184. return () => {};
  185. }
  186. observer = LongTaskObserver.getPerformanceObserver(id);
  187. } catch (e) {
  188. captureException(e);
  189. // Defensive since this is auxiliary code.
  190. }
  191. return () => {
  192. if (observer && observer.disconnect) {
  193. observer.disconnect();
  194. }
  195. };
  196. }, []);
  197. return (
  198. <Profiler id={id} onRender={onRenderCallback}>
  199. {children}
  200. </Profiler>
  201. );
  202. };
  203. export const VisuallyCompleteWithData = ({
  204. id,
  205. hasData,
  206. children,
  207. }: {
  208. children: ReactNode;
  209. hasData: boolean;
  210. id: string;
  211. }) => {
  212. const isVisuallyCompleteSet = useRef(false);
  213. const isDataCompleteSet = useRef(false);
  214. const longTaskCount = useRef(0);
  215. useEffect(() => {
  216. let observer;
  217. try {
  218. if (!window.PerformanceObserver || !browserPerformanceTimeOrigin) {
  219. return () => {};
  220. }
  221. observer = LongTaskObserver.getPerformanceObserver(id);
  222. } catch (_) {
  223. // Defensive since this is auxiliary code.
  224. }
  225. return () => {
  226. if (observer && observer.disconnect) {
  227. observer.disconnect();
  228. }
  229. };
  230. }, []);
  231. const num = useRef(1);
  232. const isVCDSet = useRef(false);
  233. if (isVCDSet && hasData && performance && performance.mark) {
  234. performance.mark(`${id}-vcsd-start`);
  235. isVCDSet.current = true;
  236. }
  237. useEffect(() => {
  238. try {
  239. const transaction: any = getCurrentSentryReactTransaction(); // Using any to override types for private api.
  240. if (!transaction) {
  241. return;
  242. }
  243. if (!isVisuallyCompleteSet.current) {
  244. const time = performance.now();
  245. transaction.registerBeforeFinishCallback((t, _) => {
  246. // Should be called after performance entries finish callback.
  247. t.setMeasurements({
  248. ...t._measurements,
  249. visuallyComplete: {value: time},
  250. });
  251. });
  252. isVisuallyCompleteSet.current = true;
  253. }
  254. if (!isDataCompleteSet.current && hasData) {
  255. isDataCompleteSet.current = true;
  256. performance.mark(`${id}-vcsd-end-pre-timeout`);
  257. window.setTimeout(() => {
  258. if (!browserPerformanceTimeOrigin) {
  259. return;
  260. }
  261. performance.mark(`${id}-vcsd-end`);
  262. const measureName = `VCD [${id}] #${num.current}`;
  263. performance.measure(
  264. `VCD [${id}] #${num.current}`,
  265. `${id}-vcsd-start`,
  266. `${id}-vcsd-end`
  267. );
  268. num.current = num.current++;
  269. const [measureEntry] = performance.getEntriesByName(measureName);
  270. if (!measureEntry) {
  271. return;
  272. }
  273. transaction.registerBeforeFinishCallback(t => {
  274. if (!browserPerformanceTimeOrigin) {
  275. return;
  276. }
  277. // Should be called after performance entries finish callback.
  278. const lcp = t._measurements.lcp?.value;
  279. // Adjust to be relative to transaction.startTimestamp
  280. const entryStartSeconds =
  281. browserPerformanceTimeOrigin / 1000 + measureEntry.startTime / 1000;
  282. const time = (entryStartSeconds - transaction.startTimestamp) * 1000;
  283. const newMeasurements = {
  284. ...t._measurements,
  285. visuallyCompleteData: {value: time},
  286. };
  287. if (lcp) {
  288. newMeasurements.lcpDiffVCD = {value: lcp - time};
  289. }
  290. t.setTag('longTaskCount', longTaskCount.current);
  291. t.setMeasurements(newMeasurements);
  292. });
  293. }, 0);
  294. }
  295. } catch (_) {
  296. // Defensive catch since this code is auxiliary.
  297. }
  298. }, [hasData]);
  299. return (
  300. <Profiler id={id} onRender={onRenderCallback}>
  301. <Fragment>{children}</Fragment>
  302. </Profiler>
  303. );
  304. };