functionTrendsWidget.tsx 14 KB

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