regressedProfileFunctions.tsx 12 KB

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