eventsChart.tsx 20 KB

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