useMetricChartSamples.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. import type {RefObject} from 'react';
  2. import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
  3. import {useTheme} from '@emotion/react';
  4. import type {XAXisOption, YAXisOption} from 'echarts/types/dist/shared';
  5. import moment from 'moment';
  6. import {getFormatter} from 'sentry/components/charts/components/tooltip';
  7. import {isChartHovered} from 'sentry/components/charts/utils';
  8. import type {Field} from 'sentry/components/ddm/metricSamplesTable';
  9. import {t} from 'sentry/locale';
  10. import type {EChartClickHandler, ReactEchartsRef} from 'sentry/types/echarts';
  11. import {defined} from 'sentry/utils';
  12. import mergeRefs from 'sentry/utils/mergeRefs';
  13. import {isCumulativeOp} from 'sentry/utils/metrics';
  14. import {formatMetricsUsingUnitAndOp} from 'sentry/utils/metrics/formatters';
  15. import type {MetricCorrelation, MetricSummary} from 'sentry/utils/metrics/types';
  16. import {
  17. getSummaryValueForOp,
  18. type MetricsSamplesResults,
  19. } from 'sentry/utils/metrics/useMetricsSamples';
  20. import {fitToValueRect, getValueRect} from 'sentry/views/ddm/chart/chartUtils';
  21. import type {CombinedMetricChartProps, Series} from 'sentry/views/ddm/chart/types';
  22. import type {Sample} from 'sentry/views/ddm/widget';
  23. type UseChartSamplesProps = {
  24. timeseries: Series[];
  25. chartRef?: RefObject<ReactEchartsRef>;
  26. correlations?: MetricCorrelation[];
  27. highlightedSampleId?: string;
  28. onClick?: (sample: Sample) => void;
  29. onMouseOut?: (sample: Sample) => void;
  30. onMouseOver?: (sample: Sample) => void;
  31. operation?: string;
  32. unit?: string;
  33. };
  34. // TODO: remove this once we have a stabilized type for this
  35. type ChartSample = MetricCorrelation & MetricSummary;
  36. function getDateRange(timeseries: Series[]) {
  37. if (!timeseries?.length) {
  38. return {min: -Infinity, max: Infinity};
  39. }
  40. const min = timeseries[0].data[0].name as number;
  41. const max = timeseries[0].data[timeseries[0].data.length - 1].name as number;
  42. return {min, max};
  43. }
  44. type EChartMouseEventParam = Parameters<EChartClickHandler>[0];
  45. export function useMetricChartSamples({
  46. correlations,
  47. onClick,
  48. highlightedSampleId,
  49. unit = '',
  50. operation,
  51. timeseries,
  52. }: UseChartSamplesProps) {
  53. const theme = useTheme();
  54. const chartRef = useRef<ReactEchartsRef>(null);
  55. const scalingFactor = timeseries?.[0]?.scalingFactor ?? 1;
  56. const [valueRect, setValueRect] = useState(getValueRect(chartRef));
  57. const samples: Record<string, ChartSample> = useMemo(() => {
  58. return (correlations ?? [])
  59. ?.flatMap(correlation => [
  60. ...correlation.metricSummaries.map(summaries => ({...summaries, ...correlation})),
  61. ])
  62. .reduce((acc, sample) => {
  63. acc[sample.transactionId] = sample;
  64. return acc;
  65. }, {});
  66. }, [correlations]);
  67. useEffect(() => {
  68. // Changes in timeseries change the valueRect since the timeseries yAxis auto scales
  69. // and scatter yAxis needs to match the scale
  70. setValueRect(getValueRect(chartRef));
  71. }, [chartRef, timeseries]);
  72. const xAxis: XAXisOption = useMemo(() => {
  73. const {min, max} = getDateRange(timeseries);
  74. return {
  75. id: 'xAxisScatter',
  76. scale: false,
  77. show: false,
  78. axisLabel: {
  79. formatter: () => {
  80. return '';
  81. },
  82. },
  83. axisPointer: {
  84. type: 'none',
  85. },
  86. min: Math.max(valueRect.xMin, min),
  87. max: Math.min(valueRect.xMax, max),
  88. };
  89. }, [valueRect.xMin, valueRect.xMax, timeseries]);
  90. const yAxis: YAXisOption = useMemo(() => {
  91. return {
  92. id: 'yAxisScatter',
  93. scale: false,
  94. show: false,
  95. axisLabel: {
  96. formatter: () => {
  97. return '';
  98. },
  99. },
  100. min: valueRect.yMin,
  101. max: valueRect.yMax,
  102. };
  103. }, [valueRect.yMin, valueRect.yMax]);
  104. const getSample = useCallback(
  105. (event: EChartMouseEventParam): ChartSample | undefined => {
  106. return samples[event.seriesId];
  107. },
  108. [samples]
  109. );
  110. const handleClick = useCallback<EChartClickHandler>(
  111. (event: EChartMouseEventParam) => {
  112. if (!onClick) {
  113. return;
  114. }
  115. const sample = getSample(event);
  116. if (!sample) {
  117. return;
  118. }
  119. onClick(sample);
  120. },
  121. [getSample, onClick]
  122. );
  123. const series = useMemo(() => {
  124. if (isCumulativeOp(operation)) {
  125. // TODO: for now we do not show samples for cumulative operations,
  126. // we will implement them as marklines
  127. return [];
  128. }
  129. return Object.values(samples).map(sample => {
  130. const isHighlighted = highlightedSampleId === sample.transactionId;
  131. const xValue = moment(sample.timestamp).valueOf();
  132. const yValue = (((sample.min ?? 0) + (sample.max ?? 0)) / 2) * scalingFactor;
  133. const [xPosition, yPosition] = fitToValueRect(xValue, yValue, valueRect);
  134. const symbol = yPosition === yValue ? 'circle' : 'arrow';
  135. const symbolRotate = yPosition > yValue ? 180 : 0;
  136. return {
  137. seriesName: sample.transactionId,
  138. id: sample.spanId,
  139. operation: '',
  140. unit: '',
  141. symbolSize: isHighlighted ? 20 : 10,
  142. animation: false,
  143. symbol,
  144. symbolRotate,
  145. color: theme.purple400,
  146. // TODO: for now we just pass these ids through, but we should probably index
  147. // samples by an id and then just pass that reference
  148. itemStyle: {
  149. color: theme.purple400,
  150. opacity: 1,
  151. },
  152. yAxisIndex: 1,
  153. xAxisIndex: 1,
  154. xValue,
  155. yValue,
  156. tooltip: {
  157. axisPointer: {
  158. type: 'none',
  159. },
  160. },
  161. data: [
  162. {
  163. name: xPosition,
  164. value: yPosition,
  165. },
  166. ],
  167. z: 10,
  168. };
  169. });
  170. }, [
  171. operation,
  172. samples,
  173. highlightedSampleId,
  174. scalingFactor,
  175. valueRect,
  176. theme.purple400,
  177. ]);
  178. const formatterOptions = useMemo(() => {
  179. return {
  180. isGroupedByDate: true,
  181. limit: 1,
  182. showTimeInTooltip: true,
  183. addSecondsToTimeFormat: true,
  184. nameFormatter: (name: string) => {
  185. return t('Event %s', name.substring(0, 8));
  186. },
  187. valueFormatter: (_, label?: string) => {
  188. // We need to access the sample as the charts datapoints are fit to the charts viewport
  189. const sample = samples[label ?? ''];
  190. const yValue = ((sample.min ?? 0) + (sample.max ?? 0)) / 2;
  191. return formatMetricsUsingUnitAndOp(yValue, unit, operation);
  192. },
  193. };
  194. }, [operation, samples, unit]);
  195. const applyChartProps = useCallback(
  196. (baseProps: CombinedMetricChartProps): CombinedMetricChartProps => {
  197. return {
  198. ...baseProps,
  199. forwardedRef: mergeRefs([baseProps.forwardedRef, chartRef]),
  200. scatterSeries: series,
  201. xAxes: [...(Array.isArray(baseProps.xAxes) ? baseProps.xAxes : []), xAxis],
  202. yAxes: [...(Array.isArray(baseProps.yAxes) ? baseProps.yAxes : []), yAxis],
  203. onClick: (...args) => {
  204. handleClick(...args);
  205. baseProps.onClick?.(...args);
  206. },
  207. tooltip: {
  208. formatter: (params: any, asyncTicket) => {
  209. // Only show the tooltip if the current chart is hovered
  210. // as chart groups trigger the tooltip for all charts in the group when one is hoverered
  211. if (!isChartHovered(chartRef?.current)) {
  212. return '';
  213. }
  214. // Hovering a single correlated sample datapoint
  215. if (params.seriesType === 'scatter') {
  216. return getFormatter(formatterOptions)(params, asyncTicket);
  217. }
  218. const baseFormatter = baseProps.tooltip?.formatter;
  219. if (typeof baseFormatter === 'string') {
  220. return baseFormatter;
  221. }
  222. if (!baseFormatter) {
  223. throw new Error(
  224. 'You need to define a tooltip formatter for the chart when using metric samples'
  225. );
  226. }
  227. return baseFormatter(params, asyncTicket);
  228. },
  229. },
  230. };
  231. },
  232. [formatterOptions, handleClick, series, xAxis, yAxis]
  233. );
  234. // eslint-disable-next-line react-hooks/exhaustive-deps
  235. return useMemo(
  236. () => ({
  237. applyChartProps,
  238. }),
  239. [applyChartProps]
  240. );
  241. }
  242. interface UseMetricChartSamplesV2Options {
  243. timeseries: Series[];
  244. highlightedSampleId?: string;
  245. onSampleClick?: (sample: MetricsSamplesResults<Field>['data'][number]) => void;
  246. operation?: string;
  247. samples?: MetricsSamplesResults<Field>['data'];
  248. unit?: string;
  249. }
  250. export function useMetricChartSamplesV2({
  251. timeseries,
  252. highlightedSampleId,
  253. onSampleClick,
  254. operation,
  255. samples,
  256. unit = '',
  257. }: UseMetricChartSamplesV2Options) {
  258. const theme = useTheme();
  259. const chartRef = useRef<ReactEchartsRef>(null);
  260. const timeseriesScalingFactor = timeseries?.[0]?.scalingFactor ?? 1;
  261. const [valueRect, setValueRect] = useState(getValueRect(chartRef));
  262. const samplesById = useMemo(() => {
  263. return (samples ?? []).reduce((acc, sample) => {
  264. acc[sample.id] = sample;
  265. return acc;
  266. }, {});
  267. }, [samples]);
  268. useEffect(() => {
  269. // Changes in timeseries change the valueRect since the timeseries yAxis auto scales
  270. // and scatter yAxis needs to match the scale
  271. setValueRect(getValueRect(chartRef));
  272. }, [chartRef, timeseries]);
  273. const xAxis: XAXisOption = useMemo(() => {
  274. const {min, max} = getDateRange(timeseries);
  275. return {
  276. id: 'xAxisScatter',
  277. scale: false,
  278. show: false,
  279. axisLabel: {
  280. formatter: () => {
  281. return '';
  282. },
  283. },
  284. axisPointer: {
  285. type: 'none',
  286. },
  287. min: Math.max(valueRect.xMin, min),
  288. max: Math.min(valueRect.xMax, max),
  289. };
  290. }, [valueRect.xMin, valueRect.xMax, timeseries]);
  291. const yAxis: YAXisOption = useMemo(() => {
  292. return {
  293. id: 'yAxisScatter',
  294. scale: false,
  295. show: false,
  296. axisLabel: {
  297. formatter: () => {
  298. return '';
  299. },
  300. },
  301. min: valueRect.yMin,
  302. max: valueRect.yMax,
  303. };
  304. }, [valueRect.yMin, valueRect.yMax]);
  305. const series = useMemo(() => {
  306. if (isCumulativeOp(operation)) {
  307. // TODO: for now we do not show samples for cumulative operations
  308. // figure out how should this be shown
  309. return [];
  310. }
  311. return (samples ?? []).map(sample => {
  312. const isHighlighted = highlightedSampleId === sample.id;
  313. const xValue = moment(sample.timestamp).valueOf();
  314. const value = getSummaryValueForOp(sample.summary, operation);
  315. const yValue = value * timeseriesScalingFactor;
  316. const [xPosition, yPosition] = fitToValueRect(xValue, yValue, valueRect);
  317. return {
  318. seriesName: sample.id,
  319. id: sample.id,
  320. operation: '',
  321. unit: '',
  322. symbolSize: isHighlighted ? 20 : 10,
  323. animation: false,
  324. symbol: yPosition === yValue ? 'circle' : 'arrow',
  325. symbolRotate: yPosition > yValue ? 180 : 0,
  326. color: theme.purple400,
  327. itemStyle: {
  328. color: theme.purple400,
  329. opacity: 1,
  330. },
  331. yAxisIndex: 1,
  332. xAxisIndex: 1,
  333. xValue,
  334. yValue,
  335. tooltip: {
  336. axisPointer: {
  337. type: 'none',
  338. },
  339. },
  340. data: [
  341. {
  342. name: xPosition,
  343. value: yPosition,
  344. },
  345. ],
  346. z: 10,
  347. };
  348. });
  349. }, [
  350. highlightedSampleId,
  351. operation,
  352. samples,
  353. theme.purple400,
  354. timeseriesScalingFactor,
  355. valueRect,
  356. ]);
  357. const formatterOptions = useMemo(() => {
  358. return {
  359. isGroupedByDate: true,
  360. limit: 1,
  361. showTimeInTooltip: true,
  362. addSecondsToTimeFormat: true,
  363. nameFormatter: (name: string) => {
  364. return t('Span %s', name.substring(0, 8));
  365. },
  366. valueFormatter: (_, label?: string) => {
  367. // We need to access the sample as the charts datapoints are fit to the charts viewport
  368. const sample = samplesById[label ?? ''];
  369. const yValue = getSummaryValueForOp(sample.summary, operation);
  370. return formatMetricsUsingUnitAndOp(yValue, unit, operation);
  371. },
  372. };
  373. }, [operation, samplesById, unit]);
  374. const handleClick = useCallback<EChartClickHandler>(
  375. (event: EChartMouseEventParam) => {
  376. const sample = samplesById[event.seriesId];
  377. if (defined(onSampleClick) && defined(sample)) {
  378. onSampleClick(sample);
  379. }
  380. },
  381. [onSampleClick, samplesById]
  382. );
  383. const applyChartProps = useCallback(
  384. (baseProps: CombinedMetricChartProps): CombinedMetricChartProps => {
  385. return {
  386. ...baseProps,
  387. forwardedRef: mergeRefs([baseProps.forwardedRef, chartRef]),
  388. scatterSeries: series,
  389. xAxes: [...(Array.isArray(baseProps.xAxes) ? baseProps.xAxes : []), xAxis],
  390. yAxes: [...(Array.isArray(baseProps.yAxes) ? baseProps.yAxes : []), yAxis],
  391. onClick: (...args) => {
  392. handleClick(...args);
  393. baseProps.onClick?.(...args);
  394. },
  395. tooltip: {
  396. formatter: (params: any, asyncTicket) => {
  397. // Only show the tooltip if the current chart is hovered
  398. // as chart groups trigger the tooltip for all charts in the group when one is hoverered
  399. if (!isChartHovered(chartRef?.current)) {
  400. return '';
  401. }
  402. // Hovering a single correlated sample datapoint
  403. if (params.seriesType === 'scatter') {
  404. return getFormatter(formatterOptions)(params, asyncTicket);
  405. }
  406. const baseFormatter = baseProps.tooltip?.formatter;
  407. if (typeof baseFormatter === 'string') {
  408. return baseFormatter;
  409. }
  410. if (!baseFormatter) {
  411. throw new Error(
  412. 'You need to define a tooltip formatter for the chart when using metric samples'
  413. );
  414. }
  415. return baseFormatter(params, asyncTicket);
  416. },
  417. },
  418. };
  419. },
  420. [formatterOptions, handleClick, series, xAxis, yAxis]
  421. );
  422. return useMemo(() => {
  423. if (!defined(samples)) {
  424. return undefined;
  425. }
  426. return {applyChartProps};
  427. }, [applyChartProps, samples]);
  428. }
  429. export type UseMetricSamplesResult = ReturnType<typeof useMetricChartSamples>;