metricChartOption.tsx 13 KB

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