eventsChart.tsx 19 KB

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