slowestFunctionsWidget.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. import type {ReactNode} from 'react';
  2. import {Fragment, useCallback, useMemo, useState} from 'react';
  3. import {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 type {MenuItemProps} from 'sentry/components/dropdownMenu';
  10. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  11. import EmptyStateWarning from 'sentry/components/emptyStateWarning';
  12. import IdBadge from 'sentry/components/idBadge';
  13. import LoadingIndicator from 'sentry/components/loadingIndicator';
  14. import Pagination from 'sentry/components/pagination';
  15. import PerformanceDuration from 'sentry/components/performanceDuration';
  16. import ScoreBar from 'sentry/components/scoreBar';
  17. import TextOverflow from 'sentry/components/textOverflow';
  18. import TimeSince from 'sentry/components/timeSince';
  19. import {Tooltip} from 'sentry/components/tooltip';
  20. import {CHART_PALETTE} from 'sentry/constants/chartPalette';
  21. import {IconChevron} from 'sentry/icons/iconChevron';
  22. import {IconEllipsis} from 'sentry/icons/iconEllipsis';
  23. import {IconWarning} from 'sentry/icons/iconWarning';
  24. import {t, tct} from 'sentry/locale';
  25. import type {Series} from 'sentry/types/echarts';
  26. import type {EventsStatsSeries} from 'sentry/types/organization';
  27. import {defined} from 'sentry/utils';
  28. import {browserHistory} from 'sentry/utils/browserHistory';
  29. import {axisLabelFormatter, tooltipFormatter} from 'sentry/utils/discover/charts';
  30. import {getShortEventId} from 'sentry/utils/events';
  31. import {Frame} from 'sentry/utils/profiling/frame';
  32. import type {EventsResultsDataRow} from 'sentry/utils/profiling/hooks/types';
  33. import {useProfileFunctions} from 'sentry/utils/profiling/hooks/useProfileFunctions';
  34. import {useProfileTopEventsStats} from 'sentry/utils/profiling/hooks/useProfileTopEventsStats';
  35. import {generateProfileRouteFromProfileReference} from 'sentry/utils/profiling/routes';
  36. import type {UseApiQueryResult} from 'sentry/utils/queryClient';
  37. import {decodeScalar} from 'sentry/utils/queryString';
  38. import type RequestError from 'sentry/utils/requestError/requestError';
  39. import {useLocation} from 'sentry/utils/useLocation';
  40. import useOrganization from 'sentry/utils/useOrganization';
  41. import usePageFilters from 'sentry/utils/usePageFilters';
  42. import useProjects from 'sentry/utils/useProjects';
  43. import {getProfileTargetId} from 'sentry/views/profiling/utils';
  44. import {MAX_FUNCTIONS} from './constants';
  45. import {
  46. Accordion,
  47. AccordionItem,
  48. ContentContainer,
  49. HeaderContainer,
  50. HeaderTitleLegend,
  51. StatusContainer,
  52. Subtitle,
  53. WidgetContainer,
  54. } from './styles';
  55. const DEFAULT_CURSOR_NAME = 'slowFnCursor';
  56. type BreakdownFunction = 'avg()' | 'p50()' | 'p75()' | 'p95()' | 'p99()';
  57. type ChartFunctions<F extends BreakdownFunction> = F | 'all_examples()';
  58. interface SlowestFunctionsWidgetProps<F extends BreakdownFunction> {
  59. breakdownFunction: F;
  60. cursorName?: string;
  61. header?: ReactNode;
  62. userQuery?: string;
  63. widgetHeight?: string;
  64. }
  65. export function SlowestFunctionsWidget<F extends BreakdownFunction>({
  66. breakdownFunction,
  67. cursorName = DEFAULT_CURSOR_NAME,
  68. header,
  69. userQuery,
  70. widgetHeight,
  71. }: SlowestFunctionsWidgetProps<F>) {
  72. const location = useLocation();
  73. const [expandedIndex, setExpandedIndex] = useState(0);
  74. const slowFnCursor = useMemo(
  75. () => decodeScalar(location.query[cursorName]),
  76. [cursorName, location.query]
  77. );
  78. const handleCursor = useCallback(
  79. (cursor: any, pathname: any, query: any) => {
  80. browserHistory.push({
  81. pathname,
  82. query: {...query, [cursorName]: cursor},
  83. });
  84. },
  85. [cursorName]
  86. );
  87. const functionsQuery = useProfileFunctions<FunctionsField>({
  88. fields: functionsFields,
  89. referrer: 'api.profiling.suspect-functions.list',
  90. sort: {
  91. key: 'sum()',
  92. order: 'desc',
  93. },
  94. query: userQuery,
  95. limit: MAX_FUNCTIONS,
  96. cursor: slowFnCursor,
  97. });
  98. const functionsData = functionsQuery.data?.data || [];
  99. const hasFunctions = (functionsData.length || 0) > 0;
  100. // make sure to query for the projects from the top functions
  101. const projects = functionsQuery.isFetched
  102. ? [
  103. ...new Set(
  104. (functionsQuery.data?.data ?? []).map(func => func['project.id'] as number)
  105. ),
  106. ]
  107. : [];
  108. const totalsQuery = useProfileFunctions<TotalsField>({
  109. fields: totalsFields,
  110. referrer: 'api.profiling.suspect-functions.totals',
  111. sort: {
  112. key: 'sum()',
  113. order: 'desc',
  114. },
  115. query: userQuery,
  116. limit: MAX_FUNCTIONS,
  117. projects,
  118. enabled: functionsQuery.isFetched && hasFunctions,
  119. });
  120. const isLoading = functionsQuery.isPending || (hasFunctions && totalsQuery.isPending);
  121. const isError = functionsQuery.isError || totalsQuery.isError;
  122. const functionStats = useProfileTopEventsStats({
  123. dataset: 'profileFunctions',
  124. fields: ['fingerprint', 'all_examples()', breakdownFunction],
  125. query: functionsData.map(f => `fingerprint:${f.fingerprint}`).join(' OR '),
  126. referrer: 'api.profiling.suspect-functions.stats',
  127. yAxes: ['all_examples()', breakdownFunction],
  128. projects,
  129. others: false,
  130. topEvents: functionsData.length,
  131. enabled: totalsQuery.isFetched && hasFunctions,
  132. });
  133. return (
  134. <WidgetContainer height={widgetHeight}>
  135. <HeaderContainer>
  136. {header ?? <HeaderTitleLegend>{t('Slowest Functions')}</HeaderTitleLegend>}
  137. <Subtitle>{t('Slowest functions by total self time spent.')}</Subtitle>
  138. <StyledPagination
  139. pageLinks={functionsQuery.getResponseHeader?.('Link') ?? null}
  140. size="xs"
  141. onCursor={handleCursor}
  142. />
  143. </HeaderContainer>
  144. <ContentContainer>
  145. {isLoading && (
  146. <StatusContainer>
  147. <LoadingIndicator />
  148. </StatusContainer>
  149. )}
  150. {isError && (
  151. <StatusContainer>
  152. <IconWarning data-test-id="error-indicator" color="gray300" size="lg" />
  153. </StatusContainer>
  154. )}
  155. {!isError && !isLoading && !hasFunctions && (
  156. <EmptyStateWarning>
  157. <p>{t('No functions found')}</p>
  158. </EmptyStateWarning>
  159. )}
  160. {hasFunctions && totalsQuery.isFetched && (
  161. <Accordion>
  162. {functionsData.map((f, i, l) => {
  163. const projectEntry = totalsQuery.data?.data?.find(
  164. row => row['project.id'] === f['project.id']
  165. );
  166. const projectTotalDuration = projectEntry?.['sum()'] ?? f['sum()'];
  167. return (
  168. <SlowestFunctionEntry
  169. key={`${f['project.id']}-${f.package}-${f.function}`}
  170. breakdownFunction={breakdownFunction}
  171. isExpanded={i === expandedIndex}
  172. setExpanded={() => {
  173. const nextIndex = expandedIndex !== i ? i : (i + 1) % l.length;
  174. setExpandedIndex(nextIndex);
  175. }}
  176. func={f}
  177. stats={functionStats}
  178. totalDuration={projectTotalDuration as number}
  179. query={userQuery ?? ''}
  180. />
  181. );
  182. })}
  183. </Accordion>
  184. )}
  185. </ContentContainer>
  186. </WidgetContainer>
  187. );
  188. }
  189. interface SlowestFunctionEntryProps<F extends BreakdownFunction> {
  190. breakdownFunction: BreakdownFunction;
  191. func: EventsResultsDataRow<FunctionsField>;
  192. isExpanded: boolean;
  193. query: string;
  194. setExpanded: () => void;
  195. totalDuration: number;
  196. stats?: UseApiQueryResult<EventsStatsSeries<ChartFunctions<F>>, RequestError>;
  197. }
  198. const BARS = 10;
  199. function SlowestFunctionEntry<F extends BreakdownFunction>({
  200. breakdownFunction,
  201. func,
  202. isExpanded,
  203. setExpanded,
  204. stats,
  205. totalDuration,
  206. }: SlowestFunctionEntryProps<F>) {
  207. const organization = useOrganization();
  208. const {projects} = useProjects();
  209. const project = projects.find(p => p.id === String(func['project.id']));
  210. const score = Math.ceil((((func['sum()'] as number) ?? 0) / totalDuration) * BARS);
  211. const palette = new Array(BARS).fill([CHART_PALETTE[0][0]]);
  212. const frame = useMemo(() => {
  213. return new Frame(
  214. {
  215. key: 0,
  216. name: func.function as string,
  217. package: func.package as string,
  218. },
  219. // Ensures that the frame runs through the normalization code path
  220. project?.platform && /node|javascript/.test(project.platform)
  221. ? project.platform
  222. : undefined,
  223. 'aggregate'
  224. );
  225. }, [func, project]);
  226. const examples: MenuItemProps[] = useMemo(() => {
  227. const rawExamples = stats?.data?.data?.find(
  228. s => s.axis === 'all_examples()' && s.label === String(func.fingerprint)
  229. );
  230. if (!defined(rawExamples?.values)) {
  231. return [];
  232. }
  233. const timestamps = stats?.data?.timestamps ?? [];
  234. return rawExamples.values
  235. .map(values => (Array.isArray(values) ? values : []))
  236. .flatMap((example, i) => {
  237. const timestamp = (
  238. <TimeSince
  239. unitStyle="extraShort"
  240. date={timestamps[i]! * 1000}
  241. tooltipShowSeconds
  242. />
  243. );
  244. return example.slice(0, 1).map(profileRef => {
  245. const targetId = getProfileTargetId(profileRef);
  246. return {
  247. key: targetId,
  248. label: (
  249. <DropdownItem>
  250. {getShortEventId(targetId)}
  251. {timestamp}
  252. </DropdownItem>
  253. ),
  254. textValue: targetId,
  255. to: generateProfileRouteFromProfileReference({
  256. orgSlug: organization.slug,
  257. projectSlug: project?.slug || '',
  258. reference: profileRef,
  259. frameName: frame.name,
  260. framePackage: frame.package,
  261. }),
  262. };
  263. });
  264. })
  265. .reverse()
  266. .slice(0, 10);
  267. }, [func, stats, organization, project, frame]);
  268. return (
  269. <Fragment>
  270. <AccordionItem>
  271. <Button
  272. icon={<IconChevron size="xs" direction={isExpanded ? 'up' : 'down'} />}
  273. aria-label={t('Expand')}
  274. aria-expanded={isExpanded}
  275. size="zero"
  276. borderless
  277. onClick={setExpanded}
  278. />
  279. {project && (
  280. <Tooltip title={project.slug}>
  281. <IdBadge project={project} avatarSize={16} hideName />
  282. </Tooltip>
  283. )}
  284. <FunctionName>
  285. <Tooltip title={frame.package}>{frame.name}</Tooltip>
  286. </FunctionName>
  287. <Tooltip
  288. title={tct('Appeared [count] times for a total time spent of [totalSelfTime]', {
  289. count: <Count value={func['count()'] as number} />,
  290. totalSelfTime: (
  291. <PerformanceDuration nanoseconds={func['sum()'] as number} abbreviation />
  292. ),
  293. })}
  294. >
  295. <ScoreBar score={score} palette={palette} size={20} radius={0} />
  296. </Tooltip>
  297. <DropdownMenu
  298. position="bottom-end"
  299. triggerProps={{
  300. icon: <IconEllipsis size="xs" />,
  301. borderless: true,
  302. showChevron: false,
  303. size: 'xs',
  304. }}
  305. items={examples}
  306. menuTitle={t('Example Profiles')}
  307. />
  308. </AccordionItem>
  309. {isExpanded && (
  310. <FunctionChartContainer>
  311. <FunctionChart
  312. func={func}
  313. breakdownFunction={breakdownFunction}
  314. stats={stats}
  315. />
  316. </FunctionChartContainer>
  317. )}
  318. </Fragment>
  319. );
  320. }
  321. interface FunctionChartProps<F extends BreakdownFunction> {
  322. breakdownFunction: F;
  323. func: EventsResultsDataRow<FunctionsField>;
  324. stats?: UseApiQueryResult<EventsStatsSeries<ChartFunctions<F>>, RequestError>;
  325. }
  326. function FunctionChart<F extends BreakdownFunction>({
  327. breakdownFunction,
  328. func,
  329. stats,
  330. }: FunctionChartProps<F>) {
  331. const {selection} = usePageFilters();
  332. const theme = useTheme();
  333. const series: Series[] = useMemo(() => {
  334. const timestamps = stats?.data?.timestamps ?? [];
  335. const rawData = stats?.data?.data?.find(
  336. s => s.axis === breakdownFunction && s.label === String(func.fingerprint)
  337. );
  338. if (!defined(rawData?.values)) {
  339. return [];
  340. }
  341. return [
  342. {
  343. data: timestamps.map((timestamp, i) => {
  344. return {
  345. name: timestamp * 1000,
  346. value: rawData.values[i]!,
  347. };
  348. }),
  349. seriesName: breakdownFunction,
  350. },
  351. ];
  352. }, [breakdownFunction, func, stats]);
  353. const chartOptions = useMemo(() => {
  354. return {
  355. height: 150,
  356. grid: {
  357. top: '10px',
  358. bottom: '10px',
  359. left: '10px',
  360. right: '10px',
  361. },
  362. yAxis: {
  363. axisLabel: {
  364. color: theme.chartLabel,
  365. formatter: (value: number) => axisLabelFormatter(value, 'duration'),
  366. },
  367. },
  368. xAxis: {
  369. type: 'time' as const,
  370. },
  371. tooltip: {
  372. valueFormatter: (value: number) => tooltipFormatter(value, 'duration'),
  373. },
  374. };
  375. }, [theme.chartLabel]);
  376. if (stats?.isPending) {
  377. return (
  378. <StatusContainer>
  379. <LoadingIndicator />
  380. </StatusContainer>
  381. );
  382. }
  383. if (stats?.isError) {
  384. return (
  385. <StatusContainer>
  386. <IconWarning data-test-id="error-indicator" color="gray300" size="lg" />
  387. </StatusContainer>
  388. );
  389. }
  390. return (
  391. <ChartZoom {...selection.datetime}>
  392. {zoomRenderProps => (
  393. <LineChart
  394. data-test-id="function-chart"
  395. {...zoomRenderProps}
  396. {...chartOptions}
  397. series={series}
  398. />
  399. )}
  400. </ChartZoom>
  401. );
  402. }
  403. const functionsFields = [
  404. 'project.id',
  405. 'fingerprint',
  406. 'package',
  407. 'function',
  408. 'count()',
  409. 'sum()',
  410. ] as const;
  411. type FunctionsField = (typeof functionsFields)[number];
  412. const totalsFields = ['project.id', 'sum()'] as const;
  413. type TotalsField = (typeof totalsFields)[number];
  414. const StyledPagination = styled(Pagination)`
  415. margin: 0;
  416. `;
  417. const FunctionName = styled(TextOverflow)`
  418. flex: 1 1 auto;
  419. `;
  420. const FunctionChartContainer = styled('div')`
  421. flex: 1 1 auto;
  422. display: flex;
  423. flex-direction: column;
  424. justify-content: center;
  425. `;
  426. const DropdownItem = styled('div')`
  427. width: 150px;
  428. display: flex;
  429. justify-content: space-between;
  430. `;