chart.tsx 15 KB

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