slowestFunctionsWidget.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. import type {CSSProperties, ReactNode} from 'react';
  2. import {Fragment, useCallback, useMemo, useState} from 'react';
  3. import styled from '@emotion/styled';
  4. import {Button} from 'sentry/components/button';
  5. import Count from 'sentry/components/count';
  6. import EmptyStateWarning from 'sentry/components/emptyStateWarning';
  7. import IdBadge from 'sentry/components/idBadge';
  8. import Link from 'sentry/components/links/link';
  9. import LoadingIndicator from 'sentry/components/loadingIndicator';
  10. import Pagination from 'sentry/components/pagination';
  11. import PerformanceDuration from 'sentry/components/performanceDuration';
  12. import ScoreBar from 'sentry/components/scoreBar';
  13. import TextOverflow from 'sentry/components/textOverflow';
  14. import {Tooltip} from 'sentry/components/tooltip';
  15. import {CHART_PALETTE} from 'sentry/constants/chartPalette';
  16. import {IconChevron, IconWarning} from 'sentry/icons';
  17. import {t, tct} from 'sentry/locale';
  18. import {space} from 'sentry/styles/space';
  19. import {defined} from 'sentry/utils';
  20. import {trackAnalytics} from 'sentry/utils/analytics';
  21. import {browserHistory} from 'sentry/utils/browserHistory';
  22. import {Frame} from 'sentry/utils/profiling/frame';
  23. import type {EventsResultsDataRow} from 'sentry/utils/profiling/hooks/types';
  24. import {useProfileFunctions} from 'sentry/utils/profiling/hooks/useProfileFunctions';
  25. import {generateProfileRouteFromProfileReference} from 'sentry/utils/profiling/routes';
  26. import {decodeScalar} from 'sentry/utils/queryString';
  27. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  28. import {useLocation} from 'sentry/utils/useLocation';
  29. import useOrganization from 'sentry/utils/useOrganization';
  30. import useProjects from 'sentry/utils/useProjects';
  31. import {
  32. Accordion,
  33. AccordionItem,
  34. ContentContainer,
  35. HeaderContainer,
  36. HeaderTitleLegend,
  37. StatusContainer,
  38. Subtitle,
  39. WidgetContainer,
  40. } from './styles';
  41. const MAX_FUNCTIONS = 3;
  42. const DEFAULT_CURSOR_NAME = 'slowFnCursor';
  43. type BreakdownFunction = 'avg()' | 'p50()' | 'p75()' | 'p95()' | 'p99()';
  44. interface SlowestFunctionsWidgetProps {
  45. breakdownFunction: BreakdownFunction;
  46. cursorName?: string;
  47. header?: ReactNode;
  48. userQuery?: string;
  49. widgetHeight?: string;
  50. }
  51. export function SlowestFunctionsWidget({
  52. breakdownFunction,
  53. cursorName = DEFAULT_CURSOR_NAME,
  54. header,
  55. userQuery,
  56. widgetHeight,
  57. }: SlowestFunctionsWidgetProps) {
  58. const location = useLocation();
  59. const [expandedIndex, setExpandedIndex] = useState(0);
  60. const slowFnCursor = useMemo(
  61. () => decodeScalar(location.query[cursorName]),
  62. [cursorName, location.query]
  63. );
  64. const handleCursor = useCallback(
  65. (cursor, pathname, query) => {
  66. browserHistory.push({
  67. pathname,
  68. query: {...query, [cursorName]: cursor},
  69. });
  70. },
  71. [cursorName]
  72. );
  73. const functionsQuery = useProfileFunctions<FunctionsField>({
  74. fields: functionsFields,
  75. referrer: 'api.profiling.suspect-functions.list',
  76. sort: {
  77. key: 'sum()',
  78. order: 'desc',
  79. },
  80. query: userQuery,
  81. limit: MAX_FUNCTIONS,
  82. cursor: slowFnCursor,
  83. });
  84. const hasFunctions = (functionsQuery.data?.data?.length || 0) > 0;
  85. const totalsQuery = useProfileFunctions<TotalsField>({
  86. fields: totalsFields,
  87. referrer: 'api.profiling.suspect-functions.totals',
  88. sort: {
  89. key: 'sum()',
  90. order: 'desc',
  91. },
  92. query: userQuery,
  93. limit: MAX_FUNCTIONS,
  94. // make sure to query for the projects from the top functions
  95. projects: functionsQuery.isFetched
  96. ? [
  97. ...new Set(
  98. (functionsQuery.data?.data ?? []).map(func => func['project.id'] as number)
  99. ),
  100. ]
  101. : [],
  102. enabled: functionsQuery.isFetched && hasFunctions,
  103. });
  104. const isLoading = functionsQuery.isPending || (hasFunctions && totalsQuery.isPending);
  105. const isError = functionsQuery.isError || totalsQuery.isError;
  106. return (
  107. <WidgetContainer height={widgetHeight}>
  108. <HeaderContainer>
  109. {header ?? <HeaderTitleLegend>{t('Slowest Functions')}</HeaderTitleLegend>}
  110. <Subtitle>{t('Slowest functions by total self time spent.')}</Subtitle>
  111. <StyledPagination
  112. pageLinks={functionsQuery.getResponseHeader?.('Link') ?? null}
  113. size="xs"
  114. onCursor={handleCursor}
  115. />
  116. </HeaderContainer>
  117. <ContentContainer>
  118. {isLoading && (
  119. <StatusContainer>
  120. <LoadingIndicator />
  121. </StatusContainer>
  122. )}
  123. {isError && (
  124. <StatusContainer>
  125. <IconWarning data-test-id="error-indicator" color="gray300" size="lg" />
  126. </StatusContainer>
  127. )}
  128. {!isError && !isLoading && !hasFunctions && (
  129. <EmptyStateWarning>
  130. <p>{t('No functions found')}</p>
  131. </EmptyStateWarning>
  132. )}
  133. {hasFunctions && totalsQuery.isFetched && (
  134. <StyledAccordion>
  135. {(functionsQuery.data?.data ?? []).map((f, i, l) => {
  136. const projectEntry = totalsQuery.data?.data?.find(
  137. row => row['project.id'] === f['project.id']
  138. );
  139. const projectTotalDuration = projectEntry?.['sum()'] ?? f['sum()'];
  140. return (
  141. <SlowestFunctionEntry
  142. key={`${f['project.id']}-${f.package}-${f.function}`}
  143. breakdownFunction={breakdownFunction}
  144. isExpanded={i === expandedIndex}
  145. setExpanded={() => {
  146. const nextIndex = expandedIndex !== i ? i : (i + 1) % l.length;
  147. setExpandedIndex(nextIndex);
  148. }}
  149. func={f}
  150. totalDuration={projectTotalDuration as number}
  151. query={userQuery ?? ''}
  152. />
  153. );
  154. })}
  155. </StyledAccordion>
  156. )}
  157. </ContentContainer>
  158. </WidgetContainer>
  159. );
  160. }
  161. interface SlowestFunctionEntryProps {
  162. breakdownFunction: BreakdownFunction;
  163. func: EventsResultsDataRow<FunctionsField>;
  164. isExpanded: boolean;
  165. query: string;
  166. setExpanded: () => void;
  167. totalDuration: number;
  168. }
  169. const BARS = 10;
  170. function SlowestFunctionEntry({
  171. breakdownFunction,
  172. func,
  173. isExpanded,
  174. query,
  175. setExpanded,
  176. totalDuration,
  177. }: SlowestFunctionEntryProps) {
  178. const organization = useOrganization();
  179. const {projects} = useProjects();
  180. const project = projects.find(p => p.id === String(func['project.id']));
  181. const score = Math.ceil((((func['sum()'] as number) ?? 0) / totalDuration) * BARS);
  182. const palette = new Array(BARS).fill([CHART_PALETTE[0][0]]);
  183. const frame = useMemo(() => {
  184. return new Frame(
  185. {
  186. key: 0,
  187. name: func.function as string,
  188. package: func.package as string,
  189. },
  190. // Ensures that the frame runs through the normalization code path
  191. project?.platform && /node|javascript/.test(project.platform)
  192. ? project.platform
  193. : undefined,
  194. 'aggregate'
  195. );
  196. }, [func, project]);
  197. const userQuery = useMemo(() => {
  198. const conditions = new MutableSearch(query);
  199. conditions.setFilterValues('project.id', [String(func['project.id'])]);
  200. // it is more efficient to filter on the fingerprint
  201. // than it is to filter on the package + function
  202. conditions.setFilterValues('fingerprint', [String(func.fingerprint)]);
  203. return conditions.formatString();
  204. }, [func, query]);
  205. const functionTransactionsQuery = useProfileFunctions<FunctionTransactionField>({
  206. fields: [...functionTransactionsFields, breakdownFunction],
  207. referrer: 'api.profiling.suspect-functions.transactions',
  208. sort: {
  209. key: 'sum()',
  210. order: 'desc',
  211. },
  212. query: userQuery,
  213. limit: 5,
  214. enabled: isExpanded,
  215. });
  216. return (
  217. <Fragment>
  218. <StyledAccordionItem>
  219. {project && (
  220. <Tooltip title={project.name}>
  221. <IdBadge project={project} avatarSize={16} hideName />
  222. </Tooltip>
  223. )}
  224. <FunctionName>
  225. <Tooltip title={frame.package}>{frame.name}</Tooltip>
  226. </FunctionName>
  227. <Tooltip
  228. title={tct('Appeared [count] times for a total time spent of [totalSelfTime]', {
  229. count: <Count value={func['count()'] as number} />,
  230. totalSelfTime: (
  231. <PerformanceDuration nanoseconds={func['sum()'] as number} abbreviation />
  232. ),
  233. })}
  234. >
  235. <ScoreBar score={score} palette={palette} size={20} radius={0} />
  236. </Tooltip>
  237. <Button
  238. icon={<IconChevron size="xs" direction={isExpanded ? 'up' : 'down'} />}
  239. aria-label={t('Expand')}
  240. aria-expanded={isExpanded}
  241. size="zero"
  242. borderless
  243. onClick={setExpanded}
  244. />
  245. </StyledAccordionItem>
  246. {isExpanded && (
  247. <Fragment>
  248. {functionTransactionsQuery.isError && (
  249. <StatusContainer>
  250. <IconWarning data-test-id="error-indicator" color="gray300" size="lg" />
  251. </StatusContainer>
  252. )}
  253. {functionTransactionsQuery.isPending && (
  254. <StatusContainer>
  255. <LoadingIndicator />
  256. </StatusContainer>
  257. )}
  258. {functionTransactionsQuery.isFetched && (
  259. <TransactionsList data-test-id="transactions-list">
  260. <TransactionsListHeader>
  261. <TextOverflow>{t('Transaction')}</TextOverflow>
  262. </TransactionsListHeader>
  263. <TransactionsListHeader align="right">
  264. <TextOverflow>{t('Count')}</TextOverflow>
  265. </TransactionsListHeader>
  266. <TransactionsListHeader align="right">
  267. <TextOverflow>{breakdownFunction.toUpperCase()}</TextOverflow>
  268. </TransactionsListHeader>
  269. <TransactionsListHeader align="right">
  270. <TextOverflow>{t('Time Spent')}</TextOverflow>
  271. </TransactionsListHeader>
  272. {(functionTransactionsQuery.data?.data ?? []).map(transaction => {
  273. const example = transaction['all_examples()']?.[0];
  274. let transactionCol = <Fragment>{transaction.transaction}</Fragment>;
  275. if (project && defined(example)) {
  276. const target = generateProfileRouteFromProfileReference({
  277. orgSlug: organization.slug,
  278. projectSlug: project.slug,
  279. frameName: frame.name,
  280. framePackage: frame.package,
  281. reference: example,
  282. });
  283. transactionCol = (
  284. <Link
  285. to={target}
  286. onClick={() => {
  287. trackAnalytics('profiling_views.go_to_flamegraph', {
  288. organization,
  289. source: 'profiling.global_suspect_functions',
  290. });
  291. }}
  292. >
  293. {transactionCol}
  294. </Link>
  295. );
  296. }
  297. return (
  298. <Fragment key={transaction.transaction as string}>
  299. <TransactionsListCell>
  300. <TextOverflow>{transactionCol}</TextOverflow>
  301. </TransactionsListCell>
  302. <TransactionsListCell align="right">
  303. <Count value={transaction['count()'] as number} />
  304. </TransactionsListCell>
  305. <TransactionsListCell align="right">
  306. <PerformanceDuration
  307. nanoseconds={transaction[breakdownFunction] as number}
  308. abbreviation
  309. />
  310. </TransactionsListCell>
  311. <TransactionsListCell align="right">
  312. <PerformanceDuration
  313. nanoseconds={transaction['sum()'] as number}
  314. abbreviation
  315. />
  316. </TransactionsListCell>
  317. </Fragment>
  318. );
  319. })}
  320. </TransactionsList>
  321. )}
  322. </Fragment>
  323. )}
  324. </Fragment>
  325. );
  326. }
  327. const functionsFields = [
  328. 'project.id',
  329. 'fingerprint',
  330. 'package',
  331. 'function',
  332. 'count()',
  333. 'sum()',
  334. ] as const;
  335. type FunctionsField = (typeof functionsFields)[number];
  336. const totalsFields = ['project.id', 'sum()'] as const;
  337. type TotalsField = (typeof totalsFields)[number];
  338. const functionTransactionsFields = [
  339. 'transaction',
  340. 'count()',
  341. 'sum()',
  342. 'all_examples()',
  343. ] as const;
  344. type FunctionTransactionField =
  345. | BreakdownFunction
  346. | (typeof functionTransactionsFields)[number];
  347. const StyledPagination = styled(Pagination)`
  348. margin: 0;
  349. `;
  350. const StyledAccordion = styled(Accordion)`
  351. display: flex;
  352. flex-direction: column;
  353. `;
  354. const StyledAccordionItem = styled(AccordionItem)`
  355. display: grid;
  356. grid-template-columns: auto 1fr auto auto;
  357. `;
  358. const FunctionName = styled(TextOverflow)`
  359. flex: 1 1 auto;
  360. `;
  361. const TransactionsList = styled('div')`
  362. flex: 1 1 auto;
  363. display: grid;
  364. grid-template-columns: minmax(0, 1fr) repeat(3, auto);
  365. grid-template-rows: 18px repeat(5, min-content);
  366. column-gap: ${space(1)};
  367. padding: 0 ${space(2)};
  368. `;
  369. const TransactionsListHeader = styled('span')<{
  370. align?: CSSProperties['textAlign'];
  371. }>`
  372. text-transform: uppercase;
  373. font-size: ${p => p.theme.fontSizeExtraSmall};
  374. font-weight: ${p => p.theme.fontWeightBold};
  375. color: ${p => p.theme.subText};
  376. text-align: ${p => p.align};
  377. `;
  378. const TransactionsListCell = styled('div')<{align?: CSSProperties['textAlign']}>`
  379. font-size: ${p => p.theme.fontSizeSmall};
  380. text-align: ${p => p.align};
  381. padding: ${space(0.5)} 0px;
  382. `;