useChartSamples.tsx 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. import type {RefObject} from 'react';
  2. import {useCallback, useEffect, useMemo, 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 {t} from 'sentry/locale';
  7. import type {ReactEchartsRef, Series} from 'sentry/types/echarts';
  8. import {isCumulativeOp} from 'sentry/utils/metrics';
  9. import {formatMetricsUsingUnitAndOp} from 'sentry/utils/metrics/formatters';
  10. import {getMetricValueNormalizer} from 'sentry/utils/metrics/normalizeMetricValue';
  11. import type {MetricCorrelation, MetricSummary} from 'sentry/utils/metrics/types';
  12. import {fitToValueRect, getValueRect} from 'sentry/views/ddm/chartUtils';
  13. import type {Sample} from 'sentry/views/ddm/widget';
  14. type UseChartSamplesProps = {
  15. timeseries: Series[];
  16. chartRef?: RefObject<ReactEchartsRef>;
  17. correlations?: MetricCorrelation[];
  18. highlightedSampleId?: string;
  19. onClick?: (sample: Sample) => void;
  20. onMouseOut?: (sample: Sample) => void;
  21. onMouseOver?: (sample: Sample) => void;
  22. operation?: string;
  23. unit?: string;
  24. };
  25. // TODO: remove this once we have a stabilized type for this
  26. type ChartSample = MetricCorrelation & MetricSummary;
  27. function getDateRange(timeseries: Series[]) {
  28. if (!timeseries?.length) {
  29. return {min: -Infinity, max: Infinity};
  30. }
  31. const min = timeseries[0].data[0].name as number;
  32. const max = timeseries[0].data[timeseries[0].data.length - 1].name as number;
  33. return {min, max};
  34. }
  35. export function useChartSamples({
  36. correlations,
  37. onClick,
  38. highlightedSampleId,
  39. unit = '',
  40. chartRef,
  41. operation,
  42. timeseries,
  43. }: UseChartSamplesProps) {
  44. const theme = useTheme();
  45. const [valueRect, setValueRect] = useState(getValueRect(chartRef));
  46. const samples: Record<string, ChartSample> = useMemo(() => {
  47. return (correlations ?? [])
  48. ?.flatMap(correlation => [
  49. ...correlation.metricSummaries.map(summaries => ({...summaries, ...correlation})),
  50. ])
  51. .reduce((acc, sample) => {
  52. acc[sample.transactionId] = sample;
  53. return acc;
  54. }, {});
  55. }, [correlations]);
  56. useEffect(() => {
  57. // Changes in timeseries change the valueRect since the timeseries yAxis auto scales
  58. // and scatter yAxis needs to match the scale
  59. setValueRect(getValueRect(chartRef));
  60. }, [chartRef, timeseries]);
  61. const xAxis: XAXisOption = useMemo(() => {
  62. const {min, max} = getDateRange(timeseries);
  63. return {
  64. id: 'xAxisScatter',
  65. scale: false,
  66. show: false,
  67. axisLabel: {
  68. formatter: () => {
  69. return '';
  70. },
  71. },
  72. axisPointer: {
  73. type: 'none',
  74. },
  75. min: Math.max(valueRect.xMin, min),
  76. max: Math.min(valueRect.xMax, max),
  77. };
  78. }, [valueRect.xMin, valueRect.xMax, timeseries]);
  79. const yAxis = useMemo(() => {
  80. return {
  81. id: 'yAxisScatter',
  82. scale: false,
  83. show: false,
  84. axisLabel: {
  85. formatter: () => {
  86. return '';
  87. },
  88. },
  89. min: valueRect.yMin,
  90. max: valueRect.yMax,
  91. };
  92. }, [valueRect.yMin, valueRect.yMax]);
  93. const getSample = useCallback(
  94. event => {
  95. return samples?.[event.seriesName] as Sample;
  96. },
  97. [samples]
  98. );
  99. const handleClick = useCallback(
  100. event => {
  101. if (!onClick) {
  102. return;
  103. }
  104. const sample = getSample(event);
  105. if (!sample) {
  106. return;
  107. }
  108. onClick(sample);
  109. },
  110. [getSample, onClick]
  111. );
  112. const series = useMemo(() => {
  113. if (isCumulativeOp(operation)) {
  114. // TODO: for now we do not show samples for cumulative operations,
  115. // we will implement them as marklines
  116. return [];
  117. }
  118. const normalizeMetric = getMetricValueNormalizer(unit ?? '');
  119. return Object.values(samples).map(sample => {
  120. const isHighlighted = highlightedSampleId === sample.transactionId;
  121. const xValue = moment(sample.timestamp).valueOf();
  122. const yValue = normalizeMetric(((sample.min ?? 0) + (sample.max ?? 0)) / 2) ?? 0;
  123. const [xPosition, yPosition] = fitToValueRect(xValue, yValue, valueRect);
  124. const symbol = yPosition === yValue ? 'circle' : 'arrow';
  125. const symbolRotate = yPosition > yValue ? 180 : 0;
  126. return {
  127. seriesName: sample.transactionId,
  128. id: sample.transactionId,
  129. // TODO: we should not use the same Series type for samples and metrics
  130. operation: '',
  131. unit: '',
  132. symbolSize: isHighlighted ? 20 : 10,
  133. animation: false,
  134. symbol,
  135. symbolRotate,
  136. color: theme.purple400,
  137. // TODO: for now we just pass these ids through, but we should probably index
  138. // samples by an id and then just pass that reference
  139. transactionId: sample.transactionId,
  140. transactionSpanId: sample.transactionSpanId,
  141. spanId: sample.spanId,
  142. projectId: sample.projectId,
  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 formatters = 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. return {
  184. handleClick,
  185. series,
  186. xAxis,
  187. yAxis,
  188. formatters,
  189. };
  190. }