slowestFunctionsWidget.tsx 11 KB

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