metricChartOption.tsx 14 KB

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