thresholdsChart.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. import {PureComponent} from 'react';
  2. import color from 'color';
  3. import type {TooltipComponentFormatterCallbackParams} from 'echarts';
  4. import debounce from 'lodash/debounce';
  5. import {extrapolatedAreaStyle} from 'sentry/components/alerts/onDemandMetricAlert';
  6. import {AreaChart} from 'sentry/components/charts/areaChart';
  7. import Graphic from 'sentry/components/charts/components/graphic';
  8. import {defaultFormatAxisLabel} from 'sentry/components/charts/components/tooltip';
  9. import type {LineChartSeries} from 'sentry/components/charts/lineChart';
  10. import LineSeries from 'sentry/components/charts/series/lineSeries';
  11. import {DEFAULT_STATS_PERIOD} from 'sentry/constants';
  12. import {CHART_PALETTE} from 'sentry/constants/chartPalette';
  13. import {space} from 'sentry/styles/space';
  14. import type {PageFilters} from 'sentry/types/core';
  15. import type {ReactEchartsRef, Series} from 'sentry/types/echarts';
  16. import theme from 'sentry/utils/theme';
  17. import {getAnomalyMarkerSeries} from 'sentry/views/alerts/rules/metric/utils/anomalyChart';
  18. import type {Anomaly} from 'sentry/views/alerts/types';
  19. import {
  20. ALERT_CHART_MIN_MAX_BUFFER,
  21. alertAxisFormatter,
  22. alertTooltipValueFormatter,
  23. isSessionAggregate,
  24. shouldScaleAlertChart,
  25. } from 'sentry/views/alerts/utils';
  26. import {getChangeStatus} from 'sentry/views/alerts/utils/getChangeStatus';
  27. import type {MetricRule, Trigger} from '../../types';
  28. import {AlertRuleThresholdType, AlertRuleTriggerType} from '../../types';
  29. type DefaultProps = {
  30. comparisonData: Series[];
  31. comparisonMarkLines: LineChartSeries[];
  32. data: Series[];
  33. };
  34. type Props = DefaultProps & {
  35. aggregate: string;
  36. hideThresholdLines: boolean;
  37. resolveThreshold: MetricRule['resolveThreshold'];
  38. thresholdType: MetricRule['thresholdType'];
  39. triggers: Trigger[];
  40. anomalies?: Anomaly[];
  41. comparisonSeriesName?: string;
  42. includePrevious?: boolean;
  43. isExtrapolatedData?: boolean;
  44. maxValue?: number;
  45. minValue?: number;
  46. minutesThresholdToDisplaySeconds?: number;
  47. } & Partial<PageFilters['datetime']>;
  48. type State = {
  49. height: number;
  50. width: number;
  51. yAxisMax: number | null;
  52. yAxisMin: number | null;
  53. };
  54. const CHART_GRID = {
  55. left: space(2),
  56. right: space(2),
  57. top: space(4),
  58. bottom: space(2),
  59. };
  60. // Colors to use for trigger thresholds
  61. const COLOR = {
  62. RESOLUTION_FILL: color(theme.green200).alpha(0.1).rgb().string(),
  63. CRITICAL_FILL: color(theme.red300).alpha(0.25).rgb().string(),
  64. WARNING_FILL: color(theme.yellow200).alpha(0.1).rgb().string(),
  65. };
  66. /**
  67. * This chart displays shaded regions that represent different Trigger thresholds in a
  68. * Metric Alert rule.
  69. */
  70. export default class ThresholdsChart extends PureComponent<Props, State> {
  71. static defaultProps: DefaultProps = {
  72. data: [],
  73. comparisonData: [],
  74. comparisonMarkLines: [],
  75. };
  76. state: State = {
  77. width: -1,
  78. height: -1,
  79. yAxisMax: null,
  80. yAxisMin: null,
  81. };
  82. componentDidMount() {
  83. this.handleUpdateChartAxis();
  84. }
  85. componentDidUpdate(prevProps: Props) {
  86. if (
  87. this.props.triggers !== prevProps.triggers ||
  88. this.props.data !== prevProps.data ||
  89. this.props.comparisonData !== prevProps.comparisonData ||
  90. this.props.comparisonMarkLines !== prevProps.comparisonMarkLines
  91. ) {
  92. this.handleUpdateChartAxis();
  93. }
  94. }
  95. ref: null | ReactEchartsRef = null;
  96. // If we have ref to chart and data, try to update chart axis so that
  97. // alertThreshold or resolveThreshold is visible in chart
  98. handleUpdateChartAxis = () => {
  99. const {triggers, resolveThreshold, hideThresholdLines} = this.props;
  100. const chartRef = this.ref?.getEchartsInstance?.();
  101. if (hideThresholdLines) {
  102. return;
  103. }
  104. if (chartRef) {
  105. const thresholds = [
  106. resolveThreshold || null,
  107. ...triggers.map(t => t.alertThreshold || null),
  108. ].filter(threshold => threshold !== null) as number[];
  109. this.updateChartAxis(Math.min(...thresholds), Math.max(...thresholds));
  110. }
  111. };
  112. /**
  113. * Updates the chart so that yAxis is within bounds of our max value
  114. */
  115. updateChartAxis = debounce((minThreshold: number, maxThreshold: number) => {
  116. const {minValue, maxValue, aggregate} = this.props;
  117. const shouldScale = shouldScaleAlertChart(aggregate);
  118. let yAxisMax =
  119. shouldScale && maxValue
  120. ? this.clampMaxValue(Math.ceil(maxValue * ALERT_CHART_MIN_MAX_BUFFER))
  121. : null;
  122. let yAxisMin =
  123. shouldScale && minValue ? Math.floor(minValue / ALERT_CHART_MIN_MAX_BUFFER) : 0;
  124. if (typeof maxValue === 'number' && maxThreshold > maxValue) {
  125. yAxisMax = maxThreshold;
  126. }
  127. if (typeof minValue === 'number' && minThreshold < minValue) {
  128. yAxisMin = Math.floor(minThreshold / ALERT_CHART_MIN_MAX_BUFFER);
  129. }
  130. // We need to force update after we set a new yAxis min/max because `convertToPixel`
  131. // can return a negative position (probably because yAxisMin/yAxisMax is not synced with chart yet)
  132. this.setState({yAxisMax, yAxisMin}, this.forceUpdate);
  133. }, 150);
  134. /**
  135. * Syncs component state with the chart's width/heights
  136. */
  137. updateDimensions = () => {
  138. const chartRef = this.ref?.getEchartsInstance?.();
  139. if (!chartRef || !chartRef.getWidth?.()) {
  140. return;
  141. }
  142. const width = chartRef.getWidth();
  143. const height = chartRef.getHeight();
  144. if (width !== this.state.width || height !== this.state.height) {
  145. this.setState({
  146. width,
  147. height,
  148. });
  149. }
  150. };
  151. handleRef = (ref: ReactEchartsRef): void => {
  152. // When chart initially renders, we want to update state with its width, as well as initialize starting
  153. // locations (on y axis) for the draggable lines
  154. if (ref && !this.ref) {
  155. this.ref = ref;
  156. this.updateDimensions();
  157. this.handleUpdateChartAxis();
  158. }
  159. if (!ref) {
  160. this.ref = null;
  161. }
  162. };
  163. /**
  164. * Draws the boundary lines and shaded areas for the chart.
  165. *
  166. * May need to refactor so that they are aware of other trigger thresholds.
  167. *
  168. * e.g. draw warning from threshold -> critical threshold instead of the entire height of chart
  169. */
  170. getThresholdLine = (
  171. trigger: Trigger,
  172. type: 'alertThreshold' | 'resolveThreshold',
  173. isResolution: boolean
  174. ) => {
  175. const {thresholdType, resolveThreshold, maxValue, hideThresholdLines} = this.props;
  176. const position =
  177. type === 'alertThreshold'
  178. ? this.getChartPixelForThreshold(trigger[type])
  179. : this.getChartPixelForThreshold(resolveThreshold);
  180. const isInverted = thresholdType === AlertRuleThresholdType.BELOW;
  181. const chartRef = this.ref?.getEchartsInstance?.();
  182. if (
  183. typeof position !== 'number' ||
  184. isNaN(position) ||
  185. !this.state.height ||
  186. !chartRef ||
  187. hideThresholdLines
  188. ) {
  189. return [];
  190. }
  191. const yAxisPixelPosition = chartRef.convertToPixel(
  192. {yAxisIndex: 0},
  193. `${this.state.yAxisMin}`
  194. );
  195. const yAxisPosition = typeof yAxisPixelPosition === 'number' ? yAxisPixelPosition : 0;
  196. // As the yAxis gets larger we want to start our line/area further to the right
  197. // Handle case where the graph max is 1 and includes decimals
  198. const yAxisMax =
  199. (Math.round(Math.max(maxValue ?? 1, this.state.yAxisMax ?? 1)) * 100) / 100;
  200. const yAxisSize = 15 + (yAxisMax <= 1 ? 15 : `${yAxisMax ?? ''}`.length * 8);
  201. // Shave off the right margin and yAxisSize from the width to get the actual area we want to render content in
  202. const graphAreaWidth =
  203. this.state.width - parseInt(CHART_GRID.right.slice(0, -2), 10) - yAxisSize;
  204. // Distance from the top of the chart to save for the legend
  205. const legendPadding = 20;
  206. // Shave off the left margin
  207. const graphAreaMargin = 7;
  208. const isCritical = trigger.label === AlertRuleTriggerType.CRITICAL;
  209. const LINE_STYLE = {
  210. stroke: isResolution ? theme.green300 : isCritical ? theme.red300 : theme.yellow300,
  211. lineDash: [2],
  212. };
  213. return [
  214. // This line is used as a "border" for the shaded region
  215. // and represents the threshold value.
  216. {
  217. type: 'line',
  218. // Resolution is considered "off" if it is -1
  219. invisible: position === null,
  220. draggable: false,
  221. position: [yAxisSize, position],
  222. shape: {y1: 1, y2: 1, x1: graphAreaMargin, x2: graphAreaWidth},
  223. style: LINE_STYLE,
  224. silent: true,
  225. z: 100,
  226. },
  227. // Shaded area for incident/resolutions to show user when they can expect to be alerted
  228. // (or when they will be considered as resolved)
  229. //
  230. // Resolution is considered "off" if it is -1
  231. ...(position !== null
  232. ? [
  233. {
  234. type: 'rect',
  235. draggable: false,
  236. silent: true,
  237. position:
  238. isResolution !== isInverted
  239. ? [yAxisSize + graphAreaMargin, position + 1]
  240. : [yAxisSize + graphAreaMargin, legendPadding],
  241. shape: {
  242. width: graphAreaWidth - graphAreaMargin,
  243. height:
  244. isResolution !== isInverted
  245. ? yAxisPosition - position
  246. : position - legendPadding,
  247. },
  248. style: {
  249. fill: isResolution
  250. ? COLOR.RESOLUTION_FILL
  251. : isCritical
  252. ? COLOR.CRITICAL_FILL
  253. : COLOR.WARNING_FILL,
  254. },
  255. // This needs to be below the draggable line
  256. z: 100,
  257. },
  258. ]
  259. : []),
  260. ];
  261. };
  262. getChartPixelForThreshold = (threshold: number | '' | null) => {
  263. const chartRef = this.ref?.getEchartsInstance?.();
  264. return (
  265. threshold !== '' &&
  266. chartRef &&
  267. chartRef.convertToPixel({yAxisIndex: 0}, `${threshold}`)
  268. );
  269. };
  270. clampMaxValue(value: number) {
  271. // When we apply top buffer to the crash free percentage (99.7% * 1.03), it
  272. // can cross 100%, so we clamp it
  273. if (isSessionAggregate(this.props.aggregate) && value > 100) {
  274. return 100;
  275. }
  276. return value;
  277. }
  278. render() {
  279. const {
  280. data,
  281. triggers,
  282. period,
  283. aggregate,
  284. comparisonData,
  285. comparisonSeriesName,
  286. comparisonMarkLines,
  287. minutesThresholdToDisplaySeconds,
  288. thresholdType,
  289. anomalies = [],
  290. } = this.props;
  291. const dataWithoutRecentBucket = data?.map(({data: eventData, ...restOfData}) => {
  292. if (this.props.isExtrapolatedData) {
  293. return {
  294. ...restOfData,
  295. data: eventData.slice(0, -1),
  296. areaStyle: extrapolatedAreaStyle,
  297. };
  298. }
  299. return {
  300. ...restOfData,
  301. data: eventData.slice(0, -1),
  302. };
  303. });
  304. const comparisonDataWithoutRecentBucket = comparisonData?.map(
  305. ({data: eventData, ...restOfData}) => ({
  306. ...restOfData,
  307. data: eventData.slice(0, -1),
  308. })
  309. );
  310. const chartOptions = {
  311. tooltip: {
  312. // use the main aggregate for all series (main, min, max, avg, comparison)
  313. // to format all values similarly
  314. valueFormatter: (value: number) =>
  315. alertTooltipValueFormatter(value, aggregate, aggregate),
  316. formatAxisLabel: (
  317. value: number,
  318. isTimestamp: boolean,
  319. utc: boolean,
  320. showTimeInTooltip: boolean,
  321. addSecondsToTimeFormat: boolean,
  322. bucketSize: number | undefined,
  323. seriesParamsOrParam: TooltipComponentFormatterCallbackParams
  324. ) => {
  325. const date = defaultFormatAxisLabel(
  326. value,
  327. isTimestamp,
  328. utc,
  329. showTimeInTooltip,
  330. addSecondsToTimeFormat,
  331. bucketSize
  332. );
  333. const seriesParams = Array.isArray(seriesParamsOrParam)
  334. ? seriesParamsOrParam
  335. : [seriesParamsOrParam];
  336. const pointY = (
  337. seriesParams.length > 1 ? seriesParams[0].data[1] : undefined
  338. ) as number | undefined;
  339. const comparisonSeries =
  340. seriesParams.length > 1
  341. ? seriesParams.find(({seriesName: _sn}) => _sn === comparisonSeriesName)
  342. : undefined;
  343. const comparisonPointY = comparisonSeries?.data[1] as number | undefined;
  344. if (
  345. comparisonPointY === undefined ||
  346. pointY === undefined ||
  347. comparisonPointY === 0
  348. ) {
  349. return `<span>${date}</span>`;
  350. }
  351. const changePercentage = ((pointY - comparisonPointY) * 100) / comparisonPointY;
  352. const changeStatus = getChangeStatus(changePercentage, thresholdType, triggers);
  353. const changeStatusColor =
  354. changeStatus === AlertRuleTriggerType.CRITICAL
  355. ? theme.red300
  356. : changeStatus === AlertRuleTriggerType.WARNING
  357. ? theme.yellow300
  358. : theme.green300;
  359. return `<span>${date}<span style="color:${changeStatusColor};margin-left:10px;">
  360. ${Math.sign(changePercentage) === 1 ? '+' : '-'}${Math.abs(
  361. changePercentage
  362. ).toFixed(2)}%</span></span>`;
  363. },
  364. },
  365. yAxis: {
  366. min: this.state.yAxisMin ?? undefined,
  367. max: this.state.yAxisMax ?? undefined,
  368. axisLabel: {
  369. formatter: (value: number) =>
  370. alertAxisFormatter(value, data[0].seriesName, aggregate),
  371. },
  372. },
  373. };
  374. return (
  375. <AreaChart
  376. isGroupedByDate
  377. showTimeInTooltip
  378. minutesThresholdToDisplaySeconds={minutesThresholdToDisplaySeconds}
  379. period={DEFAULT_STATS_PERIOD || period}
  380. forwardedRef={this.handleRef}
  381. grid={CHART_GRID}
  382. {...chartOptions}
  383. graphic={Graphic({
  384. elements: triggers.flatMap((trigger: Trigger) => [
  385. ...this.getThresholdLine(trigger, 'alertThreshold', false),
  386. ...this.getThresholdLine(trigger, 'resolveThreshold', true),
  387. ]),
  388. })}
  389. colors={CHART_PALETTE[0]}
  390. series={[
  391. ...dataWithoutRecentBucket,
  392. ...comparisonMarkLines,
  393. ...getAnomalyMarkerSeries(anomalies),
  394. ]}
  395. additionalSeries={comparisonDataWithoutRecentBucket.map(
  396. ({data: _data, ...otherSeriesProps}) =>
  397. LineSeries({
  398. name: comparisonSeriesName,
  399. data: _data.map(({name, value}) => [name, value]),
  400. lineStyle: {color: theme.gray200, type: 'dashed', width: 1},
  401. itemStyle: {color: theme.gray200},
  402. animation: false,
  403. animationThreshold: 1,
  404. animationDuration: 0,
  405. ...otherSeriesProps,
  406. })
  407. )}
  408. onFinished={() => {
  409. // We want to do this whenever the chart finishes re-rendering so that we can update the dimensions of
  410. // any graphics related to the triggers (e.g. the threshold areas + boundaries)
  411. this.updateDimensions();
  412. }}
  413. />
  414. );
  415. }
  416. }