chart.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  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 date = params[0].value[0];
  216. defaultFormatAxisLabel(
  217. params[0].value[0] as number,
  218. timeseriesFormatters.isGroupedByDate,
  219. timeseriesFormatters.utc,
  220. timeseriesFormatters.showTimeInTooltip,
  221. timeseriesFormatters.addSecondsToTimeFormat,
  222. timeseriesFormatters.bucketSize
  223. );
  224. if (deDupedParams.length === 0) {
  225. return [
  226. '<div class="tooltip-series">',
  227. `<center>${t('No data available')}</center>`,
  228. '</div>',
  229. `<div class="tooltip-footer">${date}</div>`,
  230. ].join('');
  231. }
  232. return getFormatter(timeseriesFormatters)(deDupedParams, asyncTicket);
  233. }
  234. return getFormatter(timeseriesFormatters)(params, asyncTicket);
  235. },
  236. },
  237. yAxes:
  238. uniqueUnits.length === 0
  239. ? // fallback axis for when there are no series as echarts requires at least one axis
  240. [
  241. {
  242. id: 'none',
  243. axisLabel: {
  244. formatter: (value: number) => {
  245. return formatMetricUsingUnit(value, 'none');
  246. },
  247. },
  248. },
  249. ]
  250. : [
  251. ...uniqueUnits.map((unit, index) =>
  252. unit === firstUnit
  253. ? {
  254. id: unit,
  255. axisLabel: {
  256. formatter: (value: number) => {
  257. return formatMetricUsingUnit(value, unit);
  258. },
  259. },
  260. }
  261. : {
  262. id: unit,
  263. show: index === 1,
  264. axisLabel: {
  265. show: index === 1,
  266. formatter: (value: number) => {
  267. return formatMetricUsingUnit(value, unit);
  268. },
  269. },
  270. splitLine: {
  271. show: false,
  272. },
  273. position: 'right' as const,
  274. axisPointer: {
  275. type: 'none' as const,
  276. },
  277. }
  278. ),
  279. ],
  280. xAxes: [
  281. {
  282. id: MAIN_X_AXIS_ID,
  283. axisPointer: {
  284. snap: true,
  285. },
  286. },
  287. ],
  288. };
  289. if (samples?.applyChartProps) {
  290. baseChartProps = samples.applyChartProps(baseChartProps);
  291. }
  292. if (releases?.applyChartProps) {
  293. baseChartProps = releases.applyChartProps(baseChartProps);
  294. }
  295. // Apply focus area props as last so it can disable tooltips
  296. if (focusArea?.applyChartProps) {
  297. baseChartProps = focusArea.applyChartProps(baseChartProps);
  298. }
  299. return baseChartProps;
  300. }, [
  301. seriesToShow,
  302. dateTimeOptions,
  303. bucketSize,
  304. isSubMinuteBucket,
  305. height,
  306. displayType,
  307. forwardedRef,
  308. uniqueUnits,
  309. samples,
  310. focusArea,
  311. releases,
  312. firstUnit,
  313. additionalSeries,
  314. ]);
  315. if (!enableZoom) {
  316. return (
  317. <ChartWrapper>
  318. {focusArea?.overlay}
  319. <CombinedChart {...chartProps} />
  320. </ChartWrapper>
  321. );
  322. }
  323. return (
  324. <ChartWrapper>
  325. <ChartZoom>
  326. {zoomRenderProps => <CombinedChart {...chartProps} {...zoomRenderProps} />}
  327. </ChartZoom>
  328. </ChartWrapper>
  329. );
  330. }
  331. )
  332. );
  333. function CombinedChart({
  334. displayType,
  335. series,
  336. scatterSeries = [],
  337. additionalSeries = [],
  338. ...chartProps
  339. }: CombinedMetricChartProps) {
  340. const combinedSeries = useMemo(() => {
  341. if (displayType === MetricDisplayType.LINE) {
  342. return [
  343. ...transformToLineSeries({series}),
  344. ...transformToScatterSeries({series: scatterSeries, displayType}),
  345. ...additionalSeries,
  346. ];
  347. }
  348. if (displayType === MetricDisplayType.BAR) {
  349. return [
  350. ...transformToBarSeries({series, stacked: true, animation: false}),
  351. ...transformToScatterSeries({series: scatterSeries, displayType}),
  352. ...additionalSeries,
  353. ];
  354. }
  355. if (displayType === MetricDisplayType.AREA) {
  356. return [
  357. ...transformToAreaSeries({series, stacked: true, colors: chartProps.colors}),
  358. ...transformToScatterSeries({series: scatterSeries, displayType}),
  359. ...additionalSeries,
  360. ];
  361. }
  362. return [];
  363. }, [displayType, series, scatterSeries, additionalSeries, chartProps.colors]);
  364. return <BaseChart {...chartProps} series={combinedSeries} />;
  365. }
  366. function transformToScatterSeries({
  367. series,
  368. displayType,
  369. }: {
  370. displayType: MetricDisplayType;
  371. series: Series[];
  372. }) {
  373. return series.map(({seriesName, data: seriesData, ...options}) => {
  374. if (displayType === MetricDisplayType.BAR) {
  375. return ScatterSeries({
  376. ...options,
  377. name: seriesName,
  378. data: seriesData?.map(({value, name}) => ({value: [name, value]})),
  379. });
  380. }
  381. return ScatterSeries({
  382. ...options,
  383. name: seriesName,
  384. data: seriesData?.map(({value, name}) => [name, value]),
  385. animation: false,
  386. });
  387. });
  388. }
  389. function createIngestionSeries(
  390. orignalSeries: Series,
  391. ingestionBuckets: number,
  392. displayType: MetricDisplayType
  393. ) {
  394. if (ingestionBuckets < 1) {
  395. return [orignalSeries];
  396. }
  397. const series = [
  398. {
  399. ...orignalSeries,
  400. data: orignalSeries.data.slice(0, -ingestionBuckets),
  401. },
  402. ];
  403. if (displayType === MetricDisplayType.BAR) {
  404. series.push(createIngestionBarSeries(orignalSeries, ingestionBuckets));
  405. } else if (displayType === MetricDisplayType.AREA) {
  406. series.push(createIngestionAreaSeries(orignalSeries, ingestionBuckets));
  407. } else {
  408. series.push(createIngestionLineSeries(orignalSeries, ingestionBuckets));
  409. }
  410. return series;
  411. }
  412. const EXTRAPOLATED_AREA_STRIPE_IMG =
  413. 'image://data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAABkCAYAAAC/zKGXAAAAMUlEQVR4Ae3KoREAIAwEsMKgrMeYj8BzyIpEZyTZda16mPVJFEVRFEVRFEVRFMWO8QB4uATKpuU51gAAAABJRU5ErkJggg==';
  414. export const getIngestionSeriesId = (seriesId: string) => `${seriesId}-ingestion`;
  415. function createIngestionBarSeries(series: Series, fogBucketCnt = 0) {
  416. return {
  417. ...series,
  418. id: getIngestionSeriesId(series.id),
  419. silent: true,
  420. data: series.data.map((data, index) => ({
  421. ...data,
  422. // W need to set a value for the non-fog of war buckets so that the stacking still works in echarts
  423. value: index < series.data.length - fogBucketCnt ? 0 : data.value,
  424. })),
  425. itemStyle: {
  426. opacity: 1,
  427. decal: {
  428. symbol: EXTRAPOLATED_AREA_STRIPE_IMG,
  429. dashArrayX: [6, 0],
  430. dashArrayY: [6, 0],
  431. rotation: Math.PI / 4,
  432. },
  433. },
  434. };
  435. }
  436. function createIngestionLineSeries(series: Series, fogBucketCnt = 0) {
  437. return {
  438. ...series,
  439. id: getIngestionSeriesId(series.id),
  440. silent: true,
  441. // We include the last non-fog of war bucket so that the line is connected
  442. data: series.data.slice(-fogBucketCnt - 1),
  443. lineStyle: {
  444. type: 'dotted',
  445. },
  446. };
  447. }
  448. function createIngestionAreaSeries(series: Series, fogBucketCnt = 0) {
  449. return {
  450. ...series,
  451. id: getIngestionSeriesId(series.id),
  452. silent: true,
  453. stack: 'fogOfWar',
  454. // We include the last non-fog of war bucket so that the line is connected
  455. data: series.data.slice(-fogBucketCnt - 1),
  456. lineStyle: {
  457. type: 'dotted',
  458. color: Color(series.color).lighten(0.3).string(),
  459. },
  460. };
  461. }
  462. const AVERAGE_INGESTION_DELAY_MS = 90_000;
  463. /**
  464. * Calculates the number of buckets, affected by ingestion delay.
  465. * Based on the AVERAGE_INGESTION_DELAY_MS
  466. * @param bucketSize in ms
  467. * @param lastBucketTimestamp starting time of the last bucket in ms
  468. */
  469. function getIngestionDelayBucketCount(bucketSize: number, lastBucketTimestamp: number) {
  470. const timeSinceLastBucket = Date.now() - (lastBucketTimestamp + bucketSize);
  471. const ingestionAffectedTime = Math.max(
  472. 0,
  473. AVERAGE_INGESTION_DELAY_MS - timeSinceLastBucket
  474. );
  475. return Math.ceil(ingestionAffectedTime / bucketSize);
  476. }
  477. const ChartWrapper = styled('div')`
  478. position: relative;
  479. height: 100%;
  480. `;