functionTrendsWidget.tsx 11 KB

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