eventsChart.tsx 20 KB

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