slowestFunctionsWidget.tsx 13 KB

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