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 ChartZoom from 'sentry/components/charts/chartZoom';
  13. import {
  14. defaultFormatAxisLabel,
  15. getFormatter,
  16. } from 'sentry/components/charts/components/tooltip';
  17. import {transformToLineSeries} from 'sentry/components/charts/lineChart';
  18. import ScatterSeries from 'sentry/components/charts/series/scatterSeries';
  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. tooltip: {
  177. formatter: (params, asyncTicket) => {
  178. // Only show the tooltip if the current chart is hovered
  179. // as chart groups trigger the tooltip for all charts in the group when one is hoverered
  180. if (!isChartHovered(chartRef?.current)) {
  181. return '';
  182. }
  183. // The mechanism by which we display ingestion delay the chart, duplicates the series in the chart data
  184. // so we need to de-duplicate the series before showing the tooltip
  185. // this assumes that the first series is the main series and the second is the ingestion delay series
  186. if (Array.isArray(params)) {
  187. const uniqueSeries = new Set<string>();
  188. const deDupedParams = params.filter(param => {
  189. // Filter null values from tooltip
  190. if (param.value[1] === null) {
  191. return false;
  192. }
  193. // scatter series (samples) have their own tooltip
  194. if (param.seriesType === 'scatter') {
  195. return false;
  196. }
  197. // Filter padding datapoints from tooltip
  198. if (param.value[1] === 0) {
  199. const currentSeries = seriesToShow[param.seriesIndex];
  200. const paddingIndices =
  201. 'paddingIndices' in currentSeries
  202. ? currentSeries.paddingIndices
  203. : undefined;
  204. if (paddingIndices?.has(param.dataIndex)) {
  205. return false;
  206. }
  207. }
  208. if (uniqueSeries.has(param.seriesName)) {
  209. return false;
  210. }
  211. uniqueSeries.add(param.seriesName);
  212. return true;
  213. });
  214. const date = params[0].value[0];
  215. 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">${date}</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. ]);
  313. if (!enableZoom) {
  314. return (
  315. <ChartWrapper>
  316. {focusArea?.overlay}
  317. <CombinedChart {...chartProps} additionalSeries={additionalSeries} />
  318. </ChartWrapper>
  319. );
  320. }
  321. return (
  322. <ChartWrapper>
  323. <ChartZoom>
  324. {zoomRenderProps => <CombinedChart {...chartProps} {...zoomRenderProps} />}
  325. </ChartZoom>
  326. </ChartWrapper>
  327. );
  328. }
  329. )
  330. );
  331. function CombinedChart({
  332. displayType,
  333. series,
  334. scatterSeries = [],
  335. additionalSeries = [],
  336. ...chartProps
  337. }: CombinedMetricChartProps) {
  338. const combinedSeries = useMemo(() => {
  339. if (displayType === MetricDisplayType.LINE) {
  340. return [
  341. ...transformToLineSeries({series}),
  342. ...transformToScatterSeries({series: scatterSeries, displayType}),
  343. ...additionalSeries,
  344. ];
  345. }
  346. if (displayType === MetricDisplayType.BAR) {
  347. return [
  348. ...transformToBarSeries({series, stacked: true, animation: false}),
  349. ...transformToScatterSeries({series: scatterSeries, displayType}),
  350. ...additionalSeries,
  351. ];
  352. }
  353. if (displayType === MetricDisplayType.AREA) {
  354. return [
  355. ...transformToAreaSeries({series, stacked: true, colors: chartProps.colors}),
  356. ...transformToScatterSeries({series: scatterSeries, displayType}),
  357. ...additionalSeries,
  358. ];
  359. }
  360. return [];
  361. }, [displayType, series, scatterSeries, additionalSeries, chartProps.colors]);
  362. return <BaseChart {...chartProps} series={combinedSeries} />;
  363. }
  364. function transformToScatterSeries({
  365. series,
  366. displayType,
  367. }: {
  368. displayType: MetricDisplayType;
  369. series: Series[];
  370. }) {
  371. return series.map(({seriesName, data: seriesData, ...options}) => {
  372. if (displayType === MetricDisplayType.BAR) {
  373. return ScatterSeries({
  374. ...options,
  375. name: seriesName,
  376. data: seriesData?.map(({value, name}) => ({value: [name, value]})),
  377. });
  378. }
  379. return ScatterSeries({
  380. ...options,
  381. name: seriesName,
  382. data: seriesData?.map(({value, name}) => [name, value]),
  383. animation: false,
  384. });
  385. });
  386. }
  387. function createIngestionSeries(
  388. orignalSeries: Series,
  389. ingestionBuckets: number,
  390. displayType: MetricDisplayType
  391. ) {
  392. if (ingestionBuckets < 1) {
  393. return [orignalSeries];
  394. }
  395. const series = [
  396. {
  397. ...orignalSeries,
  398. data: orignalSeries.data.slice(0, -ingestionBuckets),
  399. },
  400. ];
  401. if (displayType === MetricDisplayType.BAR) {
  402. series.push(createIngestionBarSeries(orignalSeries, ingestionBuckets));
  403. } else if (displayType === MetricDisplayType.AREA) {
  404. series.push(createIngestionAreaSeries(orignalSeries, ingestionBuckets));
  405. } else {
  406. series.push(createIngestionLineSeries(orignalSeries, ingestionBuckets));
  407. }
  408. return series;
  409. }
  410. const EXTRAPOLATED_AREA_STRIPE_IMG =
  411. 'image://data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAABkCAYAAAC/zKGXAAAAMUlEQVR4Ae3KoREAIAwEsMKgrMeYj8BzyIpEZyTZda16mPVJFEVRFEVRFEVRFMWO8QB4uATKpuU51gAAAABJRU5ErkJggg==';
  412. export const getIngestionSeriesId = (seriesId: string) => `${seriesId}-ingestion`;
  413. function createIngestionBarSeries(series: Series, fogBucketCnt = 0) {
  414. return {
  415. ...series,
  416. id: getIngestionSeriesId(series.id),
  417. silent: true,
  418. data: series.data.map((data, index) => ({
  419. ...data,
  420. // W need to set a value for the non-fog of war buckets so that the stacking still works in echarts
  421. value: index < series.data.length - fogBucketCnt ? 0 : data.value,
  422. })),
  423. itemStyle: {
  424. opacity: 1,
  425. decal: {
  426. symbol: EXTRAPOLATED_AREA_STRIPE_IMG,
  427. dashArrayX: [6, 0],
  428. dashArrayY: [6, 0],
  429. rotation: Math.PI / 4,
  430. },
  431. },
  432. };
  433. }
  434. function createIngestionLineSeries(series: Series, fogBucketCnt = 0) {
  435. return {
  436. ...series,
  437. id: getIngestionSeriesId(series.id),
  438. silent: true,
  439. // We include the last non-fog of war bucket so that the line is connected
  440. data: series.data.slice(-fogBucketCnt - 1),
  441. lineStyle: {
  442. type: 'dotted',
  443. },
  444. };
  445. }
  446. function createIngestionAreaSeries(series: Series, fogBucketCnt = 0) {
  447. return {
  448. ...series,
  449. id: getIngestionSeriesId(series.id),
  450. silent: true,
  451. stack: 'fogOfWar',
  452. // We include the last non-fog of war bucket so that the line is connected
  453. data: series.data.slice(-fogBucketCnt - 1),
  454. lineStyle: {
  455. type: 'dotted',
  456. color: Color(series.color).lighten(0.3).string(),
  457. },
  458. };
  459. }
  460. const AVERAGE_INGESTION_DELAY_MS = 90_000;
  461. /**
  462. * Calculates the number of buckets, affected by ingestion delay.
  463. * Based on the AVERAGE_INGESTION_DELAY_MS
  464. * @param bucketSize in ms
  465. * @param lastBucketTimestamp starting time of the last bucket in ms
  466. */
  467. function getIngestionDelayBucketCount(bucketSize: number, lastBucketTimestamp: number) {
  468. const timeSinceLastBucket = Date.now() - (lastBucketTimestamp + bucketSize);
  469. const ingestionAffectedTime = Math.max(
  470. 0,
  471. AVERAGE_INGESTION_DELAY_MS - timeSinceLastBucket
  472. );
  473. return Math.ceil(ingestionAffectedTime / bucketSize);
  474. }
  475. const ChartWrapper = styled('div')`
  476. position: relative;
  477. height: 100%;
  478. `;