chart.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. import {forwardRef, memo, useEffect, useMemo, useRef} from 'react';
  2. import styled from '@emotion/styled';
  3. import Color from 'color';
  4. import type {SeriesOption} from 'echarts';
  5. import * as echarts from 'echarts/core';
  6. import {CanvasRenderer} from 'echarts/renderers';
  7. import isNil from 'lodash/isNil';
  8. import omitBy from 'lodash/omitBy';
  9. import {transformToAreaSeries} from 'sentry/components/charts/areaChart';
  10. import {transformToBarSeries} from 'sentry/components/charts/barChart';
  11. import BaseChart from 'sentry/components/charts/baseChart';
  12. import {
  13. defaultFormatAxisLabel,
  14. getFormatter,
  15. } from 'sentry/components/charts/components/tooltip';
  16. import {transformToLineSeries} from 'sentry/components/charts/lineChart';
  17. import ScatterSeries from 'sentry/components/charts/series/scatterSeries';
  18. import ChartZoom from 'sentry/components/charts/useChartZoom';
  19. import {isChartHovered} from 'sentry/components/charts/utils';
  20. import {t} from 'sentry/locale';
  21. import type {ReactEchartsRef} from 'sentry/types/echarts';
  22. import mergeRefs from 'sentry/utils/mergeRefs';
  23. import {formatMetricUsingUnit} from 'sentry/utils/metrics/formatters';
  24. import {MetricDisplayType} from 'sentry/utils/metrics/types';
  25. import usePageFilters from 'sentry/utils/usePageFilters';
  26. import type {CombinedMetricChartProps, Series} from 'sentry/views/metrics/chart/types';
  27. import type {UseFocusAreaResult} from 'sentry/views/metrics/chart/useFocusArea';
  28. import type {UseMetricSamplesResult} from 'sentry/views/metrics/chart/useMetricChartSamples';
  29. import type {UseMetricReleasesResult} from 'sentry/views/metrics/chart/useMetricReleases';
  30. const MAIN_X_AXIS_ID = 'xAxis';
  31. type ChartProps = {
  32. displayType: MetricDisplayType;
  33. series: Series[];
  34. additionalSeries?: SeriesOption[];
  35. enableZoom?: boolean;
  36. focusArea?: UseFocusAreaResult;
  37. group?: string;
  38. height?: number;
  39. releases?: UseMetricReleasesResult;
  40. samples?: UseMetricSamplesResult;
  41. };
  42. // We need to enable canvas renderer for echarts before we use it here.
  43. // Once we use it in more places, this should probably move to a more global place
  44. // But for now we keep it here to not invluence the bundle size of the main chunks.
  45. echarts.use(CanvasRenderer);
  46. function isNonZeroValue(value: number | null) {
  47. return value !== null && value !== 0;
  48. }
  49. function addSeriesPadding(data: Series['data']) {
  50. const hasNonZeroSibling = (index: number) => {
  51. return (
  52. isNonZeroValue(data[index - 1]?.value) || isNonZeroValue(data[index + 1]?.value)
  53. );
  54. };
  55. const paddingIndices = new Set<number>();
  56. return {
  57. data: data.map(({name, value}, index) => {
  58. const shouldAddPadding = value === null && hasNonZeroSibling(index);
  59. if (shouldAddPadding) {
  60. paddingIndices.add(index);
  61. }
  62. return {
  63. name,
  64. value: shouldAddPadding ? 0 : value,
  65. };
  66. }),
  67. paddingIndices,
  68. };
  69. }
  70. export const MetricChart = memo(
  71. forwardRef<ReactEchartsRef, ChartProps>(
  72. (
  73. {
  74. series,
  75. displayType,
  76. height,
  77. group,
  78. samples,
  79. focusArea,
  80. enableZoom,
  81. releases,
  82. additionalSeries,
  83. },
  84. forwardedRef
  85. ) => {
  86. const chartRef = useRef<ReactEchartsRef>(null);
  87. const filteredSeries = useMemo(() => series.filter(s => !s.hidden), [series]);
  88. const firstUnit = filteredSeries[0]?.unit || 'none';
  89. const uniqueUnits = useMemo(
  90. () => [...new Set(filteredSeries.map(s => s.unit || 'none'))],
  91. [filteredSeries]
  92. );
  93. useEffect(() => {
  94. if (!group) {
  95. return;
  96. }
  97. const echartsInstance = chartRef?.current?.getEchartsInstance();
  98. if (echartsInstance && !echartsInstance.group) {
  99. echartsInstance.group = group;
  100. }
  101. });
  102. const bucketSize = series[0]?.data[1]?.name - series[0]?.data[0]?.name;
  103. const isSubMinuteBucket = bucketSize < 60_000;
  104. const lastBucketTimestamp = series[0]?.data?.[series[0]?.data?.length - 1]?.name;
  105. const ingestionBuckets = useMemo(
  106. () => getIngestionDelayBucketCount(bucketSize, lastBucketTimestamp),
  107. [bucketSize, lastBucketTimestamp]
  108. );
  109. const seriesToShow = useMemo(
  110. () =>
  111. filteredSeries
  112. .map(s => {
  113. const mappedSeries = {
  114. ...s,
  115. silent: true,
  116. yAxisIndex: uniqueUnits.indexOf(s.unit),
  117. xAxisIndex: 0,
  118. ...(displayType !== MetricDisplayType.BAR
  119. ? addSeriesPadding(s.data)
  120. : {data: s.data}),
  121. };
  122. if (displayType === MetricDisplayType.BAR) {
  123. mappedSeries.stack = s.unit;
  124. }
  125. return mappedSeries;
  126. })
  127. // Split series in two parts, one for the main chart and one for the fog of war
  128. // The order is important as the tooltip will show the first series first (for overlaps)
  129. .flatMap(s => createIngestionSeries(s, ingestionBuckets, displayType)),
  130. [filteredSeries, uniqueUnits, displayType, ingestionBuckets]
  131. );
  132. const {selection} = usePageFilters();
  133. const dateTimeOptions = useMemo(() => {
  134. return omitBy(selection.datetime, isNil);
  135. }, [selection.datetime]);
  136. const chartProps = useMemo(() => {
  137. const seriesUnits = seriesToShow.reduce(
  138. (acc, s) => {
  139. acc[s.seriesName] = s.unit;
  140. return acc;
  141. },
  142. {} as Record<string, string>
  143. );
  144. const timeseriesFormatters = {
  145. valueFormatter: (value: number, seriesName?: string) => {
  146. const unit = (seriesName && seriesUnits[seriesName]) ?? 'none';
  147. return formatMetricUsingUnit(value, unit);
  148. },
  149. isGroupedByDate: true,
  150. bucketSize,
  151. showTimeInTooltip: true,
  152. addSecondsToTimeFormat: isSubMinuteBucket,
  153. limit: 10,
  154. utc: !!dateTimeOptions.utc,
  155. filter: (_, seriesParam) => {
  156. return seriesParam?.axisId === MAIN_X_AXIS_ID;
  157. },
  158. };
  159. const heightOptions = height ? {height} : {autoHeightResize: true};
  160. let baseChartProps: CombinedMetricChartProps = {
  161. ...heightOptions,
  162. ...dateTimeOptions,
  163. displayType,
  164. forwardedRef: mergeRefs([forwardedRef, chartRef]),
  165. series: seriesToShow,
  166. devicePixelRatio: 2,
  167. renderer: 'canvas' as const,
  168. isGroupedByDate: true,
  169. colors: seriesToShow.map(s => s.color),
  170. grid: {
  171. top: 5,
  172. bottom: 0,
  173. left: 0,
  174. right: 0,
  175. },
  176. additionalSeries,
  177. tooltip: {
  178. formatter: (params, asyncTicket) => {
  179. // Only show the tooltip if the current chart is hovered
  180. // as chart groups trigger the tooltip for all charts in the group when one is hoverered
  181. if (!isChartHovered(chartRef?.current)) {
  182. return '';
  183. }
  184. // The mechanism by which we display ingestion delay the chart, duplicates the series in the chart data
  185. // so we need to de-duplicate the series before showing the tooltip
  186. // this assumes that the first series is the main series and the second is the ingestion delay series
  187. if (Array.isArray(params)) {
  188. const uniqueSeries = new Set<string>();
  189. const deDupedParams = params.filter(param => {
  190. // Filter null values from tooltip
  191. if (param.value[1] === null) {
  192. return false;
  193. }
  194. // scatter series (samples) have their own tooltip
  195. if (param.seriesType === 'scatter') {
  196. return false;
  197. }
  198. // Filter padding datapoints from tooltip
  199. if (param.value[1] === 0) {
  200. const currentSeries = seriesToShow[param.seriesIndex];
  201. const paddingIndices =
  202. 'paddingIndices' in currentSeries
  203. ? currentSeries.paddingIndices
  204. : undefined;
  205. if (paddingIndices?.has(param.dataIndex)) {
  206. return false;
  207. }
  208. }
  209. if (uniqueSeries.has(param.seriesName)) {
  210. return false;
  211. }
  212. uniqueSeries.add(param.seriesName);
  213. return true;
  214. });
  215. const formattedDate = defaultFormatAxisLabel(
  216. params[0].value[0] as number,
  217. timeseriesFormatters.isGroupedByDate,
  218. timeseriesFormatters.utc,
  219. timeseriesFormatters.showTimeInTooltip,
  220. timeseriesFormatters.addSecondsToTimeFormat,
  221. timeseriesFormatters.bucketSize
  222. );
  223. if (deDupedParams.length === 0) {
  224. return [
  225. '<div class="tooltip-series">',
  226. `<center>${t('No data available')}</center>`,
  227. '</div>',
  228. `<div class="tooltip-footer">${formattedDate}</div>`,
  229. ].join('');
  230. }
  231. return getFormatter(timeseriesFormatters)(deDupedParams, asyncTicket);
  232. }
  233. return getFormatter(timeseriesFormatters)(params, asyncTicket);
  234. },
  235. },
  236. yAxes:
  237. uniqueUnits.length === 0
  238. ? // fallback axis for when there are no series as echarts requires at least one axis
  239. [
  240. {
  241. id: 'none',
  242. axisLabel: {
  243. formatter: (value: number) => {
  244. return formatMetricUsingUnit(value, 'none');
  245. },
  246. },
  247. },
  248. ]
  249. : [
  250. ...uniqueUnits.map((unit, index) =>
  251. unit === firstUnit
  252. ? {
  253. id: unit,
  254. axisLabel: {
  255. formatter: (value: number) => {
  256. return formatMetricUsingUnit(value, unit);
  257. },
  258. },
  259. }
  260. : {
  261. id: unit,
  262. show: index === 1,
  263. axisLabel: {
  264. show: index === 1,
  265. formatter: (value: number) => {
  266. return formatMetricUsingUnit(value, unit);
  267. },
  268. },
  269. splitLine: {
  270. show: false,
  271. },
  272. position: 'right' as const,
  273. axisPointer: {
  274. type: 'none' as const,
  275. },
  276. }
  277. ),
  278. ],
  279. xAxes: [
  280. {
  281. id: MAIN_X_AXIS_ID,
  282. axisPointer: {
  283. snap: true,
  284. },
  285. },
  286. ],
  287. };
  288. if (samples?.applyChartProps) {
  289. baseChartProps = samples.applyChartProps(baseChartProps);
  290. }
  291. if (releases?.applyChartProps) {
  292. baseChartProps = releases.applyChartProps(baseChartProps);
  293. }
  294. // Apply focus area props as last so it can disable tooltips
  295. if (focusArea?.applyChartProps) {
  296. baseChartProps = focusArea.applyChartProps(baseChartProps);
  297. }
  298. return baseChartProps;
  299. }, [
  300. seriesToShow,
  301. dateTimeOptions,
  302. bucketSize,
  303. isSubMinuteBucket,
  304. height,
  305. displayType,
  306. forwardedRef,
  307. uniqueUnits,
  308. samples,
  309. focusArea,
  310. releases,
  311. firstUnit,
  312. additionalSeries,
  313. ]);
  314. if (!enableZoom) {
  315. return (
  316. <ChartWrapper>
  317. {focusArea?.overlay}
  318. <CombinedChart {...chartProps} />
  319. </ChartWrapper>
  320. );
  321. }
  322. return (
  323. <ChartWrapper>
  324. <ChartZoom>
  325. {zoomRenderProps => <CombinedChart {...chartProps} {...zoomRenderProps} />}
  326. </ChartZoom>
  327. </ChartWrapper>
  328. );
  329. }
  330. )
  331. );
  332. function CombinedChart({
  333. displayType,
  334. series,
  335. scatterSeries = [],
  336. additionalSeries = [],
  337. ...chartProps
  338. }: CombinedMetricChartProps) {
  339. const combinedSeries = useMemo(() => {
  340. if (displayType === MetricDisplayType.LINE) {
  341. return [
  342. ...transformToLineSeries({series}),
  343. ...transformToScatterSeries({series: scatterSeries, displayType}),
  344. ...additionalSeries,
  345. ];
  346. }
  347. if (displayType === MetricDisplayType.BAR) {
  348. return [
  349. ...transformToBarSeries({series, stacked: true, animation: false}),
  350. ...transformToScatterSeries({series: scatterSeries, displayType}),
  351. ...additionalSeries,
  352. ];
  353. }
  354. if (displayType === MetricDisplayType.AREA) {
  355. return [
  356. ...transformToAreaSeries({series, stacked: true, colors: chartProps.colors}),
  357. ...transformToScatterSeries({series: scatterSeries, displayType}),
  358. ...additionalSeries,
  359. ];
  360. }
  361. return [];
  362. }, [displayType, series, scatterSeries, additionalSeries, chartProps.colors]);
  363. return <BaseChart {...chartProps} series={combinedSeries} />;
  364. }
  365. function transformToScatterSeries({
  366. series,
  367. displayType,
  368. }: {
  369. displayType: MetricDisplayType;
  370. series: Series[];
  371. }) {
  372. return series.map(({seriesName, data: seriesData, ...options}) => {
  373. if (displayType === MetricDisplayType.BAR) {
  374. return ScatterSeries({
  375. ...options,
  376. name: seriesName,
  377. data: seriesData?.map(({value, name}) => ({value: [name, value]})),
  378. });
  379. }
  380. return ScatterSeries({
  381. ...options,
  382. name: seriesName,
  383. data: seriesData?.map(({value, name}) => [name, value]),
  384. animation: false,
  385. });
  386. });
  387. }
  388. function createIngestionSeries(
  389. orignalSeries: Series,
  390. ingestionBuckets: number,
  391. displayType: MetricDisplayType
  392. ) {
  393. if (ingestionBuckets < 1) {
  394. return [orignalSeries];
  395. }
  396. const series = [
  397. {
  398. ...orignalSeries,
  399. data: orignalSeries.data.slice(0, -ingestionBuckets),
  400. },
  401. ];
  402. if (displayType === MetricDisplayType.BAR) {
  403. series.push(createIngestionBarSeries(orignalSeries, ingestionBuckets));
  404. } else if (displayType === MetricDisplayType.AREA) {
  405. series.push(createIngestionAreaSeries(orignalSeries, ingestionBuckets));
  406. } else {
  407. series.push(createIngestionLineSeries(orignalSeries, ingestionBuckets));
  408. }
  409. return series;
  410. }
  411. const EXTRAPOLATED_AREA_STRIPE_IMG =
  412. 'image://data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAABkCAYAAAC/zKGXAAAAMUlEQVR4Ae3KoREAIAwEsMKgrMeYj8BzyIpEZyTZda16mPVJFEVRFEVRFEVRFMWO8QB4uATKpuU51gAAAABJRU5ErkJggg==';
  413. export const getIngestionSeriesId = (seriesId: string) => `${seriesId}-ingestion`;
  414. function createIngestionBarSeries(series: Series, fogBucketCnt = 0) {
  415. return {
  416. ...series,
  417. id: getIngestionSeriesId(series.id),
  418. silent: true,
  419. data: series.data.map((data, index) => ({
  420. ...data,
  421. // W need to set a value for the non-fog of war buckets so that the stacking still works in echarts
  422. value: index < series.data.length - fogBucketCnt ? 0 : data.value,
  423. })),
  424. itemStyle: {
  425. opacity: 1,
  426. decal: {
  427. symbol: EXTRAPOLATED_AREA_STRIPE_IMG,
  428. dashArrayX: [6, 0],
  429. dashArrayY: [6, 0],
  430. rotation: Math.PI / 4,
  431. },
  432. },
  433. };
  434. }
  435. function createIngestionLineSeries(series: Series, fogBucketCnt = 0) {
  436. return {
  437. ...series,
  438. id: getIngestionSeriesId(series.id),
  439. silent: true,
  440. // We include the last non-fog of war bucket so that the line is connected
  441. data: series.data.slice(-fogBucketCnt - 1),
  442. lineStyle: {
  443. type: 'dotted',
  444. },
  445. };
  446. }
  447. function createIngestionAreaSeries(series: Series, fogBucketCnt = 0) {
  448. return {
  449. ...series,
  450. id: getIngestionSeriesId(series.id),
  451. silent: true,
  452. stack: 'fogOfWar',
  453. // We include the last non-fog of war bucket so that the line is connected
  454. data: series.data.slice(-fogBucketCnt - 1),
  455. lineStyle: {
  456. type: 'dotted',
  457. color: Color(series.color).lighten(0.3).string(),
  458. },
  459. };
  460. }
  461. const AVERAGE_INGESTION_DELAY_MS = 90_000;
  462. /**
  463. * Calculates the number of buckets, affected by ingestion delay.
  464. * Based on the AVERAGE_INGESTION_DELAY_MS
  465. * @param bucketSize in ms
  466. * @param lastBucketTimestamp starting time of the last bucket in ms
  467. */
  468. function getIngestionDelayBucketCount(bucketSize: number, lastBucketTimestamp: number) {
  469. const timeSinceLastBucket = Date.now() - (lastBucketTimestamp + bucketSize);
  470. const ingestionAffectedTime = Math.max(
  471. 0,
  472. AVERAGE_INGESTION_DELAY_MS - timeSinceLastBucket
  473. );
  474. return Math.ceil(ingestionAffectedTime / bucketSize);
  475. }
  476. const ChartWrapper = styled('div')`
  477. position: relative;
  478. height: 100%;
  479. `;