slowestFunctionsWidget.tsx 13 KB

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