metricChartOption.tsx 13 KB

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