performanceForSentry.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. import {Fragment, Profiler, ReactNode, useEffect, useRef} from 'react';
  2. import {captureMessage, setExtra, setTag} from '@sentry/react';
  3. import * as Sentry from '@sentry/react';
  4. import {IdleTransaction} from '@sentry/tracing';
  5. import {
  6. type MeasurementUnit,
  7. type Transaction,
  8. type TransactionEvent,
  9. } from '@sentry/types';
  10. import {
  11. _browserPerformanceTimeOriginMode,
  12. browserPerformanceTimeOrigin,
  13. timestampWithMs,
  14. } from '@sentry/utils';
  15. import {useLocation} from 'sentry/utils/useLocation';
  16. import usePrevious from 'sentry/utils/usePrevious';
  17. const MIN_UPDATE_SPAN_TIME = 16; // Frame boundary @ 60fps
  18. 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.
  19. const INTERACTION_TIMEOUT = 2 * 60_000; // 2min. Wrap interactions up after this time since we don't want transactions sticking around forever.
  20. const MEASUREMENT_OUTLIER_VALUE = 5 * 60_000; // Measurements over 5 minutes don't get recorded as a metric and are tagged instead.
  21. const ASSET_OUTLIER_VALUE = 1_000_000_000; // Assets over 1GB are ignored since they are likely a reporting error.
  22. const VCD_START = 'vcd-start';
  23. const VCD_END = 'vcd-end';
  24. /**
  25. * It depends on where it is called but the way we fetch transactions can be empty despite an ongoing transaction existing.
  26. * This will return an interaction-type transaction held onto by a class static if one exists.
  27. */
  28. export function getPerformanceTransaction(): IdleTransaction | Transaction | undefined {
  29. return PerformanceInteraction.getTransaction() ?? Sentry.getActiveTransaction();
  30. }
  31. /**
  32. * Callback for React Profiler https://reactjs.org/docs/profiler.html
  33. */
  34. export function onRenderCallback(
  35. id: string,
  36. phase: 'mount' | 'update',
  37. actualDuration: number
  38. ) {
  39. try {
  40. const transaction: Transaction | undefined = getPerformanceTransaction();
  41. if (transaction && actualDuration > MIN_UPDATE_SPAN_TIME) {
  42. const now = timestampWithMs();
  43. transaction.startChild({
  44. description: `<${id}>`,
  45. op: `ui.react.${phase}`,
  46. startTimestamp: now - actualDuration / 1000,
  47. endTimestamp: now,
  48. });
  49. }
  50. } catch (_) {
  51. // Add defensive catch since this wraps all of App
  52. }
  53. }
  54. export class PerformanceInteraction {
  55. private static interactionTransaction: Transaction | null = null;
  56. private static interactionTimeoutId: number | undefined = undefined;
  57. static getTransaction() {
  58. return PerformanceInteraction.interactionTransaction;
  59. }
  60. static startInteraction(name: string, timeout = INTERACTION_TIMEOUT, immediate = true) {
  61. try {
  62. const currentIdleTransaction = Sentry.getActiveTransaction();
  63. if (currentIdleTransaction) {
  64. // If interaction is started while idle still exists.
  65. currentIdleTransaction.setTag('finishReason', 'sentry.interactionStarted'); // Override finish reason so we can capture if this has effects on idle timeout.
  66. currentIdleTransaction.finish();
  67. }
  68. PerformanceInteraction.finishInteraction(immediate);
  69. const txn = Sentry?.startTransaction({
  70. name: `ui.${name}`,
  71. op: 'interaction',
  72. });
  73. PerformanceInteraction.interactionTransaction = txn;
  74. // Auto interaction timeout
  75. PerformanceInteraction.interactionTimeoutId = window.setTimeout(() => {
  76. if (!PerformanceInteraction.interactionTransaction) {
  77. return;
  78. }
  79. PerformanceInteraction.interactionTransaction.setTag(
  80. 'ui.interaction.finish',
  81. 'timeout'
  82. );
  83. PerformanceInteraction.finishInteraction(true);
  84. }, timeout);
  85. } catch (e) {
  86. captureMessage(e);
  87. }
  88. }
  89. static async finishInteraction(immediate = false) {
  90. try {
  91. if (!PerformanceInteraction.interactionTransaction) {
  92. return;
  93. }
  94. clearTimeout(PerformanceInteraction.interactionTimeoutId);
  95. if (immediate) {
  96. PerformanceInteraction.interactionTransaction?.finish();
  97. PerformanceInteraction.interactionTransaction = null;
  98. return;
  99. }
  100. // Add a slight wait if this isn't called as the result of another transaction starting.
  101. await new Promise(resolve => setTimeout(resolve, WAIT_POST_INTERACTION));
  102. PerformanceInteraction.interactionTransaction?.finish();
  103. PerformanceInteraction.interactionTransaction = null;
  104. return;
  105. } catch (e) {
  106. captureMessage(e);
  107. }
  108. }
  109. }
  110. export function CustomProfiler({id, children}: {children: ReactNode; id: string}) {
  111. return (
  112. <Profiler id={id} onRender={onRenderCallback}>
  113. {children}
  114. </Profiler>
  115. );
  116. }
  117. /**
  118. * This component wraps the main component on a page with a measurement checking for visual completedness.
  119. * It uses the data check to make sure endpoints have resolved and the component is meaningfully rendering
  120. * which sets it apart from simply checking LCP, which makes it a good back up check the LCP heuristic performance.
  121. *
  122. * Since this component is guaranteed to be part of the -real- critical path, it also wraps the component with the custom profiler.
  123. */
  124. export function VisuallyCompleteWithData({
  125. id,
  126. hasData,
  127. children,
  128. disabled,
  129. isLoading,
  130. }: {
  131. children: ReactNode;
  132. hasData: boolean;
  133. id: string;
  134. disabled?: boolean;
  135. /**
  136. * Add isLoading to also collect navigation timings, since the data state is sometimes constant before the reload occurs.
  137. */
  138. isLoading?: boolean;
  139. }) {
  140. const location = useLocation();
  141. const previousLocation = usePrevious(location);
  142. const isDataCompleteSet = useRef(false);
  143. const num = useRef(1);
  144. const isVCDSet = useRef(false);
  145. if (isVCDSet && hasData && performance && performance.mark && !disabled) {
  146. performance.mark(`${id}-${VCD_START}`);
  147. isVCDSet.current = true;
  148. }
  149. const _hasData = isLoading === undefined ? hasData : hasData && !isLoading;
  150. useEffect(() => {
  151. // Capture changes in location to reset VCD as it's likely indicative of a route change.
  152. if (location !== previousLocation) {
  153. isDataCompleteSet.current = false;
  154. performance
  155. .getEntriesByType('mark')
  156. .map(m => m.name)
  157. .filter(n => n.includes('vcd'))
  158. .forEach(n => performance.clearMarks(n));
  159. }
  160. }, [location, previousLocation]);
  161. useEffect(() => {
  162. if (disabled) {
  163. return;
  164. }
  165. try {
  166. const transaction: any = Sentry.getActiveTransaction(); // Using any to override types for private api.
  167. if (!transaction) {
  168. return;
  169. }
  170. if (!isDataCompleteSet.current && _hasData) {
  171. isDataCompleteSet.current = true;
  172. performance.mark(`${id}-${VCD_END}-pretimeout`);
  173. window.setTimeout(() => {
  174. if (!browserPerformanceTimeOrigin) {
  175. return;
  176. }
  177. performance.mark(`${id}-${VCD_END}`);
  178. const startMarks = performance.getEntriesByName(`${id}-${VCD_START}`);
  179. const endMarks = performance.getEntriesByName(`${id}-${VCD_END}`);
  180. if (startMarks.length > 1 || endMarks.length > 1) {
  181. transaction.setTag('vcd_extra_recorded_marks', true);
  182. }
  183. const startMark = startMarks.at(-1);
  184. const endMark = endMarks.at(-1);
  185. if (!startMark || !endMark) {
  186. return;
  187. }
  188. performance.measure(
  189. `VCD [${id}] #${num.current}`,
  190. `${id}-${VCD_START}`,
  191. `${id}-${VCD_END}`
  192. );
  193. num.current = num.current++;
  194. }, 0);
  195. }
  196. } catch (_) {
  197. // Defensive catch since this code is auxiliary.
  198. }
  199. }, [_hasData, disabled, id]);
  200. if (disabled) {
  201. return <Fragment>{children}</Fragment>;
  202. }
  203. return (
  204. <Profiler id={id} onRender={onRenderCallback}>
  205. <Fragment>{children}</Fragment>
  206. </Profiler>
  207. );
  208. }
  209. interface OpAssetMeasurementDefinition {
  210. key: string;
  211. }
  212. const OP_ASSET_MEASUREMENT_MAP: Record<string, OpAssetMeasurementDefinition> = {
  213. 'resource.script': {
  214. key: 'script',
  215. },
  216. };
  217. const ASSET_MEASUREMENT_ALL = 'allResources';
  218. const SENTRY_ASSET_DOMAINS = ['sentry-cdn.com'];
  219. /**
  220. * Creates aggregate measurements for assets to understand asset size impact on performance.
  221. * The `hasAnyAssetTimings` is also added here since the asset information depends on the `allow-timing-origin` header.
  222. */
  223. const addAssetMeasurements = (transaction: TransactionEvent) => {
  224. const spans = transaction.spans;
  225. if (!spans) {
  226. return;
  227. }
  228. let allTransfered = 0;
  229. let allEncoded = 0;
  230. let hasAssetTimings = false;
  231. const getOperation = data => data.operation ?? '';
  232. const getTransferSize = data =>
  233. data['http.response_transfer_size'] ?? data['Transfer Size'] ?? 0;
  234. const getEncodedSize = data =>
  235. data['http.response_content_length'] ?? data['Encoded Body Size'] ?? 0;
  236. const getDecodedSize = data =>
  237. data['http.decoded_response_content_length'] ?? data['Decoded Body Size'] ?? 0;
  238. const getFields = data => ({
  239. operation: getOperation(data),
  240. transferSize: getTransferSize(data),
  241. encodedSize: getEncodedSize(data),
  242. decodedSize: getDecodedSize(data),
  243. });
  244. for (const [op, _] of Object.entries(OP_ASSET_MEASUREMENT_MAP)) {
  245. const filtered = spans.filter(
  246. s =>
  247. s.op === op &&
  248. SENTRY_ASSET_DOMAINS.some(
  249. domain =>
  250. !s.description ||
  251. s.description.includes(domain) ||
  252. s.description.startsWith('/')
  253. )
  254. );
  255. const transfered = filtered.reduce((acc, curr) => {
  256. const fields = getFields(curr.data);
  257. if (fields.transferSize > ASSET_OUTLIER_VALUE) {
  258. return acc;
  259. }
  260. return acc + fields.transferSize;
  261. }, 0);
  262. const encoded = filtered.reduce((acc, curr) => {
  263. const fields = getFields(curr.data);
  264. if (
  265. fields.encodedSize > ASSET_OUTLIER_VALUE ||
  266. (fields.encodedSize > 0 && fields.decodedSize === 0)
  267. ) {
  268. // There appears to be a bug where we have massive encoded sizes w/o a decode size, we'll ignore these assets for now.
  269. return acc;
  270. }
  271. return acc + fields.encodedSize;
  272. }, 0);
  273. if (encoded > 0) {
  274. hasAssetTimings = true;
  275. }
  276. allTransfered += transfered;
  277. allEncoded += encoded;
  278. }
  279. if (!transaction.measurements || !transaction.tags) {
  280. return;
  281. }
  282. transaction.measurements[`${ASSET_MEASUREMENT_ALL}.encoded`] = {
  283. value: allEncoded,
  284. unit: 'byte',
  285. };
  286. transaction.measurements[`${ASSET_MEASUREMENT_ALL}.transfer`] = {
  287. value: allTransfered,
  288. unit: 'byte',
  289. };
  290. transaction.tags.hasAnyAssetTimings = hasAssetTimings;
  291. };
  292. const addCustomMeasurements = (transaction: TransactionEvent) => {
  293. if (!browserPerformanceTimeOrigin || !transaction.start_timestamp) {
  294. return;
  295. }
  296. const measurements: Record<string, Measurement> = {...transaction.measurements};
  297. const ttfb = Object.entries(measurements).find(([key]) =>
  298. key.toLowerCase().includes('ttfb')
  299. );
  300. const ttfbValue = ttfb?.[1]?.value;
  301. const context: MeasurementContext = {
  302. transaction,
  303. ttfb: ttfbValue,
  304. browserTimeOrigin: browserPerformanceTimeOrigin,
  305. transactionStart: transaction.start_timestamp,
  306. transactionOp: (transaction.contexts?.trace?.op as string) ?? 'pageload',
  307. };
  308. for (const [name, fn] of Object.entries(customMeasurements)) {
  309. const measurement = fn(context);
  310. if (measurement) {
  311. if (
  312. measurement.unit === 'millisecond' &&
  313. measurement.value > MEASUREMENT_OUTLIER_VALUE
  314. ) {
  315. // exclude outlier measurements and don't add any of the custom measurements in case something is wrong.
  316. if (transaction.tags) {
  317. transaction.tags.outlier_vcd = name;
  318. }
  319. return;
  320. }
  321. measurements[name] = measurement;
  322. }
  323. }
  324. transaction.measurements = measurements;
  325. };
  326. interface Measurement {
  327. unit: MeasurementUnit;
  328. value: number;
  329. }
  330. interface MeasurementContext {
  331. browserTimeOrigin: number;
  332. transaction: TransactionEvent;
  333. transactionOp: string;
  334. transactionStart: number;
  335. ttfb?: number;
  336. }
  337. const getVCDSpan = (transaction: TransactionEvent) =>
  338. transaction.spans?.find(s => s.description?.startsWith('VCD'));
  339. const getBundleLoadSpan = (transaction: TransactionEvent) =>
  340. transaction.spans?.find(s => s.description === 'app.page.bundle-load');
  341. const customMeasurements: Record<
  342. string,
  343. (ctx: MeasurementContext) => Measurement | undefined
  344. > = {
  345. /**
  346. * Budget measurement between the time to first byte (the beginning of the response) and the beginning of our
  347. * webpack bundle load. Useful for us since we have an entrypoint script we want to measure the impact of.
  348. *
  349. * Performance budget: **0 ms**
  350. *
  351. * - We should get rid of delays before loading the main app bundle to improve performance.
  352. */
  353. pre_bundle_load: ({ttfb, browserTimeOrigin, transactionStart}) => {
  354. const headMark = performance.getEntriesByName('head-start')[0];
  355. if (!headMark || !ttfb) {
  356. return undefined;
  357. }
  358. const entryStartSeconds = browserTimeOrigin / 1000 + headMark.startTime / 1000;
  359. const value = (entryStartSeconds - transactionStart) * 1000 - ttfb;
  360. return {
  361. value,
  362. unit: 'millisecond',
  363. };
  364. },
  365. /**
  366. * Budget measurement representing the `app.page.bundle-load` measure.
  367. * We can use this to track asset transfer performance impact over time as a measurement.
  368. *
  369. * Performance budget: **__** ms
  370. *
  371. */
  372. bundle_load: ({transaction, ttfb}) => {
  373. const span = getBundleLoadSpan(transaction);
  374. if (!span?.endTimestamp || !span?.startTimestamp || !ttfb) {
  375. return undefined;
  376. }
  377. return {
  378. value: (span?.endTimestamp - span?.startTimestamp) * 1000,
  379. unit: 'millisecond',
  380. };
  381. },
  382. /**
  383. * Experience measurement representing the time when the first "visually complete" component approximately *finishes* rendering on the page.
  384. * - Provided by the {@link VisuallyCompleteWithData} wrapper component.
  385. * - This only fires when it receives a non-empty data set for that component. Which won't capture onboarding or empty states,
  386. * but most 'happy path' performance for using any product occurs only in views with data.
  387. * - Only record for pageload transactions
  388. *
  389. * This should replace LCP as a 'load' metric when it's present, since it also works on navigations.
  390. */
  391. visually_complete_with_data: ({transaction, ttfb, transactionStart}) => {
  392. const vcdSpan = getVCDSpan(transaction);
  393. if (!vcdSpan?.endTimestamp || !ttfb) {
  394. return undefined;
  395. }
  396. const value = (vcdSpan?.endTimestamp - transactionStart) * 1000;
  397. return {
  398. value,
  399. unit: 'millisecond',
  400. };
  401. },
  402. /**
  403. * Budget measurement for the time between loading the bundle and a visually complete component finishing it's render.
  404. *
  405. * Fires for navigation components as well using the beginning of the navigation as 'init'
  406. *
  407. * For now this is a quite broad measurement but can be roughly be broken down into:
  408. * - Post bundle load application initialization
  409. * - Http waterfalls for data
  410. * - Rendering of components, including the VCD component.
  411. */
  412. init_to_vcd: ({transaction, transactionOp, transactionStart}) => {
  413. const bundleSpan = getBundleLoadSpan(transaction);
  414. const vcdSpan = getVCDSpan(transaction);
  415. if (!vcdSpan?.endTimestamp || !['navigation', 'pageload'].includes(transactionOp)) {
  416. return undefined;
  417. }
  418. const startTimestamp =
  419. transactionOp === 'navigation' ? transactionStart : bundleSpan?.endTimestamp;
  420. if (!startTimestamp) {
  421. return undefined;
  422. }
  423. return {
  424. value: (vcdSpan.endTimestamp - startTimestamp) * 1000,
  425. unit: 'millisecond',
  426. };
  427. },
  428. };
  429. export const addExtraMeasurements = (transaction: TransactionEvent) => {
  430. try {
  431. addAssetMeasurements(transaction);
  432. addCustomMeasurements(transaction);
  433. } catch (_) {
  434. // Defensive catch since this code is auxiliary.
  435. }
  436. };
  437. /**
  438. * A util function to help create some broad buckets to group entity counts without exploding cardinality.
  439. *
  440. * @param tagName - Name for the tag, will create `<tagName>` in data and `<tagname>.grouped` as a tag
  441. * @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.
  442. * @param n - The value to be grouped, should represent `n` entities.
  443. * @param [buckets=[1,2,5]] - An optional param to specify the bucket progression. Default is 1,2,5 (10,20,50 etc).
  444. */
  445. export const setGroupedEntityTag = (
  446. tagName: string,
  447. max: number,
  448. n: number,
  449. buckets = [1, 2, 5]
  450. ) => {
  451. setExtra(tagName, n);
  452. let groups = [0];
  453. loop: for (let m = 1, mag = 0; m <= max; m *= 10, mag++) {
  454. for (const i of buckets) {
  455. const group = i * 10 ** mag;
  456. if (group > max) {
  457. break loop;
  458. }
  459. groups = [...groups, group];
  460. }
  461. }
  462. groups = [...groups, +Infinity];
  463. setTag(`${tagName}.grouped`, `<=${groups.find(g => n <= g)}`);
  464. };
  465. /**
  466. * A temporary util function used for interaction transactions that will attach a tag to the transaction, indicating the element
  467. * that was interacted with. This will allow for querying for transactions by a specific element. This is a high cardinality tag, but
  468. * it is only temporary for an experiment
  469. */
  470. export const addUIElementTag = (transaction: TransactionEvent) => {
  471. if (!transaction || transaction.contexts?.trace?.op !== 'ui.action.click') {
  472. return;
  473. }
  474. if (!transaction.tags) {
  475. return;
  476. }
  477. const interactionSpan = transaction.spans?.find(
  478. span => span.op === 'ui.interaction.click'
  479. );
  480. transaction.tags.interactionElement = interactionSpan?.description;
  481. };