metricChartOption.tsx 14 KB

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