performanceForSentry.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  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 startInteraction(name: string, timeout = INTERACTION_TIMEOUT, immediate = true) {
  48. try {
  49. const currentIdleTransaction = getCurrentSentryReactTransaction();
  50. if (currentIdleTransaction) {
  51. // If interaction is started while idle still exists.
  52. currentIdleTransaction.setTag('finishReason', 'sentry.interactionStarted'); // Override finish reason so we can capture if this has effects on idle timeout.
  53. currentIdleTransaction.finish();
  54. }
  55. PerformanceInteraction.finishInteraction(immediate);
  56. const txn = Sentry?.startTransaction({
  57. name: `ui.${name}`,
  58. op: 'interaction',
  59. });
  60. PerformanceInteraction.interactionTransaction = txn;
  61. // Auto interaction timeout
  62. PerformanceInteraction.interactionTimeoutId = window.setTimeout(() => {
  63. if (!PerformanceInteraction.interactionTransaction) {
  64. return;
  65. }
  66. PerformanceInteraction.interactionTransaction.setTag(
  67. 'ui.interaction.finish',
  68. 'timeout'
  69. );
  70. PerformanceInteraction.finishInteraction(true);
  71. }, timeout);
  72. } catch (e) {
  73. captureMessage(e);
  74. }
  75. }
  76. static async finishInteraction(immediate = false) {
  77. try {
  78. if (!PerformanceInteraction.interactionTransaction) {
  79. return;
  80. }
  81. clearTimeout(PerformanceInteraction.interactionTimeoutId);
  82. if (immediate) {
  83. PerformanceInteraction.interactionTransaction?.finish();
  84. PerformanceInteraction.interactionTransaction = null;
  85. return;
  86. }
  87. // Add a slight wait if this isn't called as the result of another transaction starting.
  88. await new Promise(resolve => setTimeout(resolve, WAIT_POST_INTERACTION));
  89. PerformanceInteraction.interactionTransaction?.finish();
  90. PerformanceInteraction.interactionTransaction = null;
  91. return;
  92. } catch (e) {
  93. captureMessage(e);
  94. }
  95. }
  96. }
  97. export class LongTaskObserver {
  98. private static observer: PerformanceObserver;
  99. private static longTaskCount = 0;
  100. private static longTaskDuration = 0;
  101. private static lastTransaction: IdleTransaction | Transaction | undefined;
  102. static setLongTaskData(t: IdleTransaction | Transaction) {
  103. const group =
  104. [
  105. 1, 2, 5, 10, 25, 50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 800, 900, 1001,
  106. ].find(n => LongTaskObserver.longTaskCount <= n) || -1;
  107. t.setTag('ui.longTaskCount.grouped', group < 1001 ? `<=${group}` : `>1000`);
  108. t.setMeasurement('longTaskCount', LongTaskObserver.longTaskCount, '');
  109. t.setMeasurement('longTaskDuration', LongTaskObserver.longTaskDuration, '');
  110. }
  111. static startPerformanceObserver(): PerformanceObserver | null {
  112. try {
  113. if (LongTaskObserver.observer) {
  114. LongTaskObserver.observer.disconnect();
  115. try {
  116. LongTaskObserver.observer.observe({entryTypes: ['longtask']});
  117. } catch (_) {
  118. // Safari doesn't support longtask, ignore this error.
  119. }
  120. return LongTaskObserver.observer;
  121. }
  122. if (!window.PerformanceObserver || !browserPerformanceTimeOrigin) {
  123. return null;
  124. }
  125. const timeOrigin = browserPerformanceTimeOrigin / 1000;
  126. const observer = new PerformanceObserver(function (list) {
  127. try {
  128. const transaction = getPerformanceTransaction();
  129. const perfEntries = list.getEntries();
  130. if (!transaction) {
  131. return;
  132. }
  133. if (transaction !== LongTaskObserver.lastTransaction) {
  134. // If long tasks observer is active and is called while the transaction has changed.
  135. if (LongTaskObserver.lastTransaction) {
  136. LongTaskObserver.setLongTaskData(LongTaskObserver.lastTransaction);
  137. }
  138. LongTaskObserver.longTaskCount = 0;
  139. LongTaskObserver.longTaskDuration = 0;
  140. LongTaskObserver.lastTransaction = transaction;
  141. }
  142. perfEntries.forEach(entry => {
  143. const startSeconds = timeOrigin + entry.startTime / 1000;
  144. LongTaskObserver.longTaskCount++;
  145. LongTaskObserver.longTaskDuration += entry.duration;
  146. transaction.startChild({
  147. description: `Long Task`,
  148. op: `ui.sentry.long-task`,
  149. startTimestamp: startSeconds,
  150. endTimestamp: startSeconds + entry.duration / 1000,
  151. });
  152. });
  153. LongTaskObserver.setLongTaskData(transaction);
  154. } catch (_) {
  155. // Defensive catch.
  156. }
  157. });
  158. if (!observer || !observer.observe) {
  159. return null;
  160. }
  161. LongTaskObserver.observer = observer;
  162. try {
  163. LongTaskObserver.observer.observe({entryTypes: ['longtask']});
  164. } catch (_) {
  165. // Safari doesn't support longtask, ignore this error.
  166. }
  167. return LongTaskObserver.observer;
  168. } catch (e) {
  169. captureException(e);
  170. // Defensive try catch.
  171. }
  172. return null;
  173. }
  174. }
  175. export const CustomerProfiler = ({id, children}: {children: ReactNode; id: string}) => {
  176. return (
  177. <Profiler id={id} onRender={onRenderCallback}>
  178. {children}
  179. </Profiler>
  180. );
  181. };
  182. /**
  183. * This component wraps the main component on a page with a measurement checking for visual completedness.
  184. * It uses the data check to make sure endpoints have resolved and the component is meaningfully rendering
  185. * which sets it apart from simply checking LCP, which makes it a good back up check the LCP heuristic performance.
  186. *
  187. * Since this component is guaranteed to be part of the -real- critical path, it also wraps the component with the custom profiler.
  188. */
  189. export const VisuallyCompleteWithData = ({
  190. id,
  191. hasData,
  192. children,
  193. }: {
  194. children: ReactNode;
  195. hasData: boolean;
  196. id: string;
  197. }) => {
  198. const isVisuallyCompleteSet = useRef(false);
  199. const isDataCompleteSet = useRef(false);
  200. const longTaskCount = useRef(0);
  201. useEffect(() => {
  202. let observer;
  203. try {
  204. if (!window.PerformanceObserver || !browserPerformanceTimeOrigin) {
  205. return () => {};
  206. }
  207. observer = LongTaskObserver.startPerformanceObserver();
  208. } catch (_) {
  209. // Defensive since this is auxiliary code.
  210. }
  211. return () => {
  212. if (observer && observer.disconnect) {
  213. observer.disconnect();
  214. }
  215. };
  216. }, []);
  217. const num = useRef(1);
  218. const isVCDSet = useRef(false);
  219. if (isVCDSet && hasData && performance && performance.mark) {
  220. performance.mark(`${id}-vcsd-start`);
  221. isVCDSet.current = true;
  222. }
  223. useEffect(() => {
  224. try {
  225. const transaction: any = getCurrentSentryReactTransaction(); // Using any to override types for private api.
  226. if (!transaction) {
  227. return;
  228. }
  229. if (!isVisuallyCompleteSet.current) {
  230. const time = performance.now();
  231. transaction.registerBeforeFinishCallback((t: Transaction, _) => {
  232. // Should be called after performance entries finish callback.
  233. t.setMeasurement('visuallyComplete', time, 'ms');
  234. });
  235. isVisuallyCompleteSet.current = true;
  236. }
  237. if (!isDataCompleteSet.current && hasData) {
  238. isDataCompleteSet.current = true;
  239. performance.mark(`${id}-vcsd-end-pre-timeout`);
  240. window.setTimeout(() => {
  241. if (!browserPerformanceTimeOrigin) {
  242. return;
  243. }
  244. performance.mark(`${id}-vcsd-end`);
  245. const measureName = `VCD [${id}] #${num.current}`;
  246. performance.measure(
  247. `VCD [${id}] #${num.current}`,
  248. `${id}-vcsd-start`,
  249. `${id}-vcsd-end`
  250. );
  251. num.current = num.current++;
  252. const [measureEntry] = performance.getEntriesByName(measureName);
  253. if (!measureEntry) {
  254. return;
  255. }
  256. transaction.registerBeforeFinishCallback((t: Transaction) => {
  257. if (!browserPerformanceTimeOrigin) {
  258. return;
  259. }
  260. // Should be called after performance entries finish callback.
  261. const lcp = (t as any)._measurements.lcp?.value;
  262. // Adjust to be relative to transaction.startTimestamp
  263. const entryStartSeconds =
  264. browserPerformanceTimeOrigin / 1000 + measureEntry.startTime / 1000;
  265. const time = (entryStartSeconds - transaction.startTimestamp) * 1000;
  266. if (lcp) {
  267. t.setMeasurement('lcpDiffVCD', lcp - time, 'ms');
  268. }
  269. t.setTag('longTaskCount', longTaskCount.current);
  270. t.setMeasurement('visuallyCompleteData', time, 'ms');
  271. });
  272. }, 0);
  273. }
  274. } catch (_) {
  275. // Defensive catch since this code is auxiliary.
  276. }
  277. }, [hasData, id]);
  278. return (
  279. <Profiler id={id} onRender={onRenderCallback}>
  280. <Fragment>{children}</Fragment>
  281. </Profiler>
  282. );
  283. };
  284. interface OpAssetMeasurementDefinition {
  285. key: string;
  286. }
  287. const OP_ASSET_MEASUREMENT_MAP: Record<string, OpAssetMeasurementDefinition> = {
  288. 'resource.script': {
  289. key: 'script',
  290. },
  291. 'resource.css': {
  292. key: 'css',
  293. },
  294. 'resource.link': {
  295. key: 'link',
  296. },
  297. 'resource.img': {
  298. key: 'img',
  299. },
  300. };
  301. const ASSET_MEASUREMENT_ALL = 'allResources';
  302. const measureAssetsOnTransaction = () => {
  303. try {
  304. const transaction: any = getCurrentSentryReactTransaction(); // Using any to override types for private api.
  305. if (!transaction) {
  306. return;
  307. }
  308. transaction.registerBeforeFinishCallback((t: Transaction) => {
  309. const spans: any[] = (t as any).spanRecorder?.spans;
  310. const measurements = (t as any)._measurements;
  311. if (!spans) {
  312. return;
  313. }
  314. if (measurements[ASSET_MEASUREMENT_ALL]) {
  315. return;
  316. }
  317. let allTransfered = 0;
  318. let allEncoded = 0;
  319. let allCount = 0;
  320. for (const [op, definition] of Object.entries(OP_ASSET_MEASUREMENT_MAP)) {
  321. const filtered = spans.filter(s => s.op === op);
  322. const count = filtered.length;
  323. const transfered = filtered.reduce(
  324. (acc, curr) => acc + (curr.data['Transfer Size'] ?? 0),
  325. 0
  326. );
  327. const encoded = filtered.reduce(
  328. (acc, curr) => acc + (curr.data['Encoded Body Size'] ?? 0),
  329. 0
  330. );
  331. if (op === 'resource.script') {
  332. t.setMeasurement(`assets.${definition.key}.encoded`, encoded, '');
  333. t.setMeasurement(`assets.${definition.key}.transfer`, transfered, '');
  334. t.setMeasurement(`assets.${definition.key}.count`, count, '');
  335. }
  336. allCount += count;
  337. allTransfered += transfered;
  338. allEncoded += encoded;
  339. }
  340. t.setMeasurement(`${ASSET_MEASUREMENT_ALL}.encoded`, allEncoded, '');
  341. t.setMeasurement(`${ASSET_MEASUREMENT_ALL}.transfer`, allTransfered, '');
  342. t.setMeasurement(`${ASSET_MEASUREMENT_ALL}.count`, allCount, '');
  343. });
  344. } catch (_) {
  345. // Defensive catch since this code is auxiliary.
  346. }
  347. };
  348. /**
  349. * This will add asset-measurement code to the transaction after a timeout.
  350. * Meant to be called from the sdk without pushing too many perf concerns into our initializeSdk code,
  351. * it's fine if not every transaction gets recorded.
  352. */
  353. export const initializeMeasureAssetsTimeout = () => {
  354. setTimeout(measureAssetsOnTransaction, 1000);
  355. };