regressedProfileFunctions.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. import {useCallback, useMemo, useState} from 'react';
  2. import {browserHistory} from 'react-router';
  3. import {useTheme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import type {SelectOption} from 'sentry/components/compactSelect';
  6. import {CompactSelect} from 'sentry/components/compactSelect';
  7. import Link from 'sentry/components/links/link';
  8. import LoadingIndicator from 'sentry/components/loadingIndicator';
  9. import Pagination from 'sentry/components/pagination';
  10. import PerformanceDuration from 'sentry/components/performanceDuration';
  11. import {TextTruncateOverflow} from 'sentry/components/profiling/textTruncateOverflow';
  12. import {t} from 'sentry/locale';
  13. import {space} from 'sentry/styles/space';
  14. import {Organization, Project} from 'sentry/types';
  15. import {trackAnalytics} from 'sentry/utils/analytics';
  16. import {formatPercentage} from 'sentry/utils/formatters';
  17. import type {FunctionTrend, TrendType} from 'sentry/utils/profiling/hooks/types';
  18. import {useCurrentProjectFromRouteParam} from 'sentry/utils/profiling/hooks/useCurrentProjectFromRouteParam';
  19. import {useProfileFunctionTrends} from 'sentry/utils/profiling/hooks/useProfileFunctionTrends';
  20. import {
  21. generateProfileDifferentialFlamegraphRouteWithQuery,
  22. generateProfileFlamechartRouteWithQuery,
  23. } from 'sentry/utils/profiling/routes';
  24. import {relativeChange} from 'sentry/utils/profiling/units/units';
  25. import {decodeScalar} from 'sentry/utils/queryString';
  26. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  27. import {useLocation} from 'sentry/utils/useLocation';
  28. import useOrganization from 'sentry/utils/useOrganization';
  29. import {ProfilingSparklineChart} from './profilingSparklineChart';
  30. const REGRESSED_FUNCTIONS_LIMIT = 5;
  31. const REGRESSED_FUNCTIONS_CURSOR = 'functionRegressionCursor';
  32. function trendToPoints(trend: FunctionTrend): {timestamp: number; value: number}[] {
  33. if (!trend.stats.data.length) {
  34. return [];
  35. }
  36. return trend.stats.data.map(p => {
  37. return {
  38. timestamp: p[0],
  39. value: p[1][0].count,
  40. };
  41. });
  42. }
  43. function findBreakPointIndex(
  44. breakpoint: number,
  45. worst: FunctionTrend['worst']
  46. ): number | undefined {
  47. let low = 0;
  48. let high = worst.length - 1;
  49. let mid = 0;
  50. let bestMatch: number | undefined;
  51. // eslint-disable-next-line
  52. while (low <= high) {
  53. mid = Math.floor((low + high) / 2);
  54. const value = worst[mid][0];
  55. if (breakpoint === value) {
  56. return mid;
  57. }
  58. if (breakpoint > value) {
  59. low = mid + 1;
  60. bestMatch = mid;
  61. } else if (breakpoint < value) {
  62. high = mid - 1;
  63. }
  64. }
  65. // We dont need an exact match as the breakpoint is not guaranteed to be
  66. // in the worst array, so we return the closest index
  67. return bestMatch;
  68. }
  69. function findWorstProfileIDBeforeAndAfter(trend: FunctionTrend): {
  70. after: string;
  71. before: string;
  72. } {
  73. const breakPointIndex = findBreakPointIndex(trend.breakpoint, trend.worst);
  74. if (breakPointIndex === undefined) {
  75. throw new Error('Could not find breakpoint index');
  76. }
  77. let beforeProfileID = '';
  78. let afterProfileID = '';
  79. const STABILITY_WINDOW = 2 * 60 * 1000;
  80. for (let i = breakPointIndex; i >= 0; i--) {
  81. if (trend.worst[i][0] < trend.breakpoint - STABILITY_WINDOW) {
  82. break;
  83. }
  84. beforeProfileID = trend.worst[i][1];
  85. }
  86. for (let i = breakPointIndex; i < trend.worst.length; i++) {
  87. if (trend.worst[i][0] > trend.breakpoint + STABILITY_WINDOW) {
  88. break;
  89. }
  90. afterProfileID = trend.worst[i][1];
  91. }
  92. return {
  93. before: beforeProfileID,
  94. after: afterProfileID,
  95. };
  96. }
  97. interface MostRegressedProfileFunctionsProps {
  98. transaction: string;
  99. }
  100. export function MostRegressedProfileFunctions(props: MostRegressedProfileFunctionsProps) {
  101. const organization = useOrganization();
  102. const project = useCurrentProjectFromRouteParam();
  103. const location = useLocation();
  104. const theme = useTheme();
  105. const fnTrendCursor = useMemo(
  106. () => decodeScalar(location.query[REGRESSED_FUNCTIONS_CURSOR]),
  107. [location.query]
  108. );
  109. const handleRegressedFunctionsCursor = useCallback((cursor, pathname, query) => {
  110. browserHistory.push({
  111. pathname,
  112. query: {...query, [REGRESSED_FUNCTIONS_CURSOR]: cursor},
  113. });
  114. }, []);
  115. const functionQuery = useMemo(() => {
  116. const conditions = new MutableSearch('');
  117. conditions.setFilterValues('is_application', ['1']);
  118. conditions.setFilterValues('transaction', [props.transaction]);
  119. return conditions.formatString();
  120. }, [props.transaction]);
  121. const [trendType, setTrendType] = useState<TrendType>('regression');
  122. const trendsQuery = useProfileFunctionTrends({
  123. trendFunction: 'p95()',
  124. trendType,
  125. query: functionQuery,
  126. limit: REGRESSED_FUNCTIONS_LIMIT,
  127. cursor: fnTrendCursor,
  128. });
  129. const trends = trendsQuery?.data ?? [];
  130. const onChangeTrendType = useCallback(v => setTrendType(v.value), []);
  131. const hasDifferentialFlamegraphPageFeature = organization.features.includes(
  132. 'profiling-differential-flamegraph-page'
  133. );
  134. return (
  135. <RegressedFunctionsContainer>
  136. <RegressedFunctionsTitleContainer>
  137. <RegressedFunctionsTypeSelect
  138. value={trendType}
  139. options={TREND_FUNCTION_OPTIONS}
  140. onChange={onChangeTrendType}
  141. triggerProps={TRIGGER_PROPS}
  142. offset={4}
  143. />
  144. <RegressedFunctionsPagination
  145. pageLinks={trendsQuery.getResponseHeader?.('Link')}
  146. onCursor={handleRegressedFunctionsCursor}
  147. size="xs"
  148. />
  149. </RegressedFunctionsTitleContainer>
  150. {trendsQuery.isLoading ? (
  151. <RegressedFunctionsQueryState>
  152. <LoadingIndicator size={36} />
  153. </RegressedFunctionsQueryState>
  154. ) : trendsQuery.isError ? (
  155. <RegressedFunctionsQueryState>
  156. {t('Failed to fetch regressed functions')}
  157. </RegressedFunctionsQueryState>
  158. ) : !trends.length ? (
  159. <RegressedFunctionsQueryState>
  160. {trendType === 'regression' ? (
  161. <p>{t('No regressed functions detected')}</p>
  162. ) : (
  163. <p>{t('No improved functions detected')}</p>
  164. )}
  165. </RegressedFunctionsQueryState>
  166. ) : (
  167. trends.map((fn, i) => {
  168. const {before, after} = findWorstProfileIDBeforeAndAfter(fn);
  169. return (
  170. <RegressedFunctionRow key={i}>
  171. {hasDifferentialFlamegraphPageFeature ? (
  172. <RegressedFunctionDifferentialFlamegraph
  173. transaction={props.transaction}
  174. organization={organization}
  175. project={project}
  176. fn={fn}
  177. />
  178. ) : (
  179. <RegressedFunctionBeforeAfterFlamechart
  180. organization={organization}
  181. project={project}
  182. before={before}
  183. after={after}
  184. fn={fn}
  185. />
  186. )}
  187. <RegressedFunctionMetricsRow>
  188. <div>
  189. <TextTruncateOverflow>{fn.package}</TextTruncateOverflow>
  190. </div>
  191. <div>
  192. {/* We dont handle improvements as formatPercentage and relativeChange
  193. on lines below dont return absolute values, else we end up with a double sign */}
  194. {trendType === 'regression'
  195. ? fn.aggregate_range_1 < fn.aggregate_range_2
  196. ? '+'
  197. : '-'
  198. : null}
  199. {formatPercentage(
  200. relativeChange(fn.aggregate_range_2, fn.aggregate_range_1)
  201. )}
  202. </div>
  203. </RegressedFunctionMetricsRow>
  204. <RegressedFunctionSparklineContainer>
  205. <ProfilingSparklineChart
  206. name="p95(function.duration)"
  207. points={trendToPoints(fn)}
  208. color={trendType === 'improvement' ? theme.green300 : theme.red300}
  209. aggregate_range_1={fn.aggregate_range_1}
  210. aggregate_range_2={fn.aggregate_range_2}
  211. breakpoint={fn.breakpoint}
  212. start={fn.stats.data[0][0]}
  213. end={fn.stats.data[fn.stats.data.length - 1][0]}
  214. />
  215. </RegressedFunctionSparklineContainer>
  216. </RegressedFunctionRow>
  217. );
  218. })
  219. )}
  220. </RegressedFunctionsContainer>
  221. );
  222. }
  223. interface RegressedFunctionDifferentialFlamegraphProps {
  224. fn: FunctionTrend;
  225. organization: Organization;
  226. project: Project | null;
  227. transaction: string;
  228. }
  229. function RegressedFunctionDifferentialFlamegraph(
  230. props: RegressedFunctionDifferentialFlamegraphProps
  231. ) {
  232. const onRegressedFunctionClick = useCallback(() => {
  233. trackAnalytics('profiling_views.go_to_differential_flamegraph', {
  234. organization: props.organization,
  235. source: `profiling_transaction.regressed_functions_table`,
  236. });
  237. }, [props.organization]);
  238. const differentialFlamegraphLink = generateProfileDifferentialFlamegraphRouteWithQuery({
  239. orgSlug: props.organization.slug,
  240. projectSlug: props.project?.slug ?? '',
  241. transaction: props.transaction,
  242. fingerprint: props.fn.fingerprint,
  243. breakpoint: props.fn.breakpoint,
  244. query: {
  245. // specify the frame to focus, the flamegraph will switch
  246. // to the appropriate thread when these are specified
  247. frameName: props.fn.function as string,
  248. framePackage: props.fn.package as string,
  249. },
  250. });
  251. return (
  252. <RegressedFunctionMainRow>
  253. <div>
  254. <Link onClick={onRegressedFunctionClick} to={differentialFlamegraphLink}>
  255. <TextTruncateOverflow>{props.fn.function}</TextTruncateOverflow>
  256. </Link>
  257. </div>
  258. <div>
  259. <Link onClick={onRegressedFunctionClick} to={differentialFlamegraphLink}>
  260. <PerformanceDuration
  261. abbreviation
  262. nanoseconds={props.fn.aggregate_range_1 as number}
  263. />
  264. <ChangeArrow>{' \u2192 '}</ChangeArrow>
  265. <PerformanceDuration
  266. abbreviation
  267. nanoseconds={props.fn.aggregate_range_2 as number}
  268. />
  269. </Link>
  270. </div>
  271. </RegressedFunctionMainRow>
  272. );
  273. }
  274. interface RegressedFunctionBeforeAfterProps {
  275. after: string;
  276. before: string;
  277. fn: FunctionTrend;
  278. organization: Organization;
  279. project: Project | null;
  280. }
  281. function RegressedFunctionBeforeAfterFlamechart(
  282. props: RegressedFunctionBeforeAfterProps
  283. ) {
  284. const onRegressedFunctionClick = useCallback(() => {
  285. trackAnalytics('profiling_views.go_to_flamegraph', {
  286. organization: props.organization,
  287. source: `profiling_transaction.regressed_functions_table`,
  288. });
  289. }, [props.organization]);
  290. return (
  291. <RegressedFunctionMainRow>
  292. <div>
  293. <Link
  294. onClick={onRegressedFunctionClick}
  295. to={generateProfileFlamechartRouteWithQuery({
  296. orgSlug: props.organization.slug,
  297. projectSlug: props.project?.slug ?? '',
  298. profileId: (props.fn['examples()']?.[0] as string) ?? '',
  299. query: {
  300. // specify the frame to focus, the flamegraph will switch
  301. // to the appropriate thread when these are specified
  302. frameName: props.fn.function as string,
  303. framePackage: props.fn.package as string,
  304. },
  305. })}
  306. >
  307. <TextTruncateOverflow>{props.fn.function}</TextTruncateOverflow>
  308. </Link>
  309. </div>
  310. <div>
  311. <Link
  312. onClick={onRegressedFunctionClick}
  313. to={generateProfileFlamechartRouteWithQuery({
  314. orgSlug: props.organization.slug,
  315. projectSlug: props.project?.slug ?? '',
  316. profileId: props.before,
  317. query: {
  318. // specify the frame to focus, the flamegraph will switch
  319. // to the appropriate thread when these are specified
  320. frameName: props.fn.function as string,
  321. framePackage: props.fn.package as string,
  322. },
  323. })}
  324. >
  325. <PerformanceDuration
  326. abbreviation
  327. nanoseconds={props.fn.aggregate_range_1 as number}
  328. />
  329. </Link>
  330. <ChangeArrow>{' \u2192 '}</ChangeArrow>
  331. <Link
  332. onClick={onRegressedFunctionClick}
  333. to={generateProfileFlamechartRouteWithQuery({
  334. orgSlug: props.organization.slug,
  335. projectSlug: props.project?.slug ?? '',
  336. profileId: props.after,
  337. query: {
  338. // specify the frame to focus, the flamegraph will switch
  339. // to the appropriate thread when these are specified
  340. frameName: props.fn.function as string,
  341. framePackage: props.fn.package as string,
  342. },
  343. })}
  344. >
  345. <PerformanceDuration
  346. abbreviation
  347. nanoseconds={props.fn.aggregate_range_2 as number}
  348. />
  349. </Link>
  350. </div>
  351. </RegressedFunctionMainRow>
  352. );
  353. }
  354. const ChangeArrow = styled('span')`
  355. color: ${p => p.theme.subText};
  356. `;
  357. const RegressedFunctionsTypeSelect = styled(CompactSelect)`
  358. button {
  359. margin: 0;
  360. padding: 0;
  361. }
  362. `;
  363. const RegressedFunctionSparklineContainer = styled('div')``;
  364. const RegressedFunctionRow = styled('div')`
  365. position: relative;
  366. margin-bottom: ${space(1)};
  367. `;
  368. const RegressedFunctionMainRow = styled('div')`
  369. display: flex;
  370. align-items: center;
  371. justify-content: space-between;
  372. > div:first-child {
  373. min-width: 0;
  374. }
  375. > div:last-child {
  376. white-space: nowrap;
  377. }
  378. `;
  379. const RegressedFunctionMetricsRow = styled('div')`
  380. display: flex;
  381. align-items: center;
  382. justify-content: space-between;
  383. font-size: ${p => p.theme.fontSizeSmall};
  384. color: ${p => p.theme.subText};
  385. margin-top: ${space(0.25)};
  386. `;
  387. const RegressedFunctionsContainer = styled('div')`
  388. flex-basis: 80px;
  389. padding: 0 ${space(1)};
  390. border-bottom: 1px solid ${p => p.theme.border};
  391. `;
  392. const RegressedFunctionsPagination = styled(Pagination)`
  393. margin: 0;
  394. button {
  395. height: 16px;
  396. width: 16px;
  397. min-width: 16px;
  398. min-height: 16px;
  399. svg {
  400. width: 10px;
  401. height: 10px;
  402. }
  403. }
  404. `;
  405. const RegressedFunctionsTitleContainer = styled('div')`
  406. display: flex;
  407. align-items: center;
  408. justify-content: space-between;
  409. margin-bottom: ${space(0.5)};
  410. margin-top: ${space(0.5)};
  411. `;
  412. const RegressedFunctionsQueryState = styled('div')`
  413. text-align: center;
  414. padding: ${space(2)} ${space(0.5)};
  415. color: ${p => p.theme.subText};
  416. `;
  417. const TRIGGER_PROPS = {borderless: true, size: 'zero' as const};
  418. const TREND_FUNCTION_OPTIONS: SelectOption<TrendType>[] = [
  419. {
  420. label: t('Most Regressed Functions'),
  421. value: 'regression' as const,
  422. },
  423. {
  424. label: t('Most Improved Functions'),
  425. value: 'improvement' as const,
  426. },
  427. ];