sidebarMEPCharts.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  1. import {Fragment} from 'react';
  2. // eslint-disable-next-line no-restricted-imports
  3. import {InjectedRouter, withRouter, WithRouterProps} from 'react-router';
  4. import {useTheme} from '@emotion/react';
  5. import styled from '@emotion/styled';
  6. import {Location} from 'history';
  7. import {Client} from 'sentry/api';
  8. import ChartZoom from 'sentry/components/charts/chartZoom';
  9. import ErrorPanel from 'sentry/components/charts/errorPanel';
  10. import EventsRequest from 'sentry/components/charts/eventsRequest';
  11. import {LineChart, LineChartProps} from 'sentry/components/charts/lineChart';
  12. import {SectionHeading} from 'sentry/components/charts/styles';
  13. import TransitionChart from 'sentry/components/charts/transitionChart';
  14. import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
  15. import {getInterval} from 'sentry/components/charts/utils';
  16. import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
  17. import Placeholder from 'sentry/components/placeholder';
  18. import QuestionTooltip from 'sentry/components/questionTooltip';
  19. import {IconWarning} from 'sentry/icons';
  20. import {t, tct} from 'sentry/locale';
  21. import {Organization} from 'sentry/types';
  22. import {Series} from 'sentry/types/echarts';
  23. import {getUtcToLocalDateObject} from 'sentry/utils/dates';
  24. import {tooltipFormatter} from 'sentry/utils/discover/charts';
  25. import EventView from 'sentry/utils/discover/eventView';
  26. import {aggregateOutputType} from 'sentry/utils/discover/fields';
  27. import {QueryError} from 'sentry/utils/discover/genericDiscoverQuery';
  28. import {
  29. formatAbbreviatedNumber,
  30. formatFloat,
  31. formatPercentage,
  32. } from 'sentry/utils/formatters';
  33. import getDynamicText from 'sentry/utils/getDynamicText';
  34. import {
  35. MetricsCardinalityContext,
  36. useMetricsCardinalityContext,
  37. } from 'sentry/utils/performance/contexts/metricsCardinality';
  38. import {Theme} from 'sentry/utils/theme';
  39. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  40. import useApi from 'sentry/utils/useApi';
  41. import {getTermHelp, PERFORMANCE_TERM} from 'sentry/views/performance/data';
  42. import {getMetricOnlyQueryParams} from '../../landing/widgets/utils';
  43. type ContainerProps = WithRouterProps & {
  44. error: QueryError | null;
  45. eventView: EventView;
  46. isLoading: boolean;
  47. location: Location;
  48. organization: Organization;
  49. totals: Record<string, number> | null;
  50. transactionName: string;
  51. isShowingMetricsEventCount?: boolean;
  52. };
  53. interface ChartData {
  54. chartOptions: Omit<LineChartProps, 'series'>;
  55. errored: boolean;
  56. loading: boolean;
  57. reloading: boolean;
  58. series: LineChartProps['series'];
  59. }
  60. type Props = Pick<ContainerProps, 'organization' | 'isLoading' | 'error' | 'totals'> & {
  61. chartData: ChartData;
  62. eventView: EventView;
  63. location: Location;
  64. router: InjectedRouter;
  65. transactionName: string;
  66. utc: boolean;
  67. end?: Date;
  68. isShowingMetricsEventCount?: boolean;
  69. isUsingMEP?: boolean;
  70. metricsChartData?: ChartData;
  71. start?: Date;
  72. statsPeriod?: string | null;
  73. };
  74. function SidebarCharts(props: Props) {
  75. const {isShowingMetricsEventCount, start, end, utc, router, statsPeriod, chartData} =
  76. props;
  77. const placeholderHeight = isShowingMetricsEventCount ? '200px' : '300px';
  78. const boxHeight = isShowingMetricsEventCount ? '300px' : '400px';
  79. return (
  80. <RelativeBox>
  81. <ChartLabels {...props} />
  82. <ChartZoom
  83. router={router}
  84. period={statsPeriod}
  85. start={start}
  86. end={end}
  87. utc={utc}
  88. xAxisIndex={[0, 1, 2]}
  89. >
  90. {zoomRenderProps => {
  91. const {errored, loading, reloading, chartOptions, series} = chartData;
  92. if (errored) {
  93. return (
  94. <ErrorPanel height={boxHeight}>
  95. <IconWarning color="gray300" size="lg" />
  96. </ErrorPanel>
  97. );
  98. }
  99. return (
  100. <TransitionChart loading={loading} reloading={reloading} height={boxHeight}>
  101. <TransparentLoadingMask visible={reloading} />
  102. {getDynamicText({
  103. value: (
  104. <LineChart {...zoomRenderProps} {...chartOptions} series={series} />
  105. ),
  106. fixed: <Placeholder height={placeholderHeight} testId="skeleton-ui" />,
  107. })}
  108. </TransitionChart>
  109. );
  110. }}
  111. </ChartZoom>
  112. </RelativeBox>
  113. );
  114. }
  115. function getDatasetCounts({
  116. chartData,
  117. metricsChartData,
  118. metricsCardinality,
  119. }: {
  120. metricsCardinality: MetricsCardinalityContext;
  121. chartData?: ChartData;
  122. metricsChartData?: ChartData;
  123. }) {
  124. const transactionCount =
  125. chartData?.series[0]?.data.reduce((sum, {value}) => sum + value, 0) ?? 0;
  126. const metricsCount =
  127. metricsChartData?.series[0]?.data.reduce((sum, {value}) => sum + value, 0) ?? 0;
  128. const missingMetrics =
  129. (!metricsCount && transactionCount) ||
  130. metricsCount < transactionCount ||
  131. metricsCardinality.outcome?.forceTransactionsOnly;
  132. return {
  133. transactionCount,
  134. metricsCount,
  135. missingMetrics,
  136. };
  137. }
  138. function ChartLabels({
  139. organization,
  140. isLoading,
  141. totals,
  142. error,
  143. isShowingMetricsEventCount,
  144. chartData,
  145. metricsChartData,
  146. }: Props) {
  147. const useAggregateAlias = !organization.features.includes(
  148. 'performance-frontend-use-events-endpoint'
  149. );
  150. const metricsCardinality = useMetricsCardinalityContext();
  151. if (isShowingMetricsEventCount) {
  152. const {transactionCount, metricsCount, missingMetrics} = getDatasetCounts({
  153. chartData,
  154. metricsChartData,
  155. metricsCardinality,
  156. });
  157. return (
  158. <Fragment>
  159. <ChartLabel top="0px">
  160. <ChartTitle>
  161. {t('Count')}
  162. <QuestionTooltip
  163. position="top"
  164. title={t(
  165. 'The count of events for the selected time period, showing the indexed events powering this page with filters compared to total processed events.'
  166. )}
  167. size="sm"
  168. />
  169. </ChartTitle>
  170. <ChartSummaryValue
  171. data-test-id="tpm-summary-value"
  172. isLoading={isLoading}
  173. error={error}
  174. value={
  175. totals
  176. ? missingMetrics
  177. ? tct('[txnCount]', {
  178. txnCount: formatAbbreviatedNumber(transactionCount),
  179. })
  180. : tct('[txnCount] of [metricCount]', {
  181. txnCount: formatAbbreviatedNumber(transactionCount),
  182. metricCount: formatAbbreviatedNumber(metricsCount),
  183. })
  184. : null
  185. }
  186. />
  187. </ChartLabel>
  188. </Fragment>
  189. );
  190. }
  191. return (
  192. <Fragment>
  193. <ChartLabel top="0px">
  194. <ChartTitle>
  195. {t('Apdex')}
  196. <QuestionTooltip
  197. position="top"
  198. title={getTermHelp(organization, PERFORMANCE_TERM.APDEX)}
  199. size="sm"
  200. />
  201. </ChartTitle>
  202. <ChartSummaryValue
  203. data-test-id="apdex-summary-value"
  204. isLoading={isLoading}
  205. error={error}
  206. value={
  207. totals
  208. ? formatFloat(useAggregateAlias ? totals.apdex : totals['apdex()'], 4)
  209. : null
  210. }
  211. />
  212. </ChartLabel>
  213. <ChartLabel top="160px">
  214. <ChartTitle>
  215. {t('Failure Rate')}
  216. <QuestionTooltip
  217. position="top"
  218. title={getTermHelp(organization, PERFORMANCE_TERM.FAILURE_RATE)}
  219. size="sm"
  220. />
  221. </ChartTitle>
  222. <ChartSummaryValue
  223. data-test-id="failure-rate-summary-value"
  224. isLoading={isLoading}
  225. error={error}
  226. value={
  227. totals
  228. ? formatPercentage(
  229. useAggregateAlias ? totals.failure_rate : totals['failure_rate()']
  230. )
  231. : null
  232. }
  233. />
  234. </ChartLabel>
  235. </Fragment>
  236. );
  237. }
  238. function getSideChartsOptions({
  239. theme,
  240. utc,
  241. isShowingMetricsEventCount,
  242. }: {
  243. theme: Theme;
  244. utc: boolean;
  245. isShowingMetricsEventCount?: boolean;
  246. }) {
  247. const colors = theme.charts.getColorPalette(3);
  248. const axisLineConfig = {
  249. scale: true,
  250. axisLine: {
  251. show: false,
  252. },
  253. axisTick: {
  254. show: false,
  255. },
  256. splitLine: {
  257. show: false,
  258. },
  259. };
  260. if (isShowingMetricsEventCount) {
  261. const chartOptions: Omit<LineChartProps, 'series'> = {
  262. height: 200,
  263. grid: [
  264. {
  265. top: '60px',
  266. left: '10px',
  267. right: '10px',
  268. height: '160px',
  269. },
  270. ],
  271. axisPointer: {
  272. // Link each x-axis together.
  273. link: [{xAxisIndex: [0]}],
  274. },
  275. xAxes: Array.from(new Array(1)).map((_i, index) => ({
  276. gridIndex: index,
  277. type: 'time',
  278. show: false,
  279. })),
  280. yAxes: [
  281. {
  282. // throughput
  283. gridIndex: 0,
  284. splitNumber: 4,
  285. axisLabel: {
  286. formatter: formatAbbreviatedNumber,
  287. color: theme.chartLabel,
  288. },
  289. ...axisLineConfig,
  290. },
  291. {
  292. // throughput
  293. gridIndex: 0,
  294. splitNumber: 4,
  295. axisLabel: {
  296. formatter: formatAbbreviatedNumber,
  297. color: theme.chartLabel,
  298. },
  299. ...axisLineConfig,
  300. },
  301. ],
  302. utc,
  303. isGroupedByDate: true,
  304. showTimeInTooltip: true,
  305. colors: [colors[0], theme.gray300],
  306. tooltip: {
  307. trigger: 'axis',
  308. truncate: 80,
  309. valueFormatter: (value, label) =>
  310. tooltipFormatter(value, aggregateOutputType(label)),
  311. nameFormatter(value: string) {
  312. return value === 'epm()' ? 'tpm()' : value;
  313. },
  314. },
  315. };
  316. return chartOptions;
  317. }
  318. const chartOptions: Omit<LineChartProps, 'series'> = {
  319. height: 300,
  320. grid: [
  321. {
  322. top: '60px',
  323. left: '10px',
  324. right: '10px',
  325. height: '100px',
  326. },
  327. {
  328. top: '220px',
  329. left: '10px',
  330. right: '10px',
  331. height: '100px',
  332. },
  333. ],
  334. axisPointer: {
  335. // Link each x-axis together.
  336. link: [{xAxisIndex: [0, 1]}],
  337. },
  338. xAxes: Array.from(new Array(2)).map((_i, index) => ({
  339. gridIndex: index,
  340. type: 'time',
  341. show: false,
  342. })),
  343. yAxes: [
  344. {
  345. // apdex
  346. gridIndex: 0,
  347. interval: 0.2,
  348. axisLabel: {
  349. formatter: (value: number) => `${formatFloat(value, 1)}`,
  350. color: theme.chartLabel,
  351. },
  352. ...axisLineConfig,
  353. },
  354. {
  355. // failure rate
  356. gridIndex: 1,
  357. splitNumber: 4,
  358. interval: 0.5,
  359. max: 1.0,
  360. axisLabel: {
  361. formatter: (value: number) => formatPercentage(value, 0),
  362. color: theme.chartLabel,
  363. },
  364. ...axisLineConfig,
  365. },
  366. ],
  367. utc,
  368. isGroupedByDate: true,
  369. showTimeInTooltip: true,
  370. colors: [colors[1], colors[2]],
  371. tooltip: {
  372. trigger: 'axis',
  373. truncate: 80,
  374. valueFormatter: (value, label) =>
  375. tooltipFormatter(value, aggregateOutputType(label)),
  376. nameFormatter(value: string) {
  377. return value === 'epm()' ? 'tpm()' : value;
  378. },
  379. },
  380. };
  381. return chartOptions;
  382. }
  383. /**
  384. * Temporary function to remove 0 values from beginning and end of the metrics time series.
  385. * TODO(): Fix the data coming back from the api so it's consistent with existing count data.
  386. */
  387. function trimLeadingTrailingZeroCounts(series: Series | undefined) {
  388. if (!series?.data) {
  389. return undefined;
  390. }
  391. if (series.data[0] && series.data[0].value === 0) {
  392. series.data.shift();
  393. }
  394. if (
  395. series.data[series.data.length - 1] &&
  396. series.data[series.data.length - 1].value === 0
  397. ) {
  398. series.data.pop();
  399. }
  400. return series;
  401. }
  402. const ALLOWED_QUERY_KEYS = ['transaction.op', 'transaction'];
  403. function SidebarChartsContainer({
  404. location,
  405. eventView,
  406. organization,
  407. router,
  408. isLoading,
  409. error,
  410. totals,
  411. transactionName,
  412. isShowingMetricsEventCount,
  413. }: ContainerProps) {
  414. const api = useApi();
  415. const theme = useTheme();
  416. const metricsCardinality = useMetricsCardinalityContext();
  417. const statsPeriod = eventView.statsPeriod;
  418. const start = eventView.start ? getUtcToLocalDateObject(eventView.start) : undefined;
  419. const end = eventView.end ? getUtcToLocalDateObject(eventView.end) : undefined;
  420. const project = eventView.project;
  421. const environment = eventView.environment;
  422. const query = eventView.query;
  423. const utc = normalizeDateTimeParams(location.query).utc === 'true';
  424. const chartOptions = getSideChartsOptions({
  425. theme,
  426. utc,
  427. isShowingMetricsEventCount,
  428. });
  429. const requestCommonProps = {
  430. api,
  431. start,
  432. end,
  433. period: statsPeriod,
  434. project,
  435. environment,
  436. query,
  437. };
  438. const contentCommonProps = {
  439. organization,
  440. router,
  441. error,
  442. isLoading,
  443. start,
  444. end,
  445. utc,
  446. totals,
  447. };
  448. const datetimeSelection = {
  449. start: start || null,
  450. end: end || null,
  451. period: statsPeriod,
  452. };
  453. const yAxis = isShowingMetricsEventCount
  454. ? ['count()', 'tpm()']
  455. : ['apdex()', 'failure_rate()'];
  456. const requestProps = {
  457. ...requestCommonProps,
  458. organization,
  459. interval: getInterval(datetimeSelection),
  460. showLoading: false,
  461. includePrevious: false,
  462. yAxis,
  463. partial: true,
  464. referrer: 'api.performance.transaction-summary.sidebar-chart',
  465. };
  466. return (
  467. <EventsRequest {...requestProps}>
  468. {({results: eventsResults, errored, loading, reloading}) => {
  469. const _results = isShowingMetricsEventCount
  470. ? (eventsResults || []).slice(0, 1)
  471. : eventsResults;
  472. const series = _results
  473. ? _results.map((values, i: number) => ({
  474. ...values,
  475. yAxisIndex: i,
  476. xAxisIndex: i,
  477. }))
  478. : [];
  479. const metricsCompatibleQueryProps = {...requestProps};
  480. const eventsQuery = new MutableSearch(query);
  481. const compatibleQuery = new MutableSearch('');
  482. for (const queryKey of ALLOWED_QUERY_KEYS) {
  483. if (eventsQuery.hasFilter(queryKey)) {
  484. compatibleQuery.setFilterValues(
  485. queryKey,
  486. eventsQuery.getFilterValues(queryKey)
  487. );
  488. }
  489. }
  490. metricsCompatibleQueryProps.query = compatibleQuery.formatString();
  491. return (
  492. <EventsRequest
  493. {...metricsCompatibleQueryProps}
  494. api={new Client()}
  495. queryExtras={getMetricOnlyQueryParams()}
  496. >
  497. {metricsChartData => {
  498. const metricSeries = metricsChartData.results
  499. ? metricsChartData.results.map((values, i: number) => ({
  500. ...values,
  501. yAxisIndex: i,
  502. xAxisIndex: i,
  503. }))
  504. : [];
  505. const chartData = {series, errored, loading, reloading, chartOptions};
  506. const _metricsChartData = {
  507. ...metricsChartData,
  508. series: metricSeries,
  509. chartOptions,
  510. };
  511. if (isShowingMetricsEventCount && metricSeries.length) {
  512. const countSeries = series[0];
  513. if (countSeries) {
  514. countSeries.seriesName = t('Indexed Events');
  515. const trimmed = trimLeadingTrailingZeroCounts(countSeries);
  516. if (trimmed) {
  517. series[0] = {...countSeries, ...trimmed};
  518. }
  519. }
  520. const {missingMetrics} = getDatasetCounts({
  521. chartData,
  522. metricsChartData: _metricsChartData,
  523. metricsCardinality,
  524. });
  525. const metricsCountSeries = metricSeries[0];
  526. if (!missingMetrics) {
  527. if (metricsCountSeries) {
  528. metricsCountSeries.seriesName = t('Processed Events');
  529. metricsCountSeries.lineStyle = {
  530. type: 'dashed',
  531. width: 1.5,
  532. };
  533. const trimmed = trimLeadingTrailingZeroCounts(metricsCountSeries);
  534. if (trimmed) {
  535. metricSeries[0] = {...metricsCountSeries, ...trimmed};
  536. }
  537. }
  538. series.push(metricsCountSeries);
  539. }
  540. }
  541. return (
  542. <SidebarCharts
  543. {...contentCommonProps}
  544. transactionName={transactionName}
  545. location={location}
  546. eventView={eventView}
  547. chartData={chartData}
  548. isShowingMetricsEventCount={isShowingMetricsEventCount}
  549. metricsChartData={_metricsChartData}
  550. />
  551. );
  552. }}
  553. </EventsRequest>
  554. );
  555. }}
  556. </EventsRequest>
  557. );
  558. }
  559. type ChartValueProps = {
  560. 'data-test-id': string;
  561. error: QueryError | null;
  562. isLoading: boolean;
  563. value: React.ReactNode;
  564. };
  565. function ChartSummaryValue({error, isLoading, value, ...props}: ChartValueProps) {
  566. if (error) {
  567. return <div {...props}>{'\u2014'}</div>;
  568. }
  569. if (isLoading) {
  570. return <Placeholder height="24px" {...props} />;
  571. }
  572. return <ChartValue {...props}>{value}</ChartValue>;
  573. }
  574. const RelativeBox = styled('div')`
  575. position: relative;
  576. `;
  577. const ChartTitle = styled(SectionHeading)`
  578. margin: 0;
  579. `;
  580. const ChartLabel = styled('div')<{top: string}>`
  581. position: absolute;
  582. top: ${p => p.top};
  583. z-index: 1;
  584. `;
  585. const ChartValue = styled('div')`
  586. font-size: ${p => p.theme.fontSizeExtraLarge};
  587. `;
  588. export default withRouter(SidebarChartsContainer);