content.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601
  1. import {Fragment, useCallback, useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {Location} from 'history';
  4. import omit from 'lodash/omit';
  5. import type {DropdownOption} from 'sentry/components/discover/transactionsList';
  6. import TransactionsList from 'sentry/components/discover/transactionsList';
  7. import * as Layout from 'sentry/components/layouts/thirds';
  8. import {DatePageFilter} from 'sentry/components/organizations/datePageFilter';
  9. import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter';
  10. import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
  11. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  12. import {TransactionSearchQueryBuilder} from 'sentry/components/performance/transactionSearchQueryBuilder';
  13. import {SuspectFunctionsTable} from 'sentry/components/profiling/suspectFunctions/suspectFunctionsTable';
  14. import {Tooltip} from 'sentry/components/tooltip';
  15. import {IconWarning} from 'sentry/icons';
  16. import {t} from 'sentry/locale';
  17. import {space} from 'sentry/styles/space';
  18. import type {Organization} from 'sentry/types/organization';
  19. import type {Project} from 'sentry/types/project';
  20. import {defined, generateQueryWithTag} from 'sentry/utils';
  21. import {trackAnalytics} from 'sentry/utils/analytics';
  22. import type EventView from 'sentry/utils/discover/eventView';
  23. import {
  24. formatTagKey,
  25. isRelativeSpanOperationBreakdownField,
  26. SPAN_OP_BREAKDOWN_FIELDS,
  27. SPAN_OP_RELATIVE_BREAKDOWN_FIELD,
  28. } from 'sentry/utils/discover/fields';
  29. import type {QueryError} from 'sentry/utils/discover/genericDiscoverQuery';
  30. import {useMEPDataContext} from 'sentry/utils/performance/contexts/metricsEnhancedPerformanceDataContext';
  31. import {decodeScalar} from 'sentry/utils/queryString';
  32. import projectSupportsReplay from 'sentry/utils/replays/projectSupportsReplay';
  33. import {useNavigate} from 'sentry/utils/useNavigate';
  34. import {useRoutes} from 'sentry/utils/useRoutes';
  35. import withProjects from 'sentry/utils/withProjects';
  36. import type {Actions} from 'sentry/views/discover/table/cellAction';
  37. import {updateQuery} from 'sentry/views/discover/table/cellAction';
  38. import type {TableColumn} from 'sentry/views/discover/table/types';
  39. import Tags from 'sentry/views/discover/tags';
  40. import {useDomainViewFilters} from 'sentry/views/insights/pages/useFilters';
  41. import {canUseTransactionMetricsData} from 'sentry/views/performance/transactionSummary/transactionOverview/utils';
  42. import {
  43. PERCENTILE as VITAL_PERCENTILE,
  44. VITAL_GROUPS,
  45. } from 'sentry/views/performance/transactionSummary/transactionVitals/constants';
  46. import {isSummaryViewFrontend, isSummaryViewFrontendPageLoad} from '../../utils';
  47. import Filter, {
  48. decodeFilterFromLocation,
  49. filterToField,
  50. filterToSearchConditions,
  51. SpanOperationBreakdownFilter,
  52. } from '../filter';
  53. import {
  54. generateProfileLink,
  55. generateReplayLink,
  56. generateTraceLink,
  57. generateTransactionIdLink,
  58. normalizeSearchConditions,
  59. SidebarSpacer,
  60. TransactionFilterOptions,
  61. } from '../utils';
  62. import TransactionSummaryCharts from './charts';
  63. import {PerformanceAtScaleContextProvider} from './performanceAtScaleContext';
  64. import RelatedIssues from './relatedIssues';
  65. import SidebarCharts from './sidebarCharts';
  66. import StatusBreakdown from './statusBreakdown';
  67. import SuspectSpans from './suspectSpans';
  68. import {TagExplorer} from './tagExplorer';
  69. import UserStats from './userStats';
  70. type Props = {
  71. error: QueryError | null;
  72. eventView: EventView;
  73. isLoading: boolean;
  74. location: Location;
  75. onChangeFilter: (newFilter: SpanOperationBreakdownFilter) => void;
  76. organization: Organization;
  77. projectId: string;
  78. projects: Project[];
  79. spanOperationBreakdownFilter: SpanOperationBreakdownFilter;
  80. totalValues: Record<string, number> | null;
  81. transactionName: string;
  82. };
  83. function SummaryContent({
  84. eventView,
  85. location,
  86. totalValues,
  87. spanOperationBreakdownFilter,
  88. organization,
  89. projects,
  90. isLoading,
  91. error,
  92. projectId,
  93. transactionName,
  94. onChangeFilter,
  95. }: Props) {
  96. const routes = useRoutes();
  97. const navigate = useNavigate();
  98. const mepDataContext = useMEPDataContext();
  99. const domainViewFilters = useDomainViewFilters();
  100. const handleSearch = useCallback(
  101. (query: string) => {
  102. const queryParams = normalizeDateTimeParams({
  103. ...(location.query || {}),
  104. query,
  105. });
  106. // do not propagate pagination when making a new search
  107. const searchQueryParams = omit(queryParams, 'cursor');
  108. navigate({
  109. pathname: location.pathname,
  110. query: searchQueryParams,
  111. });
  112. },
  113. [location, navigate]
  114. );
  115. function generateTagUrl(key: string, value: string) {
  116. const query = generateQueryWithTag(location.query, {key: formatTagKey(key), value});
  117. return {
  118. ...location,
  119. query,
  120. };
  121. }
  122. function handleCellAction(column: TableColumn<React.ReactText>) {
  123. return (action: Actions, value: React.ReactText) => {
  124. const searchConditions = normalizeSearchConditions(eventView.query);
  125. updateQuery(searchConditions, action, column, value);
  126. navigate({
  127. pathname: location.pathname,
  128. query: {
  129. ...location.query,
  130. cursor: undefined,
  131. query: searchConditions.formatString(),
  132. },
  133. });
  134. };
  135. }
  136. function handleTransactionsListSortChange(value: string) {
  137. const target = {
  138. pathname: location.pathname,
  139. query: {...location.query, showTransactions: value, transactionCursor: undefined},
  140. };
  141. navigate(target);
  142. }
  143. function handleAllEventsViewClick() {
  144. trackAnalytics('performance_views.summary.view_in_transaction_events', {
  145. organization,
  146. });
  147. }
  148. function generateEventView(
  149. transactionsListEventView: EventView,
  150. transactionsListTitles: string[]
  151. ) {
  152. const {selected} = getTransactionsListSort(location, {
  153. p95: totalValues?.['p95()'] ?? 0,
  154. spanOperationBreakdownFilter,
  155. });
  156. const sortedEventView = transactionsListEventView.withSorts([selected.sort]);
  157. if (spanOperationBreakdownFilter === SpanOperationBreakdownFilter.NONE) {
  158. const fields = [
  159. // Remove the extra field columns
  160. ...sortedEventView.fields.slice(0, transactionsListTitles.length),
  161. ];
  162. // omit "Operation Duration" column
  163. sortedEventView.fields = fields.filter(({field}) => {
  164. return !isRelativeSpanOperationBreakdownField(field);
  165. });
  166. }
  167. return sortedEventView;
  168. }
  169. const trailingItems = useMemo(() => {
  170. if (!canUseTransactionMetricsData(organization, mepDataContext)) {
  171. return <MetricsWarningIcon />;
  172. }
  173. return null;
  174. }, [organization, mepDataContext]);
  175. const hasPerformanceChartInterpolation = organization.features.includes(
  176. 'performance-chart-interpolation'
  177. );
  178. const query = useMemo(() => {
  179. return decodeScalar(location.query.query, '');
  180. }, [location]);
  181. const totalCount = totalValues === null ? null : totalValues['count()']!;
  182. // NOTE: This is not a robust check for whether or not a transaction is a front end
  183. // transaction, however it will suffice for now.
  184. const hasWebVitals =
  185. isSummaryViewFrontendPageLoad(eventView, projects) ||
  186. (totalValues !== null &&
  187. VITAL_GROUPS.some(group =>
  188. group.vitals.some(vital => {
  189. const functionName = `percentile(${vital},${VITAL_PERCENTILE})`;
  190. const field = functionName;
  191. return Number.isFinite(totalValues[field]) && totalValues[field] !== 0;
  192. })
  193. ));
  194. const isFrontendView = isSummaryViewFrontend(eventView, projects);
  195. const transactionsListTitles = [
  196. t('event id'),
  197. t('user'),
  198. t('total duration'),
  199. t('trace id'),
  200. t('timestamp'),
  201. ];
  202. const project = projects.find(p => p.id === projectId);
  203. let transactionsListEventView = eventView.clone();
  204. const fields = [...transactionsListEventView.fields];
  205. if (
  206. organization.features.includes('session-replay') &&
  207. project &&
  208. projectSupportsReplay(project)
  209. ) {
  210. transactionsListTitles.push(t('replay'));
  211. fields.push({field: 'replayId'});
  212. }
  213. if (
  214. // only show for projects that already sent a profile
  215. // once we have a more compact design we will show this for
  216. // projects that support profiling as well
  217. project?.hasProfiles &&
  218. (organization.features.includes('profiling') ||
  219. organization.features.includes('continuous-profiling'))
  220. ) {
  221. transactionsListTitles.push(t('profile'));
  222. if (organization.features.includes('profiling')) {
  223. fields.push({field: 'profile.id'});
  224. }
  225. if (organization.features.includes('continuous-profiling')) {
  226. fields.push({field: 'profiler.id'});
  227. fields.push({field: 'thread.id'});
  228. fields.push({field: 'precise.start_ts'});
  229. fields.push({field: 'precise.finish_ts'});
  230. }
  231. }
  232. // update search conditions
  233. const spanOperationBreakdownConditions = filterToSearchConditions(
  234. spanOperationBreakdownFilter,
  235. location
  236. );
  237. if (spanOperationBreakdownConditions) {
  238. eventView = eventView.clone();
  239. eventView.query = `${eventView.query} ${spanOperationBreakdownConditions}`.trim();
  240. transactionsListEventView = eventView.clone();
  241. }
  242. // update header titles of transactions list
  243. const operationDurationTableTitle =
  244. spanOperationBreakdownFilter === SpanOperationBreakdownFilter.NONE
  245. ? t('operation duration')
  246. : `${spanOperationBreakdownFilter} duration`;
  247. // add ops breakdown duration column as the 3rd column
  248. transactionsListTitles.splice(2, 0, operationDurationTableTitle);
  249. // span_ops_breakdown.relative is a preserved name and a marker for the associated
  250. // field renderer to be used to generate the relative ops breakdown
  251. let durationField = SPAN_OP_RELATIVE_BREAKDOWN_FIELD;
  252. if (spanOperationBreakdownFilter !== SpanOperationBreakdownFilter.NONE) {
  253. durationField = filterToField(spanOperationBreakdownFilter)!;
  254. }
  255. // add ops breakdown duration column as the 3rd column
  256. fields.splice(2, 0, {field: durationField});
  257. if (spanOperationBreakdownFilter === SpanOperationBreakdownFilter.NONE) {
  258. fields.push(
  259. ...SPAN_OP_BREAKDOWN_FIELDS.map(field => {
  260. return {field};
  261. })
  262. );
  263. }
  264. transactionsListEventView.fields = fields;
  265. const openAllEventsProps = {
  266. generatePerformanceTransactionEventsView: () => {
  267. const performanceTransactionEventsView = generateEventView(
  268. transactionsListEventView,
  269. transactionsListTitles
  270. );
  271. performanceTransactionEventsView.query = query;
  272. return performanceTransactionEventsView;
  273. },
  274. handleOpenAllEventsClick: handleAllEventsViewClick,
  275. };
  276. const hasNewSpansUIFlag =
  277. organization.features.includes('performance-spans-new-ui') &&
  278. organization.features.includes('insights-initial-modules');
  279. const projectIds = useMemo(() => eventView.project.slice(), [eventView.project]);
  280. function renderSearchBar() {
  281. return (
  282. <TransactionSearchQueryBuilder
  283. projects={projectIds}
  284. initialQuery={query}
  285. onSearch={handleSearch}
  286. searchSource="transaction_summary"
  287. disableLoadingTags // already loaded by the parent component
  288. filterKeyMenuWidth={420}
  289. trailingItems={trailingItems}
  290. />
  291. );
  292. }
  293. return (
  294. <Fragment>
  295. <Layout.Main>
  296. <FilterActions>
  297. <Filter
  298. organization={organization}
  299. currentFilter={spanOperationBreakdownFilter}
  300. onChangeFilter={onChangeFilter}
  301. />
  302. <PageFilterBar condensed>
  303. <EnvironmentPageFilter />
  304. <DatePageFilter />
  305. </PageFilterBar>
  306. <StyledSearchBarWrapper>{renderSearchBar()}</StyledSearchBarWrapper>
  307. </FilterActions>
  308. <PerformanceAtScaleContextProvider>
  309. <TransactionSummaryCharts
  310. organization={organization}
  311. location={location}
  312. eventView={eventView}
  313. totalValue={totalCount}
  314. currentFilter={spanOperationBreakdownFilter}
  315. withoutZerofill={hasPerformanceChartInterpolation}
  316. project={project}
  317. />
  318. <TransactionsList
  319. location={location}
  320. organization={organization}
  321. eventView={transactionsListEventView}
  322. {...openAllEventsProps}
  323. showTransactions={
  324. decodeScalar(
  325. location.query.showTransactions,
  326. TransactionFilterOptions.SLOW
  327. ) as TransactionFilterOptions
  328. }
  329. breakdown={decodeFilterFromLocation(location)}
  330. titles={transactionsListTitles}
  331. handleDropdownChange={handleTransactionsListSortChange}
  332. generateLink={{
  333. id: generateTransactionIdLink(transactionName, domainViewFilters.view),
  334. trace: generateTraceLink(
  335. eventView.normalizeDateSelection(location),
  336. domainViewFilters.view
  337. ),
  338. replayId: generateReplayLink(routes),
  339. 'profile.id': generateProfileLink(),
  340. }}
  341. handleCellAction={handleCellAction}
  342. {...getTransactionsListSort(location, {
  343. p95: totalValues?.['p95()'] ?? 0,
  344. spanOperationBreakdownFilter,
  345. })}
  346. domainViewFilters={domainViewFilters}
  347. forceLoading={isLoading}
  348. referrer="performance.transactions_summary"
  349. supportsInvestigationRule
  350. />
  351. </PerformanceAtScaleContextProvider>
  352. {!hasNewSpansUIFlag && (
  353. <SuspectSpans
  354. location={location}
  355. organization={organization}
  356. eventView={eventView}
  357. totals={
  358. defined(totalValues?.['count()'])
  359. ? {'count()': totalValues!['count()']}
  360. : null
  361. }
  362. projectId={projectId}
  363. transactionName={transactionName}
  364. />
  365. )}
  366. <TagExplorer
  367. eventView={eventView}
  368. organization={organization}
  369. location={location}
  370. projects={projects}
  371. transactionName={transactionName}
  372. currentFilter={spanOperationBreakdownFilter}
  373. domainViewFilters={domainViewFilters}
  374. />
  375. <SuspectFunctionsTable
  376. eventView={eventView}
  377. analyticsPageSource="performance_transaction"
  378. project={project}
  379. />
  380. <RelatedIssues
  381. organization={organization}
  382. location={location}
  383. transaction={transactionName}
  384. start={eventView.start}
  385. end={eventView.end}
  386. statsPeriod={eventView.statsPeriod}
  387. />
  388. </Layout.Main>
  389. <Layout.Side>
  390. <UserStats
  391. organization={organization}
  392. location={location}
  393. isLoading={isLoading}
  394. hasWebVitals={hasWebVitals}
  395. error={error}
  396. totals={totalValues}
  397. transactionName={transactionName}
  398. eventView={eventView}
  399. />
  400. {!isFrontendView && (
  401. <StatusBreakdown
  402. eventView={eventView}
  403. organization={organization}
  404. location={location}
  405. />
  406. )}
  407. <SidebarSpacer />
  408. <SidebarCharts
  409. organization={organization}
  410. isLoading={isLoading}
  411. error={error}
  412. totals={totalValues}
  413. eventView={eventView}
  414. transactionName={transactionName}
  415. />
  416. <SidebarSpacer />
  417. <Tags
  418. generateUrl={generateTagUrl}
  419. totalValues={totalCount}
  420. eventView={eventView}
  421. organization={organization}
  422. location={location}
  423. />
  424. </Layout.Side>
  425. </Fragment>
  426. );
  427. }
  428. function getFilterOptions({
  429. p95,
  430. spanOperationBreakdownFilter,
  431. }: {
  432. p95: number;
  433. spanOperationBreakdownFilter: SpanOperationBreakdownFilter;
  434. }): DropdownOption[] {
  435. if (spanOperationBreakdownFilter === SpanOperationBreakdownFilter.NONE) {
  436. return [
  437. {
  438. sort: {kind: 'asc', field: 'transaction.duration'},
  439. value: TransactionFilterOptions.FASTEST,
  440. label: t('Fastest Transactions'),
  441. },
  442. {
  443. query: p95 > 0 ? [['transaction.duration', `<=${p95.toFixed(0)}`]] : undefined,
  444. sort: {kind: 'desc', field: 'transaction.duration'},
  445. value: TransactionFilterOptions.SLOW,
  446. label: t('Slow Transactions (p95)'),
  447. },
  448. {
  449. sort: {kind: 'desc', field: 'transaction.duration'},
  450. value: TransactionFilterOptions.OUTLIER,
  451. label: t('Outlier Transactions (p100)'),
  452. },
  453. {
  454. sort: {kind: 'desc', field: 'timestamp'},
  455. value: TransactionFilterOptions.RECENT,
  456. label: t('Recent Transactions'),
  457. },
  458. ];
  459. }
  460. const field = filterToField(spanOperationBreakdownFilter)!;
  461. const operationName = spanOperationBreakdownFilter;
  462. return [
  463. {
  464. sort: {kind: 'asc', field},
  465. value: TransactionFilterOptions.FASTEST,
  466. label: t('Fastest %s Operations', operationName),
  467. },
  468. {
  469. query: p95 > 0 ? [['transaction.duration', `<=${p95.toFixed(0)}`]] : undefined,
  470. sort: {kind: 'desc', field},
  471. value: TransactionFilterOptions.SLOW,
  472. label: t('Slow %s Operations (p95)', operationName),
  473. },
  474. {
  475. sort: {kind: 'desc', field},
  476. value: TransactionFilterOptions.OUTLIER,
  477. label: t('Outlier %s Operations (p100)', operationName),
  478. },
  479. {
  480. sort: {kind: 'desc', field: 'timestamp'},
  481. value: TransactionFilterOptions.RECENT,
  482. label: t('Recent Transactions'),
  483. },
  484. ];
  485. }
  486. function getTransactionsListSort(
  487. location: Location,
  488. options: {p95: number; spanOperationBreakdownFilter: SpanOperationBreakdownFilter}
  489. ): {options: DropdownOption[]; selected: DropdownOption} {
  490. const sortOptions = getFilterOptions(options);
  491. const urlParam = decodeScalar(
  492. location.query.showTransactions,
  493. TransactionFilterOptions.SLOW
  494. );
  495. const selectedSort = sortOptions.find(opt => opt.value === urlParam) || sortOptions[0]!;
  496. return {selected: selectedSort, options: sortOptions};
  497. }
  498. function MetricsWarningIcon() {
  499. return (
  500. <Tooltip
  501. title={t(
  502. 'Based on your search criteria and sample rate, the events available may be limited.'
  503. )}
  504. >
  505. <StyledIconWarning
  506. data-test-id="search-metrics-fallback-warning"
  507. size="sm"
  508. color="warningText"
  509. />
  510. </Tooltip>
  511. );
  512. }
  513. const FilterActions = styled('div')`
  514. display: grid;
  515. gap: ${space(2)};
  516. margin-bottom: ${space(2)};
  517. @media (min-width: ${p => p.theme.breakpoints.small}) {
  518. grid-template-columns: repeat(2, min-content);
  519. }
  520. @media (min-width: ${p => p.theme.breakpoints.xlarge}) {
  521. grid-template-columns: auto auto 1fr;
  522. }
  523. `;
  524. const StyledSearchBarWrapper = styled('div')`
  525. @media (min-width: ${p => p.theme.breakpoints.small}) {
  526. order: 1;
  527. grid-column: 1/4;
  528. }
  529. @media (min-width: ${p => p.theme.breakpoints.xlarge}) {
  530. order: initial;
  531. grid-column: auto;
  532. }
  533. `;
  534. const StyledIconWarning = styled(IconWarning)`
  535. display: block;
  536. `;
  537. export default withProjects(SummaryContent);