sidebarMEPCharts.tsx 17 KB

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