metricChartOption.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. import color from 'color';
  2. import type {YAXisComponentOption} from 'echarts';
  3. import moment from 'moment';
  4. import momentTimezone from 'moment-timezone';
  5. import type {AreaChartProps, AreaChartSeries} from 'sentry/components/charts/areaChart';
  6. import MarkArea from 'sentry/components/charts/components/markArea';
  7. import MarkLine from 'sentry/components/charts/components/markLine';
  8. import {CHART_PALETTE} from 'sentry/constants/chartPalette';
  9. import {t} from 'sentry/locale';
  10. import ConfigStore from 'sentry/stores/configStore';
  11. import {space} from 'sentry/styles/space';
  12. import type {SessionApiResponse} from 'sentry/types';
  13. import type {Series} from 'sentry/types/echarts';
  14. import {getCrashFreeRateSeries} from 'sentry/utils/sessions';
  15. import {lightTheme as theme} from 'sentry/utils/theme';
  16. import {
  17. AlertRuleTriggerType,
  18. Dataset,
  19. MetricRule,
  20. } from 'sentry/views/alerts/rules/metric/types';
  21. import {Incident, IncidentActivityType, IncidentStatus} from 'sentry/views/alerts/types';
  22. import {
  23. ALERT_CHART_MIN_MAX_BUFFER,
  24. alertAxisFormatter,
  25. alertTooltipValueFormatter,
  26. SESSION_AGGREGATE_TO_FIELD,
  27. shouldScaleAlertChart,
  28. } from 'sentry/views/alerts/utils';
  29. import {AlertWizardAlertNames} from 'sentry/views/alerts/wizard/options';
  30. import {getAlertTypeFromAggregateDataset} from 'sentry/views/alerts/wizard/utils';
  31. import {isCrashFreeAlert} from '../utils/isCrashFreeAlert';
  32. function formatTooltipDate(date: moment.MomentInput, format: string): string {
  33. const {
  34. options: {timezone},
  35. } = ConfigStore.get('user');
  36. return momentTimezone.tz(date, timezone).format(format);
  37. }
  38. function createStatusAreaSeries(
  39. lineColor: string,
  40. startTime: number,
  41. endTime: number,
  42. yPosition: number
  43. ): AreaChartSeries {
  44. return {
  45. seriesName: '',
  46. type: 'line',
  47. markLine: MarkLine({
  48. silent: true,
  49. lineStyle: {color: lineColor, type: 'solid', width: 4},
  50. data: [[{coord: [startTime, yPosition]}, {coord: [endTime, yPosition]}]],
  51. }),
  52. data: [],
  53. };
  54. }
  55. function createThresholdSeries(lineColor: string, threshold: number): AreaChartSeries {
  56. return {
  57. seriesName: 'Threshold Line',
  58. type: 'line',
  59. markLine: MarkLine({
  60. silent: true,
  61. lineStyle: {color: lineColor, type: 'dashed', width: 1},
  62. data: [{yAxis: threshold}],
  63. label: {
  64. show: false,
  65. },
  66. }),
  67. data: [],
  68. };
  69. }
  70. function createIncidentSeries(
  71. incident: Incident,
  72. lineColor: string,
  73. incidentTimestamp: number,
  74. dataPoint?: AreaChartSeries['data'][0],
  75. seriesName?: string,
  76. aggregate?: string,
  77. handleIncidentClick?: (incident: Incident) => void
  78. ): AreaChartSeries {
  79. const formatter = ({value, marker}: any) => {
  80. const time = formatTooltipDate(moment(value), 'MMM D, YYYY LT');
  81. return [
  82. `<div class="tooltip-series"><div>`,
  83. `<span class="tooltip-label">${marker} <strong>${t('Alert')} #${
  84. incident.identifier
  85. }</strong></span>${
  86. dataPoint?.value
  87. ? `${seriesName} ${alertTooltipValueFormatter(
  88. dataPoint.value,
  89. seriesName ?? '',
  90. aggregate ?? ''
  91. )}`
  92. : ''
  93. }`,
  94. `</div></div>`,
  95. `<div class="tooltip-footer">${time}</div>`,
  96. '<div class="tooltip-arrow"></div>',
  97. ].join('');
  98. };
  99. return {
  100. seriesName: 'Incident Line',
  101. type: 'line',
  102. markLine: MarkLine({
  103. silent: false,
  104. lineStyle: {color: lineColor, type: 'solid'},
  105. data: [
  106. {
  107. xAxis: incidentTimestamp,
  108. // @ts-expect-error onClick not in echart types
  109. onClick: () => handleIncidentClick?.(incident),
  110. },
  111. ],
  112. label: {
  113. silent: true,
  114. show: !!incident.identifier,
  115. position: 'insideEndBottom',
  116. formatter: incident.identifier,
  117. color: lineColor,
  118. fontSize: 10,
  119. fontFamily: 'Rubik',
  120. },
  121. tooltip: {
  122. formatter,
  123. },
  124. }),
  125. data: [],
  126. tooltip: {
  127. trigger: 'item',
  128. alwaysShowContent: true,
  129. formatter,
  130. },
  131. };
  132. }
  133. export type MetricChartData = {
  134. rule: MetricRule;
  135. timeseriesData: Series[];
  136. handleIncidentClick?: (incident: Incident) => void;
  137. incidents?: Incident[];
  138. selectedIncident?: Incident | null;
  139. showWaitingForData?: boolean;
  140. };
  141. type MetricChartOption = {
  142. chartOption: AreaChartProps;
  143. criticalDuration: number;
  144. totalDuration: number;
  145. waitingForDataDuration: number;
  146. warningDuration: number;
  147. };
  148. export function getMetricAlertChartOption({
  149. timeseriesData,
  150. rule,
  151. incidents,
  152. selectedIncident,
  153. handleIncidentClick,
  154. showWaitingForData,
  155. }: MetricChartData): MetricChartOption {
  156. const criticalTrigger = rule.triggers.find(
  157. ({label}) => label === AlertRuleTriggerType.CRITICAL
  158. );
  159. const warningTrigger = rule.triggers.find(
  160. ({label}) => label === AlertRuleTriggerType.WARNING
  161. );
  162. const series: AreaChartSeries[] = [...timeseriesData];
  163. const areaSeries: AreaChartSeries[] = [];
  164. // Ensure series data appears below incident/mark lines
  165. series[0].z = 1;
  166. series[0].color = CHART_PALETTE[0][0];
  167. const dataArr = timeseriesData[0].data;
  168. const maxSeriesValue = dataArr.reduce(
  169. (currMax, coord) => Math.max(currMax, coord.value),
  170. 0
  171. );
  172. // find the lowest value between chart data points, warning threshold,
  173. // critical threshold and then apply some breathing space
  174. const minChartValue = shouldScaleAlertChart(rule.aggregate)
  175. ? Math.floor(
  176. Math.min(
  177. dataArr.reduce((currMax, coord) => Math.min(currMax, coord.value), Infinity),
  178. typeof warningTrigger?.alertThreshold === 'number'
  179. ? warningTrigger.alertThreshold
  180. : Infinity,
  181. typeof criticalTrigger?.alertThreshold === 'number'
  182. ? criticalTrigger.alertThreshold
  183. : Infinity
  184. ) / ALERT_CHART_MIN_MAX_BUFFER
  185. )
  186. : 0;
  187. const firstPoint = new Date(dataArr[0]?.name).getTime();
  188. const lastPoint = new Date(dataArr[dataArr.length - 1]?.name).getTime();
  189. const totalDuration = lastPoint - firstPoint;
  190. let waitingForDataDuration = 0;
  191. let criticalDuration = 0;
  192. let warningDuration = 0;
  193. series.push(
  194. createStatusAreaSeries(theme.green300, firstPoint, lastPoint, minChartValue)
  195. );
  196. if (showWaitingForData) {
  197. const {startIndex, endIndex} = getWaitingForDataRange(dataArr);
  198. const startTime = new Date(dataArr[startIndex]?.name).getTime();
  199. const endTime = new Date(dataArr[endIndex]?.name).getTime();
  200. waitingForDataDuration = Math.abs(endTime - startTime);
  201. series.push(createStatusAreaSeries(theme.gray200, startTime, endTime, minChartValue));
  202. }
  203. if (incidents) {
  204. // select incidents that fall within the graph range
  205. incidents
  206. .filter(
  207. incident =>
  208. !incident.dateClosed || new Date(incident.dateClosed).getTime() > firstPoint
  209. )
  210. .forEach(incident => {
  211. const activities = incident.activities ?? [];
  212. const statusChanges = activities
  213. .filter(
  214. ({type, value}) =>
  215. type === IncidentActivityType.STATUS_CHANGE &&
  216. [IncidentStatus.WARNING, IncidentStatus.CRITICAL].includes(Number(value))
  217. )
  218. .sort(
  219. (a, b) =>
  220. new Date(a.dateCreated).getTime() - new Date(b.dateCreated).getTime()
  221. );
  222. const incidentEnd = incident.dateClosed ?? new Date().getTime();
  223. const timeWindowMs = rule.timeWindow * 60 * 1000;
  224. const incidentColor =
  225. warningTrigger &&
  226. !statusChanges.find(({value}) => Number(value) === IncidentStatus.CRITICAL)
  227. ? theme.yellow300
  228. : theme.red300;
  229. const incidentStartDate = new Date(incident.dateStarted).getTime();
  230. const incidentCloseDate = incident.dateClosed
  231. ? new Date(incident.dateClosed).getTime()
  232. : lastPoint;
  233. const incidentStartValue = dataArr.find(
  234. point => new Date(point.name).getTime() >= incidentStartDate
  235. );
  236. series.push(
  237. createIncidentSeries(
  238. incident,
  239. incidentColor,
  240. incidentStartDate,
  241. incidentStartValue,
  242. series[0].seriesName,
  243. rule.aggregate,
  244. handleIncidentClick
  245. )
  246. );
  247. const areaStart = Math.max(new Date(incident.dateStarted).getTime(), firstPoint);
  248. const areaEnd = Math.min(
  249. statusChanges.length && statusChanges[0].dateCreated
  250. ? new Date(statusChanges[0].dateCreated).getTime() - timeWindowMs
  251. : new Date(incidentEnd).getTime(),
  252. lastPoint
  253. );
  254. const areaColor = warningTrigger ? theme.yellow300 : theme.red300;
  255. if (areaEnd > areaStart) {
  256. series.push(
  257. createStatusAreaSeries(areaColor, areaStart, areaEnd, minChartValue)
  258. );
  259. if (areaColor === theme.yellow300) {
  260. warningDuration += Math.abs(areaEnd - areaStart);
  261. } else {
  262. criticalDuration += Math.abs(areaEnd - areaStart);
  263. }
  264. }
  265. statusChanges.forEach((activity, idx) => {
  266. const statusAreaStart = Math.max(
  267. new Date(activity.dateCreated).getTime() - timeWindowMs,
  268. firstPoint
  269. );
  270. const statusAreaEnd = Math.min(
  271. idx === statusChanges.length - 1
  272. ? new Date(incidentEnd).getTime()
  273. : new Date(statusChanges[idx + 1].dateCreated).getTime() - timeWindowMs,
  274. lastPoint
  275. );
  276. const statusAreaColor =
  277. activity.value === `${IncidentStatus.CRITICAL}`
  278. ? theme.red300
  279. : theme.yellow300;
  280. if (statusAreaEnd > statusAreaStart) {
  281. series.push(
  282. createStatusAreaSeries(
  283. statusAreaColor,
  284. statusAreaStart,
  285. statusAreaEnd,
  286. minChartValue
  287. )
  288. );
  289. if (statusAreaColor === theme.yellow300) {
  290. warningDuration += Math.abs(statusAreaEnd - statusAreaStart);
  291. } else {
  292. criticalDuration += Math.abs(statusAreaEnd - statusAreaStart);
  293. }
  294. }
  295. });
  296. if (selectedIncident && incident.id === selectedIncident.id) {
  297. const selectedIncidentColor =
  298. incidentColor === theme.yellow300 ? theme.yellow100 : theme.red100;
  299. areaSeries.push({
  300. seriesName: '',
  301. type: 'line',
  302. markArea: MarkArea({
  303. silent: true,
  304. itemStyle: {
  305. color: color(selectedIncidentColor).alpha(0.42).rgb().string(),
  306. },
  307. data: [[{xAxis: incidentStartDate}, {xAxis: incidentCloseDate}]],
  308. }),
  309. data: [],
  310. });
  311. }
  312. });
  313. }
  314. let maxThresholdValue = 0;
  315. if (!rule.comparisonDelta && warningTrigger?.alertThreshold) {
  316. const {alertThreshold} = warningTrigger;
  317. const warningThresholdLine = createThresholdSeries(theme.yellow300, alertThreshold);
  318. series.push(warningThresholdLine);
  319. maxThresholdValue = Math.max(maxThresholdValue, alertThreshold);
  320. }
  321. if (!rule.comparisonDelta && criticalTrigger?.alertThreshold) {
  322. const {alertThreshold} = criticalTrigger;
  323. const criticalThresholdLine = createThresholdSeries(theme.red300, alertThreshold);
  324. series.push(criticalThresholdLine);
  325. maxThresholdValue = Math.max(maxThresholdValue, alertThreshold);
  326. }
  327. if (!rule.comparisonDelta && rule.resolveThreshold) {
  328. const resolveThresholdLine = createThresholdSeries(
  329. theme.green300,
  330. rule.resolveThreshold
  331. );
  332. series.push(resolveThresholdLine);
  333. maxThresholdValue = Math.max(maxThresholdValue, rule.resolveThreshold);
  334. }
  335. const yAxis: YAXisComponentOption = {
  336. axisLabel: {
  337. formatter: (value: number) =>
  338. alertAxisFormatter(value, timeseriesData[0].seriesName, rule.aggregate),
  339. },
  340. max: isCrashFreeAlert(rule.dataset)
  341. ? 100
  342. : maxThresholdValue > maxSeriesValue
  343. ? maxThresholdValue
  344. : undefined,
  345. min: minChartValue || undefined,
  346. };
  347. return {
  348. criticalDuration,
  349. warningDuration,
  350. waitingForDataDuration,
  351. totalDuration,
  352. chartOption: {
  353. isGroupedByDate: true,
  354. yAxis,
  355. series,
  356. grid: {
  357. left: space(0.25),
  358. right: space(2),
  359. top: space(3),
  360. bottom: 0,
  361. },
  362. },
  363. };
  364. }
  365. function getWaitingForDataRange(dataArr) {
  366. if (dataArr[0].value > 0) {
  367. return {startIndex: 0, endIndex: 0};
  368. }
  369. for (let i = 0; i < dataArr.length; i++) {
  370. const dataPoint = dataArr[i];
  371. if (dataPoint.value > 0) {
  372. return {startIndex: 0, endIndex: i - 1};
  373. }
  374. }
  375. return {startIndex: 0, endIndex: dataArr.length - 1};
  376. }
  377. export function transformSessionResponseToSeries(
  378. response: SessionApiResponse | null,
  379. rule: MetricRule
  380. ): MetricChartData['timeseriesData'] {
  381. const {aggregate} = rule;
  382. return [
  383. {
  384. seriesName:
  385. AlertWizardAlertNames[
  386. getAlertTypeFromAggregateDataset({
  387. aggregate,
  388. dataset: Dataset.SESSIONS,
  389. })
  390. ],
  391. data: getCrashFreeRateSeries(
  392. response?.groups,
  393. response?.intervals,
  394. SESSION_AGGREGATE_TO_FIELD[aggregate]
  395. ),
  396. },
  397. ];
  398. }