eventsChart.tsx 19 KB

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