useMetricChartSamples.tsx 14 KB

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