eventsChart.tsx 19 KB

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