chart.tsx 17 KB

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