content.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. import {Fragment, useCallback, useEffect, useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {Location} from 'history';
  4. import {Alert} from 'sentry/components/alert';
  5. import {Button, LinkButton} from 'sentry/components/button';
  6. import type {SmartSearchBarProps} from 'sentry/components/deprecatedSmartSearchBar';
  7. import FeedbackWidgetButton from 'sentry/components/feedback/widget/feedbackWidgetButton';
  8. import * as Layout from 'sentry/components/layouts/thirds';
  9. import {DatePageFilter} from 'sentry/components/organizations/datePageFilter';
  10. import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter';
  11. import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
  12. import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
  13. import {ProjectPageFilter} from 'sentry/components/organizations/projectPageFilter';
  14. import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionTooltip';
  15. import Pagination from 'sentry/components/pagination';
  16. import {TransactionSearchQueryBuilder} from 'sentry/components/performance/transactionSearchQueryBuilder';
  17. import {
  18. ProfilingAM1OrMMXUpgrade,
  19. ProfilingBetaAlertBanner,
  20. ProfilingUpgradeButton,
  21. } from 'sentry/components/profiling/billing/alerts';
  22. import {ProfileEventsTable} from 'sentry/components/profiling/profileEventsTable';
  23. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  24. import {SidebarPanelKey} from 'sentry/components/sidebar/types';
  25. import {TabList, Tabs} from 'sentry/components/tabs';
  26. import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters';
  27. import {t} from 'sentry/locale';
  28. import SidebarPanelStore from 'sentry/stores/sidebarPanelStore';
  29. import {space} from 'sentry/styles/space';
  30. import {trackAnalytics} from 'sentry/utils/analytics';
  31. import {browserHistory} from 'sentry/utils/browserHistory';
  32. import {useProfileEvents} from 'sentry/utils/profiling/hooks/useProfileEvents';
  33. import {formatError, formatSort} from 'sentry/utils/profiling/hooks/utils';
  34. import {decodeScalar} from 'sentry/utils/queryString';
  35. import {useLocation} from 'sentry/utils/useLocation';
  36. import useOrganization from 'sentry/utils/useOrganization';
  37. import usePageFilters from 'sentry/utils/usePageFilters';
  38. import useProjects from 'sentry/utils/useProjects';
  39. import {
  40. TableHeader,
  41. TableHeaderActions,
  42. TableHeaderTitle,
  43. } from 'sentry/views/explore/components/table';
  44. import {LandingAggregateFlamegraph} from 'sentry/views/profiling/landingAggregateFlamegraph';
  45. import {DEFAULT_PROFILING_DATETIME_SELECTION} from 'sentry/views/profiling/utils';
  46. import {LandingWidgetSelector} from './landing/landingWidgetSelector';
  47. import {ProfilesChart} from './landing/profileCharts';
  48. import {ProfilesChartWidget} from './landing/profilesChartWidget';
  49. import {ProfilingSlowestTransactionsPanel} from './landing/profilingSlowestTransactionsPanel';
  50. import {ProfilingOnboardingPanel} from './profilingOnboardingPanel';
  51. const LEFT_WIDGET_CURSOR = 'leftCursor';
  52. const RIGHT_WIDGET_CURSOR = 'rightCursor';
  53. const CURSOR_PARAMS = [LEFT_WIDGET_CURSOR, RIGHT_WIDGET_CURSOR];
  54. interface ProfilingContentProps {
  55. location: Location;
  56. }
  57. function validateTab(tab: unknown): tab is 'flamegraph' | 'transactions' {
  58. return tab === 'flamegraph' || tab === 'transactions';
  59. }
  60. function decodeTab(tab: unknown): 'flamegraph' | 'transactions' {
  61. // Fallback to transactions if tab is invalid. We default to transactions
  62. // because that is going to be the most common perf setup when we release.
  63. return validateTab(tab) ? tab : 'transactions';
  64. }
  65. export default function ProfilingContent({location}: ProfilingContentProps) {
  66. const organization = useOrganization();
  67. const {selection} = usePageFilters();
  68. const {projects} = useProjects();
  69. const tab = decodeTab(location.query.tab);
  70. useEffect(() => {
  71. trackAnalytics('profiling_views.landing', {
  72. organization,
  73. });
  74. }, [organization]);
  75. const onTabChange = useCallback(
  76. (newTab: 'flamegraph' | 'transactions') => {
  77. browserHistory.push({
  78. ...location,
  79. query: {
  80. ...location.query,
  81. tab: newTab,
  82. },
  83. });
  84. },
  85. [location]
  86. );
  87. const shouldShowProfilingOnboardingPanel = useMemo((): boolean => {
  88. // if it's My Projects or All projects, only show onboarding if we can't
  89. // find any projects with profiles
  90. if (
  91. selection.projects.length === 0 ||
  92. selection.projects[0] === ALL_ACCESS_PROJECTS
  93. ) {
  94. return projects.every(project => !project.hasProfiles);
  95. }
  96. // otherwise, only show onboarding if we can't find any projects with profiles
  97. // from those that were selected
  98. const projectsWithProfiles = new Set(
  99. projects.filter(project => project.hasProfiles).map(project => project.id)
  100. );
  101. return selection.projects.every(
  102. project => !projectsWithProfiles.has(String(project))
  103. );
  104. }, [selection.projects, projects]);
  105. return (
  106. <SentryDocumentTitle title={t('Profiling')} orgSlug={organization.slug}>
  107. <PageFiltersContainer
  108. defaultSelection={{datetime: DEFAULT_PROFILING_DATETIME_SELECTION}}
  109. >
  110. <Layout.Page>
  111. <ProfilingBetaAlertBanner organization={organization} />
  112. <ProfilingContentPageHeader tab={tab} onTabChange={onTabChange} />
  113. {tab === 'flamegraph' ? (
  114. <FlamegraphBody>
  115. <ProfilingFlamegraphTabContent
  116. tab={tab}
  117. shouldShowProfilingOnboardingPanel={shouldShowProfilingOnboardingPanel}
  118. />
  119. </FlamegraphBody>
  120. ) : tab === 'transactions' ? (
  121. <Layout.Body>
  122. <ProfilingTransactionsContent
  123. tab={tab}
  124. shouldShowProfilingOnboardingPanel={shouldShowProfilingOnboardingPanel}
  125. />
  126. </Layout.Body>
  127. ) : null}
  128. </Layout.Page>
  129. </PageFiltersContainer>
  130. </SentryDocumentTitle>
  131. );
  132. }
  133. interface ProfilingTabContentProps {
  134. shouldShowProfilingOnboardingPanel: boolean;
  135. tab: 'flamegraph' | 'transactions';
  136. }
  137. function ProfilingFlamegraphTabContent(props: ProfilingTabContentProps) {
  138. return (
  139. <FlamegraphMainLayout>
  140. <FlamegraphActionBar>
  141. <PageFilterBar condensed>
  142. <ProjectPageFilter resetParamsOnChange={CURSOR_PARAMS} />
  143. <EnvironmentPageFilter resetParamsOnChange={CURSOR_PARAMS} />
  144. <DatePageFilter resetParamsOnChange={CURSOR_PARAMS} />
  145. </PageFilterBar>
  146. </FlamegraphActionBar>
  147. <FlamegraphLayout>
  148. {props.shouldShowProfilingOnboardingPanel ? (
  149. <ProfilingOnboardingCTA />
  150. ) : (
  151. <LandingAggregateFlamegraphContainer>
  152. <LandingAggregateFlamegraph />
  153. </LandingAggregateFlamegraphContainer>
  154. )}
  155. <FlamegraphSidebar />
  156. </FlamegraphLayout>
  157. </FlamegraphMainLayout>
  158. );
  159. }
  160. function ProfilingTransactionsContent(props: ProfilingTabContentProps) {
  161. const organization = useOrganization();
  162. const location = useLocation();
  163. const {selection} = usePageFilters();
  164. const fields = ALL_FIELDS;
  165. const sort = formatSort<FieldType>(decodeScalar(location.query.sort), fields, {
  166. key: 'count()',
  167. order: 'desc',
  168. });
  169. const cursor = decodeScalar(location.query.cursor);
  170. const query = decodeScalar(location.query.query, '');
  171. const transactions = useProfileEvents<FieldType>({
  172. cursor,
  173. fields,
  174. query,
  175. sort,
  176. referrer: 'api.profiling.landing-table',
  177. });
  178. const transactionsError =
  179. transactions.status === 'error' ? formatError(transactions.error) : null;
  180. const handleSearch: SmartSearchBarProps['onSearch'] = useCallback(
  181. (searchQuery: string) => {
  182. browserHistory.push({
  183. ...location,
  184. query: {
  185. ...location.query,
  186. cursor: undefined,
  187. query: searchQuery || undefined,
  188. },
  189. });
  190. },
  191. [location]
  192. );
  193. return (
  194. <Layout.Main fullWidth>
  195. {transactionsError && (
  196. <Alert type="error" showIcon>
  197. {transactionsError}
  198. </Alert>
  199. )}
  200. <ActionBar>
  201. <PageFilterBar condensed>
  202. <ProjectPageFilter resetParamsOnChange={CURSOR_PARAMS} />
  203. <EnvironmentPageFilter resetParamsOnChange={CURSOR_PARAMS} />
  204. <DatePageFilter resetParamsOnChange={CURSOR_PARAMS} />
  205. </PageFilterBar>
  206. <TransactionSearchQueryBuilder
  207. projects={selection.projects}
  208. initialQuery={query}
  209. onSearch={handleSearch}
  210. searchSource="profile_landing"
  211. />
  212. </ActionBar>
  213. {props.shouldShowProfilingOnboardingPanel ? (
  214. <ProfilingOnboardingCTA />
  215. ) : (
  216. <Fragment>
  217. {organization.features.includes('profiling-global-suspect-functions') ? (
  218. <Fragment>
  219. <ProfilesChartWidget
  220. chartHeight={150}
  221. referrer="api.profiling.landing-chart"
  222. userQuery={query}
  223. selection={selection}
  224. />
  225. <WidgetsContainer>
  226. <LandingWidgetSelector
  227. cursorName={LEFT_WIDGET_CURSOR}
  228. widgetHeight="340px"
  229. defaultWidget="slowest functions"
  230. query={query}
  231. storageKey="profiling-landing-widget-0"
  232. />
  233. <LandingWidgetSelector
  234. cursorName={RIGHT_WIDGET_CURSOR}
  235. widgetHeight="340px"
  236. defaultWidget="regressed functions"
  237. query={query}
  238. storageKey="profiling-landing-widget-1"
  239. />
  240. </WidgetsContainer>
  241. </Fragment>
  242. ) : (
  243. <PanelsGrid>
  244. <ProfilingSlowestTransactionsPanel />
  245. <ProfilesChart
  246. referrer="api.profiling.landing-chart"
  247. query={query}
  248. selection={selection}
  249. hideCount
  250. />
  251. </PanelsGrid>
  252. )}
  253. <Fragment>
  254. <TableHeader>
  255. <TableHeaderTitle>{t('Transactions')}</TableHeaderTitle>
  256. <TableHeaderActions>
  257. <StyledPagination
  258. pageLinks={
  259. transactions.status === 'success'
  260. ? transactions.getResponseHeader?.('Link') ?? null
  261. : null
  262. }
  263. />
  264. </TableHeaderActions>
  265. </TableHeader>
  266. <ProfileEventsTable
  267. columns={fields.slice()}
  268. data={transactions.status === 'success' ? transactions.data : null}
  269. error={
  270. transactions.status === 'error' ? t('Unable to load profiles') : null
  271. }
  272. isLoading={transactions.status === 'pending'}
  273. sort={sort}
  274. sortableColumns={new Set(fields)}
  275. />
  276. </Fragment>
  277. </Fragment>
  278. )}
  279. </Layout.Main>
  280. );
  281. }
  282. function ProfilingOnboardingCTA() {
  283. const organization = useOrganization();
  284. // Open the modal on demand
  285. const onSetupProfilingClick = useCallback(() => {
  286. trackAnalytics('profiling_views.onboarding', {
  287. organization,
  288. });
  289. SidebarPanelStore.activatePanel(SidebarPanelKey.PROFILING_ONBOARDING);
  290. }, [organization]);
  291. return (
  292. <Fragment>
  293. <ProfilingOnboardingPanel
  294. content={
  295. // If user is on m2, show default
  296. <ProfilingAM1OrMMXUpgrade
  297. organization={organization}
  298. fallback={
  299. <Fragment>
  300. <h3>{t('Function level insights')}</h3>
  301. <p>
  302. {t(
  303. 'Discover slow-to-execute or resource intensive functions within your application'
  304. )}
  305. </p>
  306. </Fragment>
  307. }
  308. />
  309. }
  310. >
  311. <ProfilingUpgradeButton
  312. data-test-id="profiling-upgrade"
  313. organization={organization}
  314. priority="primary"
  315. onClick={onSetupProfilingClick}
  316. fallback={
  317. <Button onClick={onSetupProfilingClick} priority="primary">
  318. {t('Set Up Profiling')}
  319. </Button>
  320. }
  321. >
  322. {t('Set Up Profiling')}
  323. </ProfilingUpgradeButton>
  324. <LinkButton href="https://docs.sentry.io/product/profiling/" external>
  325. {t('Read Docs')}
  326. </LinkButton>
  327. </ProfilingOnboardingPanel>
  328. </Fragment>
  329. );
  330. }
  331. interface ProfilingContentPageHeaderProps {
  332. onTabChange: (newTab: 'flamegraph' | 'transactions') => void;
  333. tab: 'flamegraph' | 'transactions';
  334. }
  335. function ProfilingContentPageHeader(props: ProfilingContentPageHeaderProps) {
  336. return (
  337. <StyledLayoutHeader>
  338. <StyledHeaderContent>
  339. <Layout.Title>
  340. {t('Profiling')}
  341. <PageHeadingQuestionTooltip
  342. docsUrl="https://docs.sentry.io/product/profiling/"
  343. title={t(
  344. 'Profiling collects detailed information in production about the functions executing in your application and how long they take to run, giving you code-level visibility into your hot paths.'
  345. )}
  346. />
  347. </Layout.Title>
  348. <FeedbackWidgetButton />
  349. </StyledHeaderContent>
  350. <div>
  351. <Tabs value={props.tab} onChange={props.onTabChange}>
  352. <TabList hideBorder>
  353. <TabList.Item key="transactions">{t('Transactions')}</TabList.Item>
  354. <TabList.Item key="flamegraph">{t('Flamegraph')}</TabList.Item>
  355. </TabList>
  356. </Tabs>
  357. </div>
  358. </StyledLayoutHeader>
  359. );
  360. }
  361. const ALL_FIELDS = [
  362. 'transaction',
  363. 'project.id',
  364. 'last_seen()',
  365. 'p50()',
  366. 'p75()',
  367. 'p95()',
  368. 'p99()',
  369. 'count()',
  370. ] as const;
  371. type FieldType = (typeof ALL_FIELDS)[number];
  372. const FlamegraphBody = styled(Layout.Body)`
  373. display: grid;
  374. grid-template-rows: 1fr;
  375. `;
  376. const FlamegraphMainLayout = styled(Layout.Main)`
  377. display: grid;
  378. grid-column: 1 / -1;
  379. grid-template-rows: min-content 1fr;
  380. `;
  381. const FlamegraphLayout = styled('div')`
  382. display: grid;
  383. grid-template-areas: 'flamegraph sidebar';
  384. grid-template-columns: 1fr min-content;
  385. margin-top: ${space(2)};
  386. `;
  387. const FlamegraphActionBar = styled('div')``;
  388. const FlamegraphSidebar = styled('div')`
  389. grid-area: sidebar;
  390. `;
  391. const LandingAggregateFlamegraphContainer = styled('div')`
  392. height: 100%;
  393. min-height: 300px;
  394. position: relative;
  395. border: 1px solid ${p => p.theme.border};
  396. border-radius: ${p => p.theme.borderRadius};
  397. margin-bottom: ${space(2)};
  398. grid-area: flamegraph;
  399. `;
  400. const StyledLayoutHeader = styled(Layout.Header)`
  401. display: block;
  402. `;
  403. const StyledHeaderContent = styled(Layout.HeaderContent)`
  404. display: flex;
  405. align-items: center;
  406. justify-content: space-between;
  407. flex-direction: row;
  408. `;
  409. const ActionBar = styled('div')`
  410. display: grid;
  411. gap: ${space(2)};
  412. grid-template-columns: min-content auto;
  413. margin-bottom: ${space(2)};
  414. `;
  415. // TODO: another simple primitive that can easily be <Grid columns={2} />
  416. const PanelsGrid = styled('div')`
  417. display: grid;
  418. grid-template-columns: minmax(0, 1fr) 1fr;
  419. gap: ${space(2)};
  420. @media (max-width: ${p => p.theme.breakpoints.small}) {
  421. grid-template-columns: minmax(0, 1fr);
  422. }
  423. `;
  424. const WidgetsContainer = styled('div')`
  425. display: grid;
  426. grid-template-columns: 1fr 1fr;
  427. gap: ${space(2)};
  428. @media (max-width: ${p => p.theme.breakpoints.small}) {
  429. grid-template-columns: 1fr;
  430. }
  431. `;
  432. const StyledPagination = styled(Pagination)`
  433. margin: 0;
  434. `;