123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450 |
- 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 {Series} from 'sentry/types/echarts';
- import type {SessionApiResponse} from 'sentry/types/organization';
- 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 [
- `<div class="tooltip-series"><div>`,
- `<span class="tooltip-label">${marker} <strong>${t('Alert')} #${
- incident.identifier
- }</strong></span>${
- dataPoint?.value
- ? `${seriesName} ${alertTooltipValueFormatter(
- dataPoint.value,
- seriesName ?? '',
- aggregate ?? ''
- )}`
- : ''
- }`,
- `</div></div>`,
- `<div class="tooltip-footer">${time}</div>`,
- '<div class="tooltip-arrow"></div>',
- ].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]
- ),
- },
- ];
- }
|