thresholdsChart.tsx 14 KB

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