useMetricChartSamples.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  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} 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 {t} from 'sentry/locale';
  9. import type {EChartClickHandler, ReactEchartsRef, Series} from 'sentry/types/echarts';
  10. import mergeRefs from 'sentry/utils/mergeRefs';
  11. import {isCumulativeOp} from 'sentry/utils/metrics';
  12. import {formatMetricsUsingUnitAndOp} from 'sentry/utils/metrics/formatters';
  13. import {getMetricValueNormalizer} from 'sentry/utils/metrics/normalizeMetricValue';
  14. import type {MetricCorrelation, MetricSummary} from 'sentry/utils/metrics/types';
  15. import {fitToValueRect, getValueRect} from 'sentry/views/ddm/chart/chartUtils';
  16. import type {CombinedMetricChartProps} from 'sentry/views/ddm/chart/types';
  17. import type {Sample} from 'sentry/views/ddm/widget';
  18. type UseChartSamplesProps = {
  19. timeseries: Series[];
  20. chartRef?: RefObject<ReactEchartsRef>;
  21. correlations?: MetricCorrelation[];
  22. highlightedSampleId?: string;
  23. onClick?: (sample: Sample) => void;
  24. onMouseOut?: (sample: Sample) => void;
  25. onMouseOver?: (sample: Sample) => void;
  26. operation?: string;
  27. unit?: string;
  28. };
  29. // TODO: remove this once we have a stabilized type for this
  30. type ChartSample = MetricCorrelation & MetricSummary;
  31. function getDateRange(timeseries: Series[]) {
  32. if (!timeseries?.length) {
  33. return {min: -Infinity, max: Infinity};
  34. }
  35. const min = timeseries[0].data[0].name as number;
  36. const max = timeseries[0].data[timeseries[0].data.length - 1].name as number;
  37. return {min, max};
  38. }
  39. type EChartMouseEventParam = Parameters<EChartClickHandler>[0];
  40. export function useMetricChartSamples({
  41. correlations,
  42. onClick,
  43. highlightedSampleId,
  44. unit = '',
  45. operation,
  46. timeseries,
  47. }: UseChartSamplesProps) {
  48. const theme = useTheme();
  49. const chartRef = useRef<ReactEchartsRef>(null);
  50. const [valueRect, setValueRect] = useState(getValueRect(chartRef));
  51. const samples: Record<string, ChartSample> = useMemo(() => {
  52. return (correlations ?? [])
  53. ?.flatMap(correlation => [
  54. ...correlation.metricSummaries.map(summaries => ({...summaries, ...correlation})),
  55. ])
  56. .reduce((acc, sample) => {
  57. acc[sample.transactionId] = sample;
  58. return acc;
  59. }, {});
  60. }, [correlations]);
  61. useEffect(() => {
  62. // Changes in timeseries change the valueRect since the timeseries yAxis auto scales
  63. // and scatter yAxis needs to match the scale
  64. setValueRect(getValueRect(chartRef));
  65. }, [chartRef, timeseries]);
  66. const xAxis: XAXisOption = useMemo(() => {
  67. const {min, max} = getDateRange(timeseries);
  68. return {
  69. id: 'xAxisScatter',
  70. scale: false,
  71. show: false,
  72. axisLabel: {
  73. formatter: () => {
  74. return '';
  75. },
  76. },
  77. axisPointer: {
  78. type: 'none',
  79. },
  80. min: Math.max(valueRect.xMin, min),
  81. max: Math.min(valueRect.xMax, max),
  82. };
  83. }, [valueRect.xMin, valueRect.xMax, timeseries]);
  84. const yAxis = useMemo(() => {
  85. return {
  86. id: 'yAxisScatter',
  87. scale: false,
  88. show: false,
  89. axisLabel: {
  90. formatter: () => {
  91. return '';
  92. },
  93. },
  94. min: valueRect.yMin,
  95. max: valueRect.yMax,
  96. };
  97. }, [valueRect.yMin, valueRect.yMax]);
  98. const getSample = useCallback(
  99. (event: EChartMouseEventParam): ChartSample | undefined => {
  100. return samples[event.seriesId];
  101. },
  102. [samples]
  103. );
  104. const handleClick = useCallback<EChartClickHandler>(
  105. (event: EChartMouseEventParam) => {
  106. if (!onClick) {
  107. return;
  108. }
  109. const sample = getSample(event);
  110. if (!sample) {
  111. return;
  112. }
  113. onClick(sample);
  114. },
  115. [getSample, onClick]
  116. );
  117. const series = useMemo(() => {
  118. if (isCumulativeOp(operation)) {
  119. // TODO: for now we do not show samples for cumulative operations,
  120. // we will implement them as marklines
  121. return [];
  122. }
  123. const normalizeMetric = getMetricValueNormalizer(unit ?? '');
  124. return Object.values(samples).map(sample => {
  125. const isHighlighted = highlightedSampleId === sample.transactionId;
  126. const xValue = moment(sample.timestamp).valueOf();
  127. const yValue = normalizeMetric(((sample.min ?? 0) + (sample.max ?? 0)) / 2) ?? 0;
  128. const [xPosition, yPosition] = fitToValueRect(xValue, yValue, valueRect);
  129. const symbol = yPosition === yValue ? 'circle' : 'arrow';
  130. const symbolRotate = yPosition > yValue ? 180 : 0;
  131. return {
  132. seriesName: sample.transactionId,
  133. id: sample.transactionId,
  134. operation: '',
  135. unit: '',
  136. symbolSize: isHighlighted ? 20 : 10,
  137. animation: false,
  138. symbol,
  139. symbolRotate,
  140. color: theme.purple400,
  141. // TODO: for now we just pass these ids through, but we should probably index
  142. // samples by an id and then just pass that reference
  143. itemStyle: {
  144. color: theme.purple400,
  145. opacity: 1,
  146. },
  147. yAxisIndex: 1,
  148. xAxisIndex: 1,
  149. xValue,
  150. yValue,
  151. tooltip: {
  152. axisPointer: {
  153. type: 'none',
  154. },
  155. },
  156. data: [
  157. {
  158. name: xPosition,
  159. value: yPosition,
  160. },
  161. ],
  162. z: 10,
  163. };
  164. });
  165. }, [operation, unit, samples, highlightedSampleId, valueRect, theme.purple400]);
  166. const formatterOptions = useMemo(() => {
  167. return {
  168. isGroupedByDate: true,
  169. limit: 1,
  170. showTimeInTooltip: true,
  171. addSecondsToTimeFormat: true,
  172. nameFormatter: (name: string) => {
  173. return t('Event %s', name.substring(0, 8));
  174. },
  175. valueFormatter: (_, label?: string) => {
  176. // We need to access the sample as the charts datapoints are fit to the charts viewport
  177. const sample = samples[label ?? ''];
  178. const yValue = ((sample.min ?? 0) + (sample.max ?? 0)) / 2;
  179. return formatMetricsUsingUnitAndOp(yValue, unit, operation);
  180. },
  181. };
  182. }, [operation, samples, unit]);
  183. const applyChartProps = useCallback(
  184. (baseProps: CombinedMetricChartProps): CombinedMetricChartProps => {
  185. return {
  186. ...baseProps,
  187. forwardedRef: mergeRefs([baseProps.forwardedRef, chartRef]),
  188. scatterSeries: series,
  189. xAxes: [...(Array.isArray(baseProps.xAxes) ? baseProps.xAxes : []), xAxis],
  190. yAxes: [...(Array.isArray(baseProps.yAxes) ? baseProps.yAxes : []), yAxis],
  191. onClick: (...args) => {
  192. handleClick(...args);
  193. baseProps.onClick?.(...args);
  194. },
  195. tooltip: {
  196. formatter: (params: any, asyncTicket) => {
  197. // Only show the tooltip if the current chart is hovered
  198. // as chart groups trigger the tooltip for all charts in the group when one is hoverered
  199. if (!isChartHovered(chartRef?.current)) {
  200. return '';
  201. }
  202. // Hovering a single correlated sample datapoint
  203. if (params.seriesType === 'scatter') {
  204. return getFormatter(formatterOptions)(params, asyncTicket);
  205. }
  206. const baseFormatter = baseProps.tooltip?.formatter;
  207. if (typeof baseFormatter === 'string') {
  208. return baseFormatter;
  209. }
  210. if (!baseFormatter) {
  211. throw new Error(
  212. 'You need to define a tooltip formatter for the chart when using metric samples'
  213. );
  214. }
  215. return baseFormatter(params, asyncTicket);
  216. },
  217. },
  218. };
  219. },
  220. [formatterOptions, handleClick, series, xAxis, yAxis]
  221. );
  222. // eslint-disable-next-line react-hooks/exhaustive-deps
  223. return useMemo(
  224. () => ({
  225. applyChartProps,
  226. }),
  227. [applyChartProps]
  228. );
  229. }
  230. export type UseMetricSamplesResult = ReturnType<typeof useMetricChartSamples>;