slowestFunctionsWidget.tsx 14 KB

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