import color from 'color'; import type {YAXisComponentOption} from 'echarts'; import moment from 'moment'; import momentTimezone from 'moment-timezone'; import type {AreaChartProps, AreaChartSeries} from 'sentry/components/charts/areaChart'; import MarkArea from 'sentry/components/charts/components/markArea'; import MarkLine from 'sentry/components/charts/components/markLine'; import {CHART_PALETTE} from 'sentry/constants/chartPalette'; import {t} from 'sentry/locale'; import ConfigStore from 'sentry/stores/configStore'; import {space} from 'sentry/styles/space'; import type {SessionApiResponse} from 'sentry/types'; import type {Series} from 'sentry/types/echarts'; import {formatMRIField} from 'sentry/utils/metrics/mri'; import {getCrashFreeRateSeries} from 'sentry/utils/sessions'; import {lightTheme as theme} from 'sentry/utils/theme'; import type {MetricRule, Trigger} from 'sentry/views/alerts/rules/metric/types'; import {AlertRuleTriggerType, Dataset} from 'sentry/views/alerts/rules/metric/types'; import type {Incident} from 'sentry/views/alerts/types'; import {IncidentActivityType, IncidentStatus} from 'sentry/views/alerts/types'; import { ALERT_CHART_MIN_MAX_BUFFER, alertAxisFormatter, alertTooltipValueFormatter, SESSION_AGGREGATE_TO_FIELD, shouldScaleAlertChart, } from 'sentry/views/alerts/utils'; import {AlertWizardAlertNames} from 'sentry/views/alerts/wizard/options'; import {getAlertTypeFromAggregateDataset} from 'sentry/views/alerts/wizard/utils'; import {isCrashFreeAlert} from '../utils/isCrashFreeAlert'; function formatTooltipDate(date: moment.MomentInput, format: string): string { const { options: {timezone}, } = ConfigStore.get('user'); return momentTimezone.tz(date, timezone).format(format); } function createStatusAreaSeries( lineColor: string, startTime: number, endTime: number, yPosition: number ): AreaChartSeries { return { seriesName: '', type: 'line', markLine: MarkLine({ silent: true, lineStyle: {color: lineColor, type: 'solid', width: 4}, data: [[{coord: [startTime, yPosition]}, {coord: [endTime, yPosition]}]], }), data: [], }; } function createThresholdSeries(lineColor: string, threshold: number): AreaChartSeries { return { seriesName: 'Threshold Line', type: 'line', markLine: MarkLine({ silent: true, lineStyle: {color: lineColor, type: 'dashed', width: 1}, data: [{yAxis: threshold}], label: { show: false, }, }), data: [], }; } function createIncidentSeries( incident: Incident, lineColor: string, incidentTimestamp: number, dataPoint?: AreaChartSeries['data'][0], seriesName?: string, aggregate?: string, handleIncidentClick?: (incident: Incident) => void ): AreaChartSeries { const formatter = ({value, marker}: any) => { const time = formatTooltipDate(moment(value), 'MMM D, YYYY LT'); return [ `
`, `${marker} ${t('Alert')} #${ incident.identifier }${ dataPoint?.value ? `${seriesName} ${alertTooltipValueFormatter( dataPoint.value, seriesName ?? '', aggregate ?? '' )}` : '' }`, `
`, ``, '
', ].join(''); }; return { seriesName: 'Incident Line', type: 'line', markLine: MarkLine({ silent: false, lineStyle: {color: lineColor, type: 'solid'}, data: [ { xAxis: incidentTimestamp, // @ts-expect-error onClick not in echart types onClick: () => handleIncidentClick?.(incident), }, ], label: { silent: true, show: !!incident.identifier, position: 'insideEndBottom', formatter: incident.identifier, color: lineColor, fontSize: 10, fontFamily: 'Rubik', }, tooltip: { formatter, }, }), data: [], tooltip: { trigger: 'item', alwaysShowContent: true, formatter, }, }; } export type MetricChartData = { rule: MetricRule; timeseriesData: Series[]; handleIncidentClick?: (incident: Incident) => void; incidents?: Incident[]; selectedIncident?: Incident | null; showWaitingForData?: boolean; }; type MetricChartOption = { chartOption: AreaChartProps; criticalDuration: number; totalDuration: number; waitingForDataDuration: number; warningDuration: number; }; export function getMetricAlertChartOption({ timeseriesData, rule, incidents, selectedIncident, handleIncidentClick, showWaitingForData, }: MetricChartData): MetricChartOption { let criticalTrigger: Trigger | undefined; let warningTrigger: Trigger | undefined; for (const trigger of rule.triggers) { if (trigger.label === AlertRuleTriggerType.CRITICAL) { criticalTrigger ??= trigger; } if (trigger.label === AlertRuleTriggerType.WARNING) { warningTrigger ??= trigger; } if (criticalTrigger && warningTrigger) { break; } } const series: AreaChartSeries[] = timeseriesData.map(s => ({ ...s, seriesName: s.seriesName && formatMRIField(s.seriesName), })); const areaSeries: AreaChartSeries[] = []; // Ensure series data appears below incident/mark lines series[0].z = 1; series[0].color = CHART_PALETTE[0][0]; const dataArr = timeseriesData[0].data; let maxSeriesValue = Number.NEGATIVE_INFINITY; let minSeriesValue = Number.POSITIVE_INFINITY; for (const coord of dataArr) { if (coord.value > maxSeriesValue) { maxSeriesValue = coord.value; } if (coord.value < minSeriesValue) { minSeriesValue = coord.value; } } // find the lowest value between chart data points, warning threshold, // critical threshold and then apply some breathing space const minChartValue = shouldScaleAlertChart(rule.aggregate) ? Math.floor( Math.min( minSeriesValue, typeof warningTrigger?.alertThreshold === 'number' ? warningTrigger.alertThreshold : Infinity, typeof criticalTrigger?.alertThreshold === 'number' ? criticalTrigger.alertThreshold : Infinity ) / ALERT_CHART_MIN_MAX_BUFFER ) : 0; const firstPoint = new Date(dataArr[0]?.name).getTime(); const lastPoint = new Date(dataArr[dataArr.length - 1]?.name).getTime(); const totalDuration = lastPoint - firstPoint; let waitingForDataDuration = 0; let criticalDuration = 0; let warningDuration = 0; series.push( createStatusAreaSeries(theme.green300, firstPoint, lastPoint, minChartValue) ); if (showWaitingForData) { const {startIndex, endIndex} = getWaitingForDataRange(dataArr); const startTime = new Date(dataArr[startIndex]?.name).getTime(); const endTime = new Date(dataArr[endIndex]?.name).getTime(); waitingForDataDuration = Math.abs(endTime - startTime); series.push(createStatusAreaSeries(theme.gray200, startTime, endTime, minChartValue)); } if (incidents) { // select incidents that fall within the graph range incidents .filter( incident => !incident.dateClosed || new Date(incident.dateClosed).getTime() > firstPoint ) .forEach(incident => { const activities = incident.activities ?? []; const statusChanges = activities .filter( ({type, value}) => type === IncidentActivityType.STATUS_CHANGE && [IncidentStatus.WARNING, IncidentStatus.CRITICAL].includes(Number(value)) ) .sort( (a, b) => new Date(a.dateCreated).getTime() - new Date(b.dateCreated).getTime() ); const incidentEnd = incident.dateClosed ?? new Date().getTime(); const timeWindowMs = rule.timeWindow * 60 * 1000; const incidentColor = warningTrigger && !statusChanges.find(({value}) => Number(value) === IncidentStatus.CRITICAL) ? theme.yellow300 : theme.red300; const incidentStartDate = new Date(incident.dateStarted).getTime(); const incidentCloseDate = incident.dateClosed ? new Date(incident.dateClosed).getTime() : lastPoint; const incidentStartValue = dataArr.find( point => new Date(point.name).getTime() >= incidentStartDate ); series.push( createIncidentSeries( incident, incidentColor, incidentStartDate, incidentStartValue, series[0].seriesName, rule.aggregate, handleIncidentClick ) ); const areaStart = Math.max(new Date(incident.dateStarted).getTime(), firstPoint); const areaEnd = Math.min( statusChanges.length && statusChanges[0].dateCreated ? new Date(statusChanges[0].dateCreated).getTime() - timeWindowMs : new Date(incidentEnd).getTime(), lastPoint ); const areaColor = warningTrigger ? theme.yellow300 : theme.red300; if (areaEnd > areaStart) { series.push( createStatusAreaSeries(areaColor, areaStart, areaEnd, minChartValue) ); if (areaColor === theme.yellow300) { warningDuration += Math.abs(areaEnd - areaStart); } else { criticalDuration += Math.abs(areaEnd - areaStart); } } statusChanges.forEach((activity, idx) => { const statusAreaStart = Math.max( new Date(activity.dateCreated).getTime() - timeWindowMs, firstPoint ); const statusAreaEnd = Math.min( idx === statusChanges.length - 1 ? new Date(incidentEnd).getTime() : new Date(statusChanges[idx + 1].dateCreated).getTime() - timeWindowMs, lastPoint ); const statusAreaColor = activity.value === `${IncidentStatus.CRITICAL}` ? theme.red300 : theme.yellow300; if (statusAreaEnd > statusAreaStart) { series.push( createStatusAreaSeries( statusAreaColor, statusAreaStart, statusAreaEnd, minChartValue ) ); if (statusAreaColor === theme.yellow300) { warningDuration += Math.abs(statusAreaEnd - statusAreaStart); } else { criticalDuration += Math.abs(statusAreaEnd - statusAreaStart); } } }); if (selectedIncident && incident.id === selectedIncident.id) { const selectedIncidentColor = incidentColor === theme.yellow300 ? theme.yellow100 : theme.red100; areaSeries.push({ seriesName: '', type: 'line', markArea: MarkArea({ silent: true, itemStyle: { color: color(selectedIncidentColor).alpha(0.42).rgb().string(), }, data: [[{xAxis: incidentStartDate}, {xAxis: incidentCloseDate}]], }), data: [], }); } }); } let maxThresholdValue = 0; if (!rule.comparisonDelta && warningTrigger?.alertThreshold) { const {alertThreshold} = warningTrigger; const warningThresholdLine = createThresholdSeries(theme.yellow300, alertThreshold); series.push(warningThresholdLine); maxThresholdValue = Math.max(maxThresholdValue, alertThreshold); } if (!rule.comparisonDelta && criticalTrigger?.alertThreshold) { const {alertThreshold} = criticalTrigger; const criticalThresholdLine = createThresholdSeries(theme.red300, alertThreshold); series.push(criticalThresholdLine); maxThresholdValue = Math.max(maxThresholdValue, alertThreshold); } if (!rule.comparisonDelta && rule.resolveThreshold) { const resolveThresholdLine = createThresholdSeries( theme.green300, rule.resolveThreshold ); series.push(resolveThresholdLine); maxThresholdValue = Math.max(maxThresholdValue, rule.resolveThreshold); } const yAxis: YAXisComponentOption = { axisLabel: { formatter: (value: number) => alertAxisFormatter(value, timeseriesData[0].seriesName, rule.aggregate), }, max: isCrashFreeAlert(rule.dataset) ? 100 : maxThresholdValue > maxSeriesValue ? maxThresholdValue : undefined, min: minChartValue || undefined, }; return { criticalDuration, warningDuration, waitingForDataDuration, totalDuration, chartOption: { isGroupedByDate: true, yAxis, series, grid: { left: space(0.25), right: space(2), top: space(3), bottom: 0, }, }, }; } function getWaitingForDataRange(dataArr) { if (dataArr[0].value > 0) { return {startIndex: 0, endIndex: 0}; } for (let i = 0; i < dataArr.length; i++) { const dataPoint = dataArr[i]; if (dataPoint.value > 0) { return {startIndex: 0, endIndex: i - 1}; } } return {startIndex: 0, endIndex: dataArr.length - 1}; } export function transformSessionResponseToSeries( response: SessionApiResponse | null, rule: MetricRule ): MetricChartData['timeseriesData'] { const {aggregate} = rule; return [ { seriesName: AlertWizardAlertNames[ getAlertTypeFromAggregateDataset({ aggregate, dataset: Dataset.SESSIONS, }) ], data: getCrashFreeRateSeries( response?.groups, response?.intervals, SESSION_AGGREGATE_TO_FIELD[aggregate] ), }, ]; }