sidebarCharts.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  1. import * as React from 'react';
  2. import {browserHistory, InjectedRouter, withRouter, WithRouterProps} from 'react-router';
  3. import {useTheme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import color from 'color';
  6. import {Location} from 'history';
  7. import ChartZoom from 'sentry/components/charts/chartZoom';
  8. import MarkPoint from 'sentry/components/charts/components/markPoint';
  9. import ErrorPanel from 'sentry/components/charts/errorPanel';
  10. import EventsRequest from 'sentry/components/charts/eventsRequest';
  11. import LineChart 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 {getUtcToLocalDateObject} from 'sentry/utils/dates';
  23. import {tooltipFormatter} from 'sentry/utils/discover/charts';
  24. import EventView from 'sentry/utils/discover/eventView';
  25. import {
  26. formatAbbreviatedNumber,
  27. formatFloat,
  28. formatPercentage,
  29. } from 'sentry/utils/formatters';
  30. import getDynamicText from 'sentry/utils/getDynamicText';
  31. import {TransactionMetric} from 'sentry/utils/metrics/fields';
  32. import MetricsRequest from 'sentry/utils/metrics/metricsRequest';
  33. import AnomaliesQuery from 'sentry/utils/performance/anomalies/anomaliesQuery';
  34. import {decodeScalar} from 'sentry/utils/queryString';
  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 {transformMetricsToArea} from 'sentry/views/performance/landing/widgets/transforms/transformMetricsToArea';
  39. import {
  40. anomaliesRouteWithQuery,
  41. ANOMALY_FLAG,
  42. anomalyToColor,
  43. } from '../transactionAnomalies/utils';
  44. type ContainerProps = WithRouterProps & {
  45. error: string | null;
  46. eventView: EventView;
  47. isLoading: boolean;
  48. location: Location;
  49. organization: Organization;
  50. totals: Record<string, number> | null;
  51. transactionName: string;
  52. isMetricsData?: boolean;
  53. };
  54. type Props = Pick<
  55. ContainerProps,
  56. 'organization' | 'isLoading' | 'error' | 'totals' | 'isMetricsData'
  57. > & {
  58. chartData: {
  59. chartOptions: Record<string, any>;
  60. errored: boolean;
  61. loading: boolean;
  62. reloading: boolean;
  63. series: React.ComponentProps<typeof LineChart>['series'];
  64. };
  65. eventView: EventView;
  66. location: Location;
  67. router: InjectedRouter;
  68. transactionName: string;
  69. utc: boolean;
  70. end?: Date;
  71. start?: Date;
  72. statsPeriod?: string | null;
  73. };
  74. function SidebarCharts({
  75. organization,
  76. isLoading,
  77. error,
  78. totals,
  79. start,
  80. end,
  81. utc,
  82. router,
  83. statsPeriod,
  84. chartData,
  85. isMetricsData,
  86. eventView,
  87. location,
  88. transactionName,
  89. }: Props) {
  90. const theme = useTheme();
  91. return (
  92. <RelativeBox>
  93. <ChartLabel top="0px">
  94. <ChartTitle>
  95. {t('Apdex')}
  96. <QuestionTooltip
  97. position="top"
  98. title={getTermHelp(organization, PERFORMANCE_TERM.APDEX)}
  99. size="sm"
  100. />
  101. </ChartTitle>
  102. {isMetricsData ? (
  103. 'TODO Metrics'
  104. ) : (
  105. <ChartSummaryValue
  106. data-test-id="apdex-summary-value"
  107. isLoading={isLoading}
  108. error={error}
  109. value={isMetricsData ? null : totals ? formatFloat(totals.apdex, 4) : null}
  110. />
  111. )}
  112. </ChartLabel>
  113. <ChartLabel top="160px">
  114. <ChartTitle>
  115. {t('Failure Rate')}
  116. <QuestionTooltip
  117. position="top"
  118. title={getTermHelp(organization, PERFORMANCE_TERM.FAILURE_RATE)}
  119. size="sm"
  120. />
  121. </ChartTitle>
  122. <ChartSummaryValue
  123. data-test-id="failure-rate-summary-value"
  124. isLoading={isLoading}
  125. error={error}
  126. value={totals ? formatPercentage(totals.failure_rate) : null}
  127. />
  128. </ChartLabel>
  129. <ChartLabel top="320px">
  130. <ChartTitle>
  131. {t('TPM')}
  132. <QuestionTooltip
  133. position="top"
  134. title={getTermHelp(organization, PERFORMANCE_TERM.TPM)}
  135. size="sm"
  136. />
  137. </ChartTitle>
  138. <ChartSummaryValue
  139. data-test-id="tpm-summary-value"
  140. isLoading={isLoading}
  141. error={error}
  142. value={totals ? tct('[tpm] tpm', {tpm: formatFloat(totals.tpm, 4)}) : null}
  143. />
  144. </ChartLabel>
  145. <AnomaliesQuery
  146. location={location}
  147. organization={organization}
  148. eventView={eventView}
  149. >
  150. {results => (
  151. <ChartZoom
  152. router={router}
  153. period={statsPeriod}
  154. start={start}
  155. end={end}
  156. utc={utc}
  157. xAxisIndex={[0, 1, 2]}
  158. >
  159. {zoomRenderProps => {
  160. const {errored, loading, reloading, chartOptions, series} = chartData;
  161. if (errored) {
  162. return (
  163. <ErrorPanel height="580px">
  164. <IconWarning color="gray300" size="lg" />
  165. </ErrorPanel>
  166. );
  167. }
  168. if (organization.features.includes(ANOMALY_FLAG)) {
  169. const epmSeries = series.find(
  170. s => s.seriesName.includes('epm') || s.seriesName.includes('tpm')
  171. );
  172. if (epmSeries && results.data) {
  173. epmSeries.markPoint = MarkPoint({
  174. data: results.data.anomalies.map(a => ({
  175. name: a.id,
  176. yAxis: epmSeries.data.find(({name}) => name > (a.end + a.start) / 2)
  177. ?.value,
  178. // TODO: the above is O(n*m), remove after we change the api to include the midpoint of y.
  179. xAxis: a.start,
  180. itemStyle: {
  181. borderColor: color(anomalyToColor(a.confidence, theme)).string(),
  182. color: color(anomalyToColor(a.confidence, theme))
  183. .alpha(0.2)
  184. .rgb()
  185. .string(),
  186. },
  187. onClick: () => {
  188. const target = anomaliesRouteWithQuery({
  189. orgSlug: organization.slug,
  190. query: location.query,
  191. projectID: decodeScalar(location.query.project),
  192. transaction: transactionName,
  193. });
  194. browserHistory.push(target);
  195. },
  196. })),
  197. symbol: 'circle',
  198. symbolSize: 16,
  199. });
  200. }
  201. }
  202. return (
  203. <TransitionChart loading={loading} reloading={reloading} height="580px">
  204. <TransparentLoadingMask visible={reloading} />
  205. {getDynamicText({
  206. value: (
  207. <LineChart {...zoomRenderProps} {...chartOptions} series={series} />
  208. ),
  209. fixed: <Placeholder height="480px" testId="skeleton-ui" />,
  210. })}
  211. </TransitionChart>
  212. );
  213. }}
  214. </ChartZoom>
  215. )}
  216. </AnomaliesQuery>
  217. </RelativeBox>
  218. );
  219. }
  220. function SidebarChartsContainer({
  221. location,
  222. eventView,
  223. organization,
  224. router,
  225. isLoading,
  226. error,
  227. totals,
  228. isMetricsData,
  229. transactionName,
  230. }: ContainerProps) {
  231. const api = useApi();
  232. const theme = useTheme();
  233. const colors = theme.charts.getColorPalette(3);
  234. const statsPeriod = eventView.statsPeriod;
  235. const start = eventView.start ? getUtcToLocalDateObject(eventView.start) : undefined;
  236. const end = eventView.end ? getUtcToLocalDateObject(eventView.end) : undefined;
  237. const project = eventView.project;
  238. const environment = eventView.environment;
  239. const query = eventView.query;
  240. const utc = normalizeDateTimeParams(location.query).utc === 'true';
  241. const axisLineConfig = {
  242. scale: true,
  243. axisLine: {
  244. show: false,
  245. },
  246. axisTick: {
  247. show: false,
  248. },
  249. splitLine: {
  250. show: false,
  251. },
  252. };
  253. const chartOptions = {
  254. height: 480,
  255. grid: [
  256. {
  257. top: '60px',
  258. left: '10px',
  259. right: '10px',
  260. height: '100px',
  261. },
  262. {
  263. top: '220px',
  264. left: '10px',
  265. right: '10px',
  266. height: '100px',
  267. },
  268. {
  269. top: '380px',
  270. left: '10px',
  271. right: '10px',
  272. height: '120px',
  273. },
  274. ],
  275. axisPointer: {
  276. // Link each x-axis together.
  277. link: [{xAxisIndex: [0, 1, 2]}],
  278. },
  279. xAxes: Array.from(new Array(3)).map((_i, index) => ({
  280. gridIndex: index,
  281. type: 'time' as const,
  282. show: false,
  283. })),
  284. yAxes: [
  285. {
  286. // apdex
  287. gridIndex: 0,
  288. interval: 0.2,
  289. axisLabel: {
  290. formatter: (value: number) => formatFloat(value, 1),
  291. color: theme.chartLabel,
  292. },
  293. ...axisLineConfig,
  294. },
  295. {
  296. // failure rate
  297. gridIndex: 1,
  298. splitNumber: 4,
  299. interval: 0.5,
  300. max: 1.0,
  301. axisLabel: {
  302. formatter: (value: number) => formatPercentage(value, 0),
  303. color: theme.chartLabel,
  304. },
  305. ...axisLineConfig,
  306. },
  307. {
  308. // throughput
  309. gridIndex: 2,
  310. splitNumber: 4,
  311. axisLabel: {
  312. formatter: formatAbbreviatedNumber,
  313. color: theme.chartLabel,
  314. },
  315. ...axisLineConfig,
  316. },
  317. ],
  318. utc,
  319. isGroupedByDate: true,
  320. showTimeInTooltip: true,
  321. colors: [colors[0], colors[1], colors[2]] as string[],
  322. tooltip: {
  323. trigger: 'axis' as const,
  324. truncate: 80,
  325. valueFormatter: tooltipFormatter,
  326. nameFormatter(value: string) {
  327. return value === 'epm()' ? 'tpm()' : value;
  328. },
  329. },
  330. };
  331. const requestCommonProps = {
  332. api,
  333. start,
  334. end,
  335. statsPeriod,
  336. project,
  337. environment,
  338. query,
  339. };
  340. const contentCommonProps = {
  341. organization,
  342. router,
  343. error,
  344. isLoading,
  345. start,
  346. end,
  347. utc,
  348. totals,
  349. };
  350. if (isMetricsData) {
  351. const fields = [`count(${TransactionMetric.TRANSACTION_DURATION})`];
  352. chartOptions.tooltip.nameFormatter = (name: string) => {
  353. return name === 'failure_rate()' ? fields[0] : name;
  354. };
  355. // Fetch failure rate metrics
  356. return (
  357. <MetricsRequest
  358. {...requestCommonProps}
  359. query={new MutableSearch(requestCommonProps.query).formatString()} // TODO(metrics): not all tags will be compatible with metrics
  360. orgSlug={organization.slug}
  361. field={fields}
  362. groupBy={['transaction.status']}
  363. >
  364. {failureRateRequestProps => {
  365. const failureRateData = transformMetricsToArea(
  366. {
  367. location,
  368. fields,
  369. },
  370. failureRateRequestProps,
  371. true
  372. );
  373. const failureRateSerie = failureRateData.data.map(values => ({
  374. ...values,
  375. seriesName: 'failure_rate()',
  376. yAxisIndex: 1,
  377. xAxisIndex: 1,
  378. }));
  379. // Fetch trasaction per minute metrics
  380. return (
  381. <MetricsRequest
  382. api={api}
  383. orgSlug={organization.slug}
  384. start={start}
  385. end={end}
  386. statsPeriod={statsPeriod}
  387. project={project}
  388. environment={environment}
  389. query={new MutableSearch(query).formatString()} // TODO(metrics): not all tags will be compatible with metrics
  390. field={fields}
  391. >
  392. {tpmRequestProps => {
  393. const tpmData = transformMetricsToArea(
  394. {
  395. location,
  396. fields,
  397. },
  398. tpmRequestProps
  399. );
  400. const tpmSerie = tpmData.data.map(values => ({
  401. ...values,
  402. yAxisIndex: 2,
  403. xAxisIndex: 2,
  404. }));
  405. return (
  406. <SidebarCharts
  407. {...contentCommonProps}
  408. location={location}
  409. transactionName={transactionName}
  410. totals={{
  411. failure_rate: failureRateData.dataMean?.[0].mean ?? 0,
  412. tpm: tpmData.dataMean?.[0].mean ?? 0,
  413. }}
  414. isLoading={failureRateRequestProps.loading || tpmRequestProps.loading}
  415. error={
  416. failureRateRequestProps.errored || tpmRequestProps.errored
  417. ? t('Error fetching metrics data')
  418. : null
  419. }
  420. eventView={eventView}
  421. chartData={{
  422. loading: failureRateRequestProps.loading || tpmRequestProps.loading,
  423. reloading:
  424. failureRateRequestProps.reloading || tpmRequestProps.reloading,
  425. errored: failureRateRequestProps.errored || tpmRequestProps.errored,
  426. chartOptions,
  427. series: [...failureRateSerie, ...tpmSerie],
  428. }}
  429. isMetricsData
  430. />
  431. );
  432. }}
  433. </MetricsRequest>
  434. );
  435. }}
  436. </MetricsRequest>
  437. );
  438. }
  439. const datetimeSelection = {
  440. start: start || null,
  441. end: end || null,
  442. period: statsPeriod,
  443. };
  444. return (
  445. <EventsRequest
  446. {...requestCommonProps}
  447. organization={organization}
  448. interval={getInterval(datetimeSelection)}
  449. showLoading={false}
  450. includePrevious={false}
  451. yAxis={['apdex()', 'failure_rate()', 'epm()']}
  452. partial
  453. referrer="api.performance.transaction-summary.sidebar-chart"
  454. >
  455. {({results, errored, loading, reloading}) => {
  456. const series = results
  457. ? results.map((values, i: number) => ({
  458. ...values,
  459. yAxisIndex: i,
  460. xAxisIndex: i,
  461. }))
  462. : [];
  463. return (
  464. <SidebarCharts
  465. {...contentCommonProps}
  466. transactionName={transactionName}
  467. location={location}
  468. eventView={eventView}
  469. chartData={{series, errored, loading, reloading, chartOptions}}
  470. />
  471. );
  472. }}
  473. </EventsRequest>
  474. );
  475. }
  476. type ChartValueProps = {
  477. 'data-test-id': string;
  478. error: string | null;
  479. isLoading: boolean;
  480. value: React.ReactNode;
  481. };
  482. function ChartSummaryValue({error, isLoading, value, ...props}: ChartValueProps) {
  483. if (error) {
  484. return <div {...props}>{'\u2014'}</div>;
  485. }
  486. if (isLoading) {
  487. return <Placeholder height="24px" {...props} />;
  488. }
  489. return <ChartValue {...props}>{value}</ChartValue>;
  490. }
  491. const RelativeBox = styled('div')`
  492. position: relative;
  493. `;
  494. const ChartTitle = styled(SectionHeading)`
  495. margin: 0;
  496. `;
  497. const ChartLabel = styled('div')<{top: string}>`
  498. position: absolute;
  499. top: ${p => p.top};
  500. z-index: 1;
  501. `;
  502. const ChartValue = styled('div')`
  503. font-size: ${p => p.theme.fontSizeExtraLarge};
  504. `;
  505. export default withRouter(SidebarChartsContainer);