slowestFunctionsWidget.tsx 13 KB

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