thresholdsChart.tsx 14 KB

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