functionTrendsWidget.tsx 14 KB

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