slowestFunctionsWidget.tsx 13 KB

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