chart.tsx 15 KB

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