functionTrendsWidget.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. import type {ReactNode} from 'react';
  2. import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
  3. import type {Theme} from '@emotion/react';
  4. import {useTheme} from '@emotion/react';
  5. import styled from '@emotion/styled';
  6. import partition from 'lodash/partition';
  7. import {Button} from 'sentry/components/button';
  8. import ChartZoom from 'sentry/components/charts/chartZoom';
  9. import {LineChart} from 'sentry/components/charts/lineChart';
  10. import Count from 'sentry/components/count';
  11. import EmptyStateWarning from 'sentry/components/emptyStateWarning';
  12. import IdBadge from 'sentry/components/idBadge';
  13. import Link from 'sentry/components/links/link';
  14. import LoadingIndicator from 'sentry/components/loadingIndicator';
  15. import type {CursorHandler} from 'sentry/components/pagination';
  16. import Pagination from 'sentry/components/pagination';
  17. import PerformanceDuration from 'sentry/components/performanceDuration';
  18. import TextOverflow from 'sentry/components/textOverflow';
  19. import {Tooltip} from 'sentry/components/tooltip';
  20. import {IconArrow, IconChevron, IconWarning} from 'sentry/icons';
  21. import {t, tct} from 'sentry/locale';
  22. import {space} from 'sentry/styles/space';
  23. import type {Series} from 'sentry/types/echarts';
  24. import {trackAnalytics} from 'sentry/utils/analytics';
  25. import {browserHistory} from 'sentry/utils/browserHistory';
  26. import {axisLabelFormatter, tooltipFormatter} from 'sentry/utils/discover/charts';
  27. import type {FunctionTrend, TrendType} from 'sentry/utils/profiling/hooks/types';
  28. import {useProfileFunctionTrends} from 'sentry/utils/profiling/hooks/useProfileFunctionTrends';
  29. import {generateProfileRouteFromProfileReference} from 'sentry/utils/profiling/routes';
  30. import {decodeScalar} from 'sentry/utils/queryString';
  31. import {useLocation} from 'sentry/utils/useLocation';
  32. import useOrganization from 'sentry/utils/useOrganization';
  33. import usePageFilters from 'sentry/utils/usePageFilters';
  34. import useProjects from 'sentry/utils/useProjects';
  35. import {
  36. Accordion,
  37. AccordionItem,
  38. ContentContainer,
  39. HeaderContainer,
  40. HeaderTitleLegend,
  41. StatusContainer,
  42. Subtitle,
  43. WidgetContainer,
  44. } from './styles';
  45. const MAX_FUNCTIONS = 3;
  46. const DEFAULT_CURSOR_NAME = 'fnTrendCursor';
  47. interface FunctionTrendsWidgetProps {
  48. trendFunction: 'p50()' | 'p75()' | 'p95()' | 'p99()';
  49. trendType: TrendType;
  50. cursorName?: string;
  51. header?: ReactNode;
  52. userQuery?: string;
  53. widgetHeight?: string;
  54. }
  55. export function FunctionTrendsWidget({
  56. cursorName = DEFAULT_CURSOR_NAME,
  57. header,
  58. trendFunction,
  59. trendType,
  60. widgetHeight,
  61. userQuery,
  62. }: FunctionTrendsWidgetProps) {
  63. const location = useLocation();
  64. const [expandedIndex, setExpandedIndex] = useState(0);
  65. const fnTrendCursor = useMemo(
  66. () => decodeScalar(location.query[cursorName]),
  67. [cursorName, location.query]
  68. );
  69. const handleCursor = useCallback(
  70. (cursor, pathname, query) => {
  71. browserHistory.push({
  72. pathname,
  73. query: {...query, [cursorName]: cursor},
  74. });
  75. },
  76. [cursorName]
  77. );
  78. const trendsQuery = useProfileFunctionTrends({
  79. trendFunction,
  80. trendType,
  81. query: userQuery,
  82. limit: MAX_FUNCTIONS,
  83. cursor: fnTrendCursor,
  84. });
  85. useEffect(() => {
  86. setExpandedIndex(0);
  87. }, [trendsQuery.data]);
  88. const hasTrends = (trendsQuery.data?.length || 0) > 0;
  89. const isLoading = trendsQuery.isPending;
  90. const isError = trendsQuery.isError;
  91. return (
  92. <WidgetContainer height={widgetHeight}>
  93. <FunctionTrendsWidgetHeader
  94. header={header}
  95. handleCursor={handleCursor}
  96. pageLinks={trendsQuery.getResponseHeader?.('Link') ?? null}
  97. trendType={trendType}
  98. />
  99. <ContentContainer>
  100. {isLoading && (
  101. <StatusContainer>
  102. <LoadingIndicator />
  103. </StatusContainer>
  104. )}
  105. {isError && (
  106. <StatusContainer>
  107. <IconWarning data-test-id="error-indicator" color="gray300" size="lg" />
  108. </StatusContainer>
  109. )}
  110. {!isError && !isLoading && !hasTrends && (
  111. <EmptyStateWarning>
  112. {trendType === 'regression' ? (
  113. <p>{t('No regressed functions detected')}</p>
  114. ) : (
  115. <p>{t('No improved functions detected')}</p>
  116. )}
  117. </EmptyStateWarning>
  118. )}
  119. {hasTrends && (
  120. <Accordion>
  121. {(trendsQuery.data ?? []).map((f, i, l) => {
  122. return (
  123. <FunctionTrendsEntry
  124. key={`${f.project}-${f.function}-${f.package}`}
  125. trendFunction={trendFunction}
  126. trendType={trendType}
  127. isExpanded={i === expandedIndex}
  128. setExpanded={() => {
  129. const nextIndex = expandedIndex !== i ? i : (i + 1) % l.length;
  130. setExpandedIndex(nextIndex);
  131. }}
  132. func={f}
  133. />
  134. );
  135. })}
  136. </Accordion>
  137. )}
  138. </ContentContainer>
  139. </WidgetContainer>
  140. );
  141. }
  142. interface FunctionTrendsWidgetHeaderProps {
  143. handleCursor: CursorHandler;
  144. header: ReactNode;
  145. pageLinks: string | null;
  146. trendType: TrendType;
  147. }
  148. function FunctionTrendsWidgetHeader({
  149. handleCursor,
  150. header,
  151. pageLinks,
  152. trendType,
  153. }: FunctionTrendsWidgetHeaderProps) {
  154. switch (trendType) {
  155. case 'regression':
  156. return (
  157. <HeaderContainer>
  158. {header ?? (
  159. <HeaderTitleLegend>{t('Most Regressed Functions')}</HeaderTitleLegend>
  160. )}
  161. <Subtitle>{t('Functions by most regressed.')}</Subtitle>
  162. <StyledPagination pageLinks={pageLinks} size="xs" onCursor={handleCursor} />
  163. </HeaderContainer>
  164. );
  165. case 'improvement':
  166. return (
  167. <HeaderContainer>
  168. {header ?? (
  169. <HeaderTitleLegend>{t('Most Improved Functions')}</HeaderTitleLegend>
  170. )}
  171. <Subtitle>{t('Functions by most improved.')}</Subtitle>
  172. <StyledPagination pageLinks={pageLinks} size="xs" onCursor={handleCursor} />
  173. </HeaderContainer>
  174. );
  175. default:
  176. throw new Error(t('Unknown trend type'));
  177. }
  178. }
  179. interface FunctionTrendsEntryProps {
  180. func: FunctionTrend;
  181. isExpanded: boolean;
  182. setExpanded: () => void;
  183. trendFunction: string;
  184. trendType: TrendType;
  185. }
  186. function FunctionTrendsEntry({
  187. func,
  188. isExpanded,
  189. setExpanded,
  190. trendFunction,
  191. trendType,
  192. }: FunctionTrendsEntryProps) {
  193. const organization = useOrganization();
  194. const {projects} = useProjects();
  195. const project = projects.find(p => p.id === func.project);
  196. const [beforeExamples, afterExamples] = useMemo(() => {
  197. return partition(func.examples, ([ts, _example]) => ts <= func.breakpoint);
  198. }, [func]);
  199. let before = <PerformanceDuration nanoseconds={func.aggregate_range_1} abbreviation />;
  200. let after = <PerformanceDuration nanoseconds={func.aggregate_range_2} abbreviation />;
  201. function handleGoToProfile() {
  202. switch (trendType) {
  203. case 'improvement':
  204. trackAnalytics('profiling_views.go_to_flamegraph', {
  205. organization,
  206. source: 'profiling.function_trends.improvement',
  207. });
  208. break;
  209. case 'regression':
  210. trackAnalytics('profiling_views.go_to_flamegraph', {
  211. organization,
  212. source: 'profiling.function_trends.regression',
  213. });
  214. break;
  215. default:
  216. throw new Error('Unknown trend type');
  217. }
  218. }
  219. if (project && beforeExamples.length >= 2 && afterExamples.length >= 2) {
  220. // By choosing the 2nd most recent example in each period, we guarantee the example
  221. // occurred within the period and eliminate confusion with picking an example in
  222. // the same bucket as the breakpoint.
  223. const beforeTarget = generateProfileRouteFromProfileReference({
  224. orgSlug: organization.slug,
  225. projectSlug: project.slug,
  226. reference: beforeExamples[beforeExamples.length - 2][1],
  227. frameName: func.function as string,
  228. framePackage: func.package as string,
  229. });
  230. before = (
  231. <Link to={beforeTarget} onClick={handleGoToProfile}>
  232. {before}
  233. </Link>
  234. );
  235. const afterTarget = generateProfileRouteFromProfileReference({
  236. orgSlug: organization.slug,
  237. projectSlug: project.slug,
  238. reference: afterExamples[afterExamples.length - 2][1],
  239. frameName: func.function as string,
  240. framePackage: func.package as string,
  241. });
  242. after = (
  243. <Link to={afterTarget} onClick={handleGoToProfile}>
  244. {after}
  245. </Link>
  246. );
  247. }
  248. return (
  249. <Fragment>
  250. <StyledAccordionItem>
  251. <Button
  252. icon={<IconChevron size="xs" direction={isExpanded ? 'up' : 'down'} />}
  253. aria-label={t('Expand')}
  254. aria-expanded={isExpanded}
  255. size="zero"
  256. borderless
  257. onClick={() => setExpanded()}
  258. />
  259. {project && (
  260. <Tooltip title={project.name}>
  261. <IdBadge project={project} avatarSize={16} hideName />
  262. </Tooltip>
  263. )}
  264. <FunctionName>
  265. <Tooltip title={func.package}>{func.function}</Tooltip>
  266. </FunctionName>
  267. <Tooltip
  268. title={tct('Appeared [count] times.', {
  269. count: <Count value={func['count()']} />,
  270. })}
  271. >
  272. <DurationChange>
  273. {before}
  274. <IconArrow direction="right" size="xs" />
  275. {after}
  276. </DurationChange>
  277. </Tooltip>
  278. </StyledAccordionItem>
  279. {isExpanded && (
  280. <FunctionTrendsChartContainer>
  281. <FunctionTrendsChart func={func} trendFunction={trendFunction} />
  282. </FunctionTrendsChartContainer>
  283. )}
  284. </Fragment>
  285. );
  286. }
  287. interface FunctionTrendsChartProps {
  288. func: FunctionTrend;
  289. trendFunction: string;
  290. }
  291. function FunctionTrendsChart({func, trendFunction}: FunctionTrendsChartProps) {
  292. const {selection} = usePageFilters();
  293. const theme = useTheme();
  294. const series: Series[] = useMemo(() => {
  295. const trendSeries = {
  296. data: func.stats.data.map(([timestamp, data]) => {
  297. return {
  298. name: timestamp * 1e3,
  299. value: data[0].count / 1e6,
  300. };
  301. }),
  302. seriesName: trendFunction,
  303. color: getTrendLineColor(func.change, theme),
  304. };
  305. const seriesStart = func.stats.data[0][0] * 1e3;
  306. const seriesMid = func.breakpoint * 1e3;
  307. const seriesEnd = func.stats.data[func.stats.data.length - 1][0] * 1e3;
  308. const dividingLine = {
  309. data: [],
  310. color: theme.textColor,
  311. seriesName: 'dividing line',
  312. markLine: {},
  313. };
  314. dividingLine.markLine = {
  315. data: [{xAxis: seriesMid}],
  316. label: {show: false},
  317. lineStyle: {
  318. color: theme.textColor,
  319. type: 'solid',
  320. width: 2,
  321. },
  322. symbol: ['none', 'none'],
  323. tooltip: {
  324. show: false,
  325. },
  326. silent: true,
  327. };
  328. const beforeLine = {
  329. data: [],
  330. color: theme.textColor,
  331. seriesName: 'before line',
  332. markLine: {},
  333. };
  334. beforeLine.markLine = {
  335. data: [
  336. [
  337. {value: 'Past', coord: [seriesStart, func.aggregate_range_1 / 1e6]},
  338. {coord: [seriesMid, func.aggregate_range_1 / 1e6]},
  339. ],
  340. ],
  341. label: {
  342. fontSize: 11,
  343. show: true,
  344. color: theme.textColor,
  345. silent: true,
  346. formatter: 'Past',
  347. position: 'insideStartTop',
  348. },
  349. lineStyle: {
  350. color: theme.textColor,
  351. type: 'dashed',
  352. width: 1,
  353. },
  354. symbol: ['none', 'none'],
  355. tooltip: {
  356. formatter: getTooltipFormatter(t('Past Baseline'), func.aggregate_range_1),
  357. },
  358. };
  359. const afterLine = {
  360. data: [],
  361. color: theme.textColor,
  362. seriesName: 'after line',
  363. markLine: {},
  364. };
  365. afterLine.markLine = {
  366. data: [
  367. [
  368. {
  369. value: 'Present',
  370. coord: [seriesMid, func.aggregate_range_2 / 1e6],
  371. },
  372. {coord: [seriesEnd, func.aggregate_range_2 / 1e6]},
  373. ],
  374. ],
  375. label: {
  376. fontSize: 11,
  377. show: true,
  378. color: theme.textColor,
  379. silent: true,
  380. formatter: 'Present',
  381. position: 'insideEndBottom',
  382. },
  383. lineStyle: {
  384. color: theme.textColor,
  385. type: 'dashed',
  386. width: 1,
  387. },
  388. symbol: ['none', 'none'],
  389. tooltip: {
  390. formatter: getTooltipFormatter(t('Present Baseline'), func.aggregate_range_2),
  391. },
  392. };
  393. return [trendSeries, dividingLine, beforeLine, afterLine];
  394. }, [func, trendFunction, theme]);
  395. const chartOptions = useMemo(() => {
  396. return {
  397. height: 150,
  398. grid: {
  399. top: '10px',
  400. bottom: '10px',
  401. left: '10px',
  402. right: '10px',
  403. },
  404. yAxis: {
  405. axisLabel: {
  406. color: theme.chartLabel,
  407. formatter: (value: number) => axisLabelFormatter(value, 'duration'),
  408. },
  409. },
  410. xAxis: {
  411. type: 'time' as const,
  412. },
  413. tooltip: {
  414. valueFormatter: (value: number) => tooltipFormatter(value, 'duration'),
  415. },
  416. };
  417. }, [theme.chartLabel]);
  418. return (
  419. <ChartZoom {...selection.datetime}>
  420. {zoomRenderProps => (
  421. <LineChart {...zoomRenderProps} {...chartOptions} series={series} />
  422. )}
  423. </ChartZoom>
  424. );
  425. }
  426. function getTrendLineColor(trend: TrendType, theme: Theme) {
  427. switch (trend) {
  428. case 'improvement':
  429. return theme.green300;
  430. case 'regression':
  431. return theme.red300;
  432. default:
  433. throw new Error('Unknown trend type');
  434. }
  435. }
  436. function getTooltipFormatter(label: string, baseline: number) {
  437. return [
  438. '<div class="tooltip-series tooltip-series-solo">',
  439. '<div>',
  440. `<span class="tooltip-label"><strong>${label}</strong></span>`,
  441. tooltipFormatter(baseline / 1e6, 'duration'),
  442. '</div>',
  443. '</div>',
  444. '<div class="tooltip-arrow"></div>',
  445. ].join('');
  446. }
  447. const StyledPagination = styled(Pagination)`
  448. margin: 0;
  449. `;
  450. const StyledAccordionItem = styled(AccordionItem)`
  451. display: grid;
  452. grid-template-columns: auto auto 1fr auto;
  453. `;
  454. const FunctionName = styled(TextOverflow)`
  455. flex: 1 1 auto;
  456. `;
  457. const FunctionTrendsChartContainer = styled('div')`
  458. flex: 1 1 auto;
  459. `;
  460. const DurationChange = styled('span')`
  461. color: ${p => p.theme.gray300};
  462. display: flex;
  463. align-items: center;
  464. gap: ${space(1)};
  465. `;