eventsChart.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688
  1. import * as React from 'react';
  2. import {InjectedRouter} from 'react-router';
  3. import {withTheme} from '@emotion/react';
  4. import type {
  5. EChartsOption,
  6. LegendComponentOption,
  7. XAXisComponentOption,
  8. YAXisComponentOption,
  9. } from 'echarts';
  10. import {Query} from 'history';
  11. import isEqual from 'lodash/isEqual';
  12. import {Client} from 'sentry/api';
  13. import {AreaChart, AreaChartProps} from 'sentry/components/charts/areaChart';
  14. import {BarChart, BarChartProps} from 'sentry/components/charts/barChart';
  15. import ChartZoom, {ZoomRenderProps} from 'sentry/components/charts/chartZoom';
  16. import ErrorPanel from 'sentry/components/charts/errorPanel';
  17. import {LineChart, LineChartProps} from 'sentry/components/charts/lineChart';
  18. import ReleaseSeries from 'sentry/components/charts/releaseSeries';
  19. import TransitionChart from 'sentry/components/charts/transitionChart';
  20. import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
  21. import {
  22. getInterval,
  23. processTableResults,
  24. RELEASE_LINES_THRESHOLD,
  25. } from 'sentry/components/charts/utils';
  26. import {WorldMapChart, WorldMapChartProps} from 'sentry/components/charts/worldMapChart';
  27. import {IconWarning} from 'sentry/icons';
  28. import {t} from 'sentry/locale';
  29. import {DateString, OrganizationSummary} from 'sentry/types';
  30. import {Series} from 'sentry/types/echarts';
  31. import {defined} from 'sentry/utils';
  32. import {axisLabelFormatter, tooltipFormatter} from 'sentry/utils/discover/charts';
  33. import {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
  34. import {
  35. aggregateMultiPlotType,
  36. aggregateOutputType,
  37. getEquation,
  38. isEquation,
  39. } from 'sentry/utils/discover/fields';
  40. import {decodeList} from 'sentry/utils/queryString';
  41. import {Theme} from 'sentry/utils/theme';
  42. import EventsGeoRequest from './eventsGeoRequest';
  43. import EventsRequest from './eventsRequest';
  44. type ChartComponent =
  45. | React.ComponentType<BarChartProps>
  46. | React.ComponentType<AreaChartProps>
  47. | React.ComponentType<LineChartProps>
  48. | React.ComponentType<WorldMapChartProps>;
  49. type ChartProps = {
  50. currentSeriesNames: string[];
  51. loading: boolean;
  52. previousSeriesNames: string[];
  53. reloading: boolean;
  54. stacked: boolean;
  55. tableData: TableDataWithTitle[];
  56. theme: Theme;
  57. timeseriesData: Series[];
  58. yAxis: string;
  59. zoomRenderProps: ZoomRenderProps;
  60. chartComponent?: ChartComponent;
  61. chartOptions?: Omit<EChartsOption, 'xAxis' | 'yAxis'> & {
  62. xAxis?: XAXisComponentOption;
  63. yAxis?: YAXisComponentOption;
  64. };
  65. colors?: string[];
  66. /**
  67. * By default, only the release series is disableable. This adds
  68. * a list of series names that are also disableable.
  69. */
  70. disableableSeries?: string[];
  71. fromDiscover?: boolean;
  72. height?: number;
  73. interval?: string;
  74. legendOptions?: LegendComponentOption;
  75. minutesThresholdToDisplaySeconds?: number;
  76. previousSeriesTransformer?: (series?: Series | null) => Series | null | undefined;
  77. previousTimeseriesData?: Series[] | null;
  78. referrer?: string;
  79. releaseSeries?: Series[];
  80. /**
  81. * A callback to allow for post-processing of the series data.
  82. * Can be used to rename series or even insert a new series.
  83. */
  84. seriesTransformer?: (series: Series[]) => Series[];
  85. showDaily?: boolean;
  86. showLegend?: boolean;
  87. timeframe?: {end: number; start: number};
  88. topEvents?: number;
  89. };
  90. type State = {
  91. forceUpdate: boolean;
  92. seriesSelection: Record<string, boolean>;
  93. };
  94. class Chart extends React.Component<ChartProps, State> {
  95. state: State = {
  96. seriesSelection: {},
  97. forceUpdate: false,
  98. };
  99. shouldComponentUpdate(nextProps: ChartProps, nextState: State) {
  100. if (nextState.forceUpdate) {
  101. return true;
  102. }
  103. if (!isEqual(this.state.seriesSelection, nextState.seriesSelection)) {
  104. return true;
  105. }
  106. if (nextProps.reloading || !nextProps.timeseriesData) {
  107. return false;
  108. }
  109. if (
  110. isEqual(this.props.timeseriesData, nextProps.timeseriesData) &&
  111. isEqual(this.props.releaseSeries, nextProps.releaseSeries) &&
  112. isEqual(this.props.previousTimeseriesData, nextProps.previousTimeseriesData) &&
  113. isEqual(this.props.tableData, nextProps.tableData)
  114. ) {
  115. return false;
  116. }
  117. return true;
  118. }
  119. getChartComponent(): ChartComponent {
  120. const {showDaily, timeseriesData, yAxis, chartComponent} = this.props;
  121. if (defined(chartComponent)) {
  122. return chartComponent;
  123. }
  124. if (showDaily) {
  125. return BarChart;
  126. }
  127. if (timeseriesData.length > 1) {
  128. switch (aggregateMultiPlotType(yAxis)) {
  129. case 'line':
  130. return LineChart;
  131. case 'area':
  132. return AreaChart;
  133. default:
  134. throw new Error(`Unknown multi plot type for ${yAxis}`);
  135. }
  136. }
  137. return AreaChart;
  138. }
  139. handleLegendSelectChanged = legendChange => {
  140. const {disableableSeries = []} = this.props;
  141. const {selected} = legendChange;
  142. const seriesSelection = Object.keys(selected).reduce((state, key) => {
  143. // we only want them to be able to disable the Releases&Other series,
  144. // and not any of the other possible series here
  145. const disableable =
  146. ['Releases', 'Other'].includes(key) || disableableSeries.includes(key);
  147. state[key] = disableable ? selected[key] : true;
  148. return state;
  149. }, {});
  150. // we have to force an update here otherwise ECharts will
  151. // update its internal state and disable the series
  152. this.setState({seriesSelection, forceUpdate: true}, () =>
  153. this.setState({forceUpdate: false})
  154. );
  155. };
  156. render() {
  157. const {
  158. theme,
  159. loading: _loading,
  160. reloading: _reloading,
  161. yAxis,
  162. releaseSeries,
  163. zoomRenderProps,
  164. timeseriesData,
  165. previousTimeseriesData,
  166. showLegend,
  167. legendOptions,
  168. chartOptions: chartOptionsProp,
  169. currentSeriesNames,
  170. previousSeriesNames,
  171. seriesTransformer,
  172. previousSeriesTransformer,
  173. colors,
  174. height,
  175. timeframe,
  176. topEvents,
  177. tableData,
  178. fromDiscover,
  179. ...props
  180. } = this.props;
  181. const {seriesSelection} = this.state;
  182. let Component = this.getChartComponent();
  183. if (Component === WorldMapChart) {
  184. const {data, title} = processTableResults(tableData);
  185. const tableSeries = [
  186. {
  187. seriesName: title,
  188. data,
  189. },
  190. ];
  191. return <WorldMapChart series={tableSeries} fromDiscover={fromDiscover} />;
  192. }
  193. Component = Component as Exclude<
  194. ChartComponent,
  195. React.ComponentType<WorldMapChartProps>
  196. >;
  197. const data = [
  198. ...(currentSeriesNames.length > 0 ? currentSeriesNames : [t('Current')]),
  199. ...(previousSeriesNames.length > 0 ? previousSeriesNames : [t('Previous')]),
  200. ];
  201. const releasesLegend = t('Releases');
  202. const hasOther = topEvents && topEvents + 1 === timeseriesData.length;
  203. if (hasOther) {
  204. data.push('Other');
  205. }
  206. if (Array.isArray(releaseSeries)) {
  207. data.push(releasesLegend);
  208. }
  209. // Temporary fix to improve performance on pages with a high number of releases.
  210. const releases = releaseSeries && releaseSeries[0];
  211. const hideReleasesByDefault =
  212. Array.isArray(releaseSeries) &&
  213. (releases as any)?.markLine?.data &&
  214. (releases as any).markLine.data.length >= RELEASE_LINES_THRESHOLD;
  215. const selected = !Array.isArray(releaseSeries)
  216. ? seriesSelection
  217. : Object.keys(seriesSelection).length === 0 && hideReleasesByDefault
  218. ? {[releasesLegend]: false}
  219. : seriesSelection;
  220. const legend = showLegend
  221. ? {
  222. right: 16,
  223. top: 12,
  224. data,
  225. selected,
  226. ...(legendOptions ?? {}),
  227. }
  228. : undefined;
  229. let series = Array.isArray(releaseSeries)
  230. ? [...timeseriesData, ...releaseSeries]
  231. : timeseriesData;
  232. let previousSeries = previousTimeseriesData;
  233. if (seriesTransformer) {
  234. series = seriesTransformer(series);
  235. }
  236. if (previousSeriesTransformer) {
  237. previousSeries = previousSeries?.map(
  238. prev => previousSeriesTransformer(prev) as Series
  239. );
  240. }
  241. const chartColors = timeseriesData.length
  242. ? colors?.slice(0, series.length) ?? [
  243. ...theme.charts.getColorPalette(timeseriesData.length - 2 - (hasOther ? 1 : 0)),
  244. ]
  245. : undefined;
  246. if (chartColors && chartColors.length && hasOther) {
  247. chartColors.push(theme.chartOther);
  248. }
  249. const chartOptions = {
  250. colors: chartColors,
  251. grid: {
  252. left: '24px',
  253. right: '24px',
  254. top: '32px',
  255. bottom: '12px',
  256. },
  257. seriesOptions: {
  258. showSymbol: false,
  259. },
  260. tooltip: {
  261. trigger: 'axis' as const,
  262. truncate: 80,
  263. valueFormatter: (value: number) =>
  264. tooltipFormatter(value, aggregateOutputType(yAxis)),
  265. },
  266. xAxis: timeframe
  267. ? {
  268. min: timeframe.start,
  269. max: timeframe.end,
  270. }
  271. : undefined,
  272. yAxis: {
  273. axisLabel: {
  274. color: theme.chartLabel,
  275. formatter: (value: number) =>
  276. axisLabelFormatter(value, aggregateOutputType(yAxis)),
  277. },
  278. },
  279. ...(chartOptionsProp ?? {}),
  280. animation: typeof Component === typeof BarChart ? false : undefined,
  281. };
  282. return (
  283. <Component
  284. {...props}
  285. {...zoomRenderProps}
  286. {...chartOptions}
  287. legend={legend}
  288. onLegendSelectChanged={this.handleLegendSelectChanged}
  289. series={series}
  290. previousPeriod={previousSeries ? previousSeries : undefined}
  291. height={height}
  292. />
  293. );
  294. }
  295. }
  296. const ThemedChart = withTheme(Chart);
  297. export type EventsChartProps = {
  298. api: Client;
  299. /**
  300. * Absolute end date.
  301. */
  302. end: DateString;
  303. /**
  304. * Environment condition.
  305. */
  306. environments: string[];
  307. organization: OrganizationSummary;
  308. /**
  309. * Project ids
  310. */
  311. projects: number[];
  312. /**
  313. * The discover query string to find events with.
  314. */
  315. query: string;
  316. router: InjectedRouter;
  317. /**
  318. * Absolute start date.
  319. */
  320. start: DateString;
  321. /**
  322. * The aggregate/metric to plot.
  323. */
  324. yAxis: string | string[];
  325. /**
  326. * Markup for optional chart header
  327. */
  328. chartHeader?: React.ReactNode;
  329. /**
  330. * Override the default color palette.
  331. */
  332. colors?: string[];
  333. confirmedQuery?: boolean;
  334. /**
  335. * Name of the series
  336. */
  337. currentSeriesName?: string;
  338. /**
  339. * Don't show the previous period's data. Will automatically disable
  340. * when start/end are used.
  341. */
  342. disablePrevious?: boolean;
  343. /**
  344. * Don't show the release marklines.
  345. */
  346. disableReleases?: boolean;
  347. /**
  348. * A list of release names to visually emphasize. Can only be used when `disableReleases` is false.
  349. */
  350. emphasizeReleases?: string[];
  351. /**
  352. * The fields that act as grouping conditions when generating a topEvents chart.
  353. */
  354. field?: string[];
  355. /**
  356. * The interval resolution for a chart e.g. 1m, 5m, 1d
  357. */
  358. interval?: string;
  359. /**
  360. * Order condition when showing topEvents
  361. */
  362. orderby?: string;
  363. /**
  364. * Relative datetime expression. eg. 14d
  365. */
  366. period?: string | null;
  367. preserveReleaseQueryParams?: boolean;
  368. /**
  369. * Name of the previous series
  370. */
  371. previousSeriesName?: string;
  372. /**
  373. * A unique name for what's triggering this request, see organization_events_stats for an allowlist
  374. */
  375. referrer?: string;
  376. releaseQueryExtra?: Query;
  377. /**
  378. * Override the interval calculation and show daily results.
  379. */
  380. showDaily?: boolean;
  381. /**
  382. * Fetch n top events as dictated by the field and orderby props.
  383. */
  384. topEvents?: number;
  385. /**
  386. * Chart zoom will change 'pageStart' instead of 'start'
  387. */
  388. usePageZoom?: boolean;
  389. /**
  390. * Should datetimes be formatted in UTC?
  391. */
  392. utc?: boolean | null;
  393. /**
  394. * Whether or not to zerofill results
  395. */
  396. withoutZerofill?: boolean;
  397. } & Pick<
  398. ChartProps,
  399. | 'seriesTransformer'
  400. | 'previousSeriesTransformer'
  401. | 'showLegend'
  402. | 'minutesThresholdToDisplaySeconds'
  403. | 'disableableSeries'
  404. | 'legendOptions'
  405. | 'chartOptions'
  406. | 'chartComponent'
  407. | 'height'
  408. | 'fromDiscover'
  409. >;
  410. type ChartDataProps = {
  411. errored: boolean;
  412. loading: boolean;
  413. reloading: boolean;
  414. zoomRenderProps: ZoomRenderProps;
  415. previousTimeseriesData?: Series[] | null;
  416. releaseSeries?: Series[];
  417. results?: Series[];
  418. tableData?: TableDataWithTitle[];
  419. timeframe?: {end: number; start: number};
  420. timeseriesData?: Series[];
  421. topEvents?: number;
  422. };
  423. class EventsChart extends React.Component<EventsChartProps> {
  424. isStacked() {
  425. const {topEvents, yAxis} = this.props;
  426. return (
  427. (typeof topEvents === 'number' && topEvents > 0) ||
  428. (Array.isArray(yAxis) && yAxis.length > 1)
  429. );
  430. }
  431. render() {
  432. const {
  433. api,
  434. organization,
  435. period,
  436. utc,
  437. query,
  438. router,
  439. start,
  440. end,
  441. projects,
  442. environments,
  443. showLegend,
  444. minutesThresholdToDisplaySeconds,
  445. yAxis,
  446. disablePrevious,
  447. disableReleases,
  448. emphasizeReleases,
  449. currentSeriesName: currentName,
  450. previousSeriesName: previousName,
  451. seriesTransformer,
  452. previousSeriesTransformer,
  453. field,
  454. interval,
  455. showDaily,
  456. topEvents,
  457. orderby,
  458. confirmedQuery,
  459. colors,
  460. chartHeader,
  461. legendOptions,
  462. chartOptions,
  463. preserveReleaseQueryParams,
  464. releaseQueryExtra,
  465. disableableSeries,
  466. chartComponent,
  467. usePageZoom,
  468. height,
  469. withoutZerofill,
  470. fromDiscover,
  471. ...props
  472. } = this.props;
  473. // Include previous only on relative dates (defaults to relative if no start and end)
  474. const includePrevious = !disablePrevious && !start && !end;
  475. const yAxisArray = decodeList(yAxis);
  476. const yAxisSeriesNames = yAxisArray.map(name => {
  477. let yAxisLabel = name && isEquation(name) ? getEquation(name) : name;
  478. if (yAxisLabel && yAxisLabel.length > 60) {
  479. yAxisLabel = yAxisLabel.substr(0, 60) + '...';
  480. }
  481. return yAxisLabel;
  482. });
  483. const previousSeriesNames = previousName
  484. ? [previousName]
  485. : yAxisSeriesNames.map(name => t('previous %s', name));
  486. const currentSeriesNames = currentName ? [currentName] : yAxisSeriesNames;
  487. const intervalVal = showDaily ? '1d' : interval || getInterval(this.props, 'high');
  488. let chartImplementation = ({
  489. zoomRenderProps,
  490. releaseSeries,
  491. errored,
  492. loading,
  493. reloading,
  494. results,
  495. timeseriesData,
  496. previousTimeseriesData,
  497. timeframe,
  498. tableData,
  499. }: ChartDataProps) => {
  500. if (errored) {
  501. return (
  502. <ErrorPanel>
  503. <IconWarning color="gray300" size="lg" />
  504. </ErrorPanel>
  505. );
  506. }
  507. const seriesData = results ? results : timeseriesData;
  508. return (
  509. <TransitionChart
  510. loading={loading}
  511. reloading={reloading}
  512. height={height ? `${height}px` : undefined}
  513. >
  514. <TransparentLoadingMask visible={reloading} />
  515. {React.isValidElement(chartHeader) && chartHeader}
  516. <ThemedChart
  517. zoomRenderProps={zoomRenderProps}
  518. loading={loading}
  519. reloading={reloading}
  520. showLegend={showLegend}
  521. minutesThresholdToDisplaySeconds={minutesThresholdToDisplaySeconds}
  522. releaseSeries={releaseSeries || []}
  523. timeseriesData={seriesData ?? []}
  524. previousTimeseriesData={previousTimeseriesData}
  525. currentSeriesNames={currentSeriesNames}
  526. previousSeriesNames={previousSeriesNames}
  527. seriesTransformer={seriesTransformer}
  528. previousSeriesTransformer={previousSeriesTransformer}
  529. stacked={this.isStacked()}
  530. yAxis={yAxisArray[0]}
  531. showDaily={showDaily}
  532. colors={colors}
  533. legendOptions={legendOptions}
  534. chartOptions={chartOptions}
  535. disableableSeries={disableableSeries}
  536. chartComponent={chartComponent}
  537. height={height}
  538. timeframe={timeframe}
  539. topEvents={topEvents}
  540. tableData={tableData ?? []}
  541. fromDiscover={fromDiscover}
  542. />
  543. </TransitionChart>
  544. );
  545. };
  546. if (!disableReleases) {
  547. const previousChart = chartImplementation;
  548. chartImplementation = chartProps => (
  549. <ReleaseSeries
  550. utc={utc}
  551. period={period}
  552. start={start}
  553. end={end}
  554. projects={projects}
  555. environments={environments}
  556. emphasizeReleases={emphasizeReleases}
  557. preserveQueryParams={preserveReleaseQueryParams}
  558. queryExtra={releaseQueryExtra}
  559. >
  560. {({releaseSeries}) => previousChart({...chartProps, releaseSeries})}
  561. </ReleaseSeries>
  562. );
  563. }
  564. return (
  565. <ChartZoom
  566. router={router}
  567. period={period}
  568. start={start}
  569. end={end}
  570. utc={utc}
  571. usePageDate={usePageZoom}
  572. {...props}
  573. >
  574. {zoomRenderProps => {
  575. if (chartComponent === WorldMapChart) {
  576. return (
  577. <EventsGeoRequest
  578. api={api}
  579. organization={organization}
  580. yAxis={yAxis}
  581. query={query}
  582. orderby={orderby}
  583. projects={projects}
  584. period={period}
  585. start={start}
  586. end={end}
  587. environments={environments}
  588. referrer={props.referrer}
  589. >
  590. {({errored, loading, reloading, tableData}) =>
  591. chartImplementation({
  592. errored,
  593. loading,
  594. reloading,
  595. zoomRenderProps,
  596. tableData,
  597. })
  598. }
  599. </EventsGeoRequest>
  600. );
  601. }
  602. return (
  603. <EventsRequest
  604. {...props}
  605. api={api}
  606. organization={organization}
  607. period={period}
  608. project={projects}
  609. environment={environments}
  610. start={start}
  611. end={end}
  612. interval={intervalVal}
  613. query={query}
  614. includePrevious={includePrevious}
  615. currentSeriesNames={currentSeriesNames}
  616. previousSeriesNames={previousSeriesNames}
  617. yAxis={yAxis}
  618. field={field}
  619. orderby={orderby}
  620. topEvents={topEvents}
  621. confirmedQuery={confirmedQuery}
  622. partial
  623. // Cannot do interpolation when stacking series
  624. withoutZerofill={withoutZerofill && !this.isStacked()}
  625. >
  626. {eventData => {
  627. return chartImplementation({
  628. ...eventData,
  629. zoomRenderProps,
  630. });
  631. }}
  632. </EventsRequest>
  633. );
  634. }}
  635. </ChartZoom>
  636. );
  637. }
  638. }
  639. export default EventsChart;