diff --git a/static/app/chartcuterie/config.tsx b/static/app/chartcuterie/config.tsx index 7483417c7a8aad..c9931455b369ea 100644 --- a/static/app/chartcuterie/config.tsx +++ b/static/app/chartcuterie/config.tsx @@ -13,6 +13,7 @@ import {lightTheme} from 'sentry/utils/theme/theme'; import {makeDiscoverCharts} from './discover'; import {makeMetricAlertCharts} from './metricAlert'; +import {makeMetricDetectorCharts} from './metricDetector'; import {makePerformanceCharts} from './performance'; import type { ChartcuterieConfig, @@ -42,6 +43,7 @@ const register = (renderDescriptor: RenderDescriptor) => makeDiscoverCharts(lightTheme).forEach(register); makeMetricAlertCharts(lightTheme).forEach(register); +makeMetricDetectorCharts(lightTheme).forEach(register); makePerformanceCharts(lightTheme).forEach(register); export default config; diff --git a/static/app/chartcuterie/metricDetector.tsx b/static/app/chartcuterie/metricDetector.tsx new file mode 100644 index 00000000000000..32ffb9cbbfd1c0 --- /dev/null +++ b/static/app/chartcuterie/metricDetector.tsx @@ -0,0 +1,129 @@ +import type {Theme} from '@emotion/react'; +import type {LineSeriesOption, YAXisComponentOption} from 'echarts'; + +import type {AreaChartSeries} from 'sentry/components/charts/areaChart'; +import XAxis from 'sentry/components/charts/components/xAxis'; +import AreaSeries from 'sentry/components/charts/series/areaSeries'; +import type {SessionApiResponse} from 'sentry/types/organization'; +import { + getMetricDetectorChartOption, + transformSessionResponseToSeries, + type MetricDetectorChartData, +} from 'sentry/views/detectors/components/details/metric/charts/metricDetectorChartOptions'; + +import {DEFAULT_FONT_FAMILY, makeSlackChartDefaults, slackChartSize} from './slack'; +import type {RenderDescriptor} from './types'; +import {ChartType} from './types'; + +function transformAreaSeries(series: AreaChartSeries[]): LineSeriesOption[] { + return series.map(({seriesName, data, ...otherSeriesProps}) => { + const areaSeries = AreaSeries({ + name: seriesName, + data: data.map(({name, value}) => [name, value]), + lineStyle: { + opacity: 1, + width: 0.4, + }, + areaStyle: { + opacity: 1.0, + }, + animation: false, + animationThreshold: 1, + animationDuration: 0, + ...otherSeriesProps, + }); + + // Fix incident label font family, cannot use Rubik + if (areaSeries.markLine?.label) { + areaSeries.markLine.label.fontFamily = DEFAULT_FONT_FAMILY; + } + + return areaSeries; + }); +} + +export function makeMetricDetectorCharts( + theme: Theme +): Array> { + const slackChartDefaults = makeSlackChartDefaults(theme); + const metricDetectorCharts: Array> = []; + + const metricDetectorXaxis = XAxis({ + theme, + splitNumber: 3, + isGroupedByDate: true, + axisLabel: {fontSize: 11, fontFamily: DEFAULT_FONT_FAMILY}, + }); + const metricDetectorYaxis: YAXisComponentOption = { + axisLabel: {fontSize: 11, fontFamily: DEFAULT_FONT_FAMILY}, + splitLine: { + lineStyle: { + color: theme.chartLineColor, + opacity: 0.3, + }, + }, + }; + + metricDetectorCharts.push({ + key: ChartType.SLACK_METRIC_DETECTOR_EVENTS, + getOption: (data: MetricDetectorChartData) => { + const {chartOption} = getMetricDetectorChartOption(data, theme); + + return { + ...chartOption, + backgroundColor: theme.background, + series: transformAreaSeries(chartOption.series), + xAxis: metricDetectorXaxis, + yAxis: { + ...chartOption.yAxis, + ...metricDetectorYaxis, + axisLabel: { + ...chartOption.yAxis!.axisLabel, + ...metricDetectorYaxis.axisLabel, + }, + }, + grid: slackChartDefaults.grid, + }; + }, + ...slackChartSize, + }); + + interface MetricDetectorSessionData + extends Omit { + sessionResponse: SessionApiResponse; + } + + metricDetectorCharts.push({ + key: ChartType.SLACK_METRIC_DETECTOR_SESSIONS, + getOption: (data: MetricDetectorSessionData) => { + const {sessionResponse, detector, ...rest} = data; + const {chartOption} = getMetricDetectorChartOption( + { + ...rest, + detector, + timeseriesData: transformSessionResponseToSeries(sessionResponse, detector), + }, + theme + ); + + return { + ...chartOption, + backgroundColor: theme.background, + series: transformAreaSeries(chartOption.series), + xAxis: metricDetectorXaxis, + yAxis: { + ...chartOption.yAxis, + ...metricDetectorYaxis, + axisLabel: { + ...chartOption.yAxis!.axisLabel, + ...metricDetectorYaxis.axisLabel, + }, + }, + grid: slackChartDefaults.grid, + }; + }, + ...slackChartSize, + }); + + return metricDetectorCharts; +} diff --git a/static/app/chartcuterie/types.tsx b/static/app/chartcuterie/types.tsx index 526a15747f79b8..79857a14810c83 100644 --- a/static/app/chartcuterie/types.tsx +++ b/static/app/chartcuterie/types.tsx @@ -16,6 +16,8 @@ export enum ChartType { SLACK_DISCOVER_PREVIOUS_PERIOD = 'slack:discover.previousPeriod', SLACK_METRIC_ALERT_EVENTS = 'slack:metricAlert.events', SLACK_METRIC_ALERT_SESSIONS = 'slack:metricAlert.sessions', + SLACK_METRIC_DETECTOR_EVENTS = 'slack:metricDetector.events', + SLACK_METRIC_DETECTOR_SESSIONS = 'slack:metricDetector.sessions', SLACK_PERFORMANCE_ENDPOINT_REGRESSION = 'slack:performance.endpointRegression', SLACK_PERFORMANCE_FUNCTION_REGRESSION = 'slack:performance.functionRegression', } diff --git a/static/app/types/workflowEngine/detectors.tsx b/static/app/types/workflowEngine/detectors.tsx index d1b7d1ca7d088f..aa782f9fb27632 100644 --- a/static/app/types/workflowEngine/detectors.tsx +++ b/static/app/types/workflowEngine/detectors.tsx @@ -48,7 +48,7 @@ export interface SnubaQueryDataSource extends BaseDataSource { snubaQuery: SnubaQuery; status: number; subscription: string; - } | null; + }; type: 'snuba_query_subscription'; } diff --git a/static/app/views/detectors/components/details/metric/charts/metricDetectorChartOptions.tsx b/static/app/views/detectors/components/details/metric/charts/metricDetectorChartOptions.tsx new file mode 100644 index 00000000000000..0d76f1f84b0dd1 --- /dev/null +++ b/static/app/views/detectors/components/details/metric/charts/metricDetectorChartOptions.tsx @@ -0,0 +1,452 @@ +import type {Theme} from '@emotion/react'; +import color from 'color'; +import type {YAXisComponentOption} from 'echarts'; +import moment 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 {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import type {Series} from 'sentry/types/echarts'; +import type {GroupOpenPeriod} from 'sentry/types/group'; +import type {SessionApiResponse} from 'sentry/types/organization'; +import {DetectorPriorityLevel} from 'sentry/types/workflowEngine/dataConditions'; +import type {MetricDetector} from 'sentry/types/workflowEngine/detectors'; +import {getCrashFreeRateSeries} from 'sentry/utils/sessions'; +import {Dataset} from 'sentry/views/alerts/rules/metric/types'; +import {getAnomalyMarkerSeries} from 'sentry/views/alerts/rules/metric/utils/anomalyChart'; +import {isCrashFreeAlert} from 'sentry/views/alerts/rules/metric/utils/isCrashFreeAlert'; +import type {Anomaly} from 'sentry/views/alerts/types'; +import {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'; + +function formatTooltipDate(date: moment.MomentInput, format: string): string { + return moment(date).format(format); +} + +function createStatusAreaSeries( + lineColor: string, + startTime: number, + endTime: number, + yPosition: number +): AreaChartSeries { + return { + seriesName: 'Status Area', + 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( + openPeriod: GroupOpenPeriod, + lineColor: string, + incidentTimestamp: number, + dataPoint?: AreaChartSeries['data'][0], + seriesName?: string, + aggregate?: string +): AreaChartSeries { + const formatter = ({value, marker}: any) => { + const time = formatTooltipDate(moment(value), 'MMM D, YYYY LT'); + return [ + `
`, + `${marker} ${t('Alert')} #${ + openPeriod.id + }${ + 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, + }, + ], + label: { + silent: true, + show: !!openPeriod.id, + position: 'insideEndBottom', + formatter: openPeriod.id, + color: lineColor, + fontSize: 10, + fontFamily: 'Rubik', + }, + tooltip: { + formatter, + }, + }), + data: [], + tooltip: { + trigger: 'item', + alwaysShowContent: true, + formatter, + }, + }; +} + +export type MetricDetectorChartData = { + detector: MetricDetector; + timeseriesData: Series[]; + anomalies?: Anomaly[]; + openPeriods?: GroupOpenPeriod[]; + selectedOpenPeriod?: GroupOpenPeriod | null; + seriesName?: string; + showWaitingForData?: boolean; +}; + +export type MetricDetectorChartOption = { + chartOption: AreaChartProps; + criticalDuration: number; + totalDuration: number; + waitingForDataDuration: number; + warningDuration: number; +}; + +export function getMetricDetectorChartOption( + { + timeseriesData, + detector, + seriesName, + openPeriods, + selectedOpenPeriod, + showWaitingForData, + anomalies, + }: MetricDetectorChartData, + theme: Theme +): MetricDetectorChartOption { + const criticalCondition = detector.conditionGroup?.conditions?.find( + condition => condition.conditionResult === DetectorPriorityLevel.HIGH + ); + const warningCondition = detector.conditionGroup?.conditions?.find( + condition => condition.conditionResult === DetectorPriorityLevel.MEDIUM + ); + const resolutionCondition = detector.conditionGroup?.conditions?.find( + condition => condition.conditionResult === DetectorPriorityLevel.OK + ); + + const dataSource = detector.dataSources[0]; + const snubaQuery = dataSource.queryObj.snubaQuery; + + const series: AreaChartSeries[] = timeseriesData.map(s => s); + const areaSeries: AreaChartSeries[] = []; + const colors = theme.chart.getColorPalette(0); + // Ensure series data appears below incident/mark lines + series[0]!.z = 1; + series[0]!.color = colors[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(snubaQuery.aggregate) + ? Math.floor( + Math.min( + minSeriesValue, + typeof warningCondition?.comparison === 'number' + ? warningCondition.comparison + : Infinity, + typeof criticalCondition?.comparison === 'number' + ? criticalCondition.comparison + : Infinity + ) / ALERT_CHART_MIN_MAX_BUFFER + ) + : 0; + const startDate = new Date(dataArr[0]?.name!); + + const endDate = dataArr.length > 1 ? new Date(dataArr.at(-1)!.name) : new Date(); + const firstPoint = startDate.getTime(); + const lastPoint = endDate.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 (openPeriods) { + openPeriods + .filter( + openPeriod => !openPeriod.end || new Date(openPeriod.end).getTime() > firstPoint + ) + .forEach(openPeriod => { + const statusChanges = openPeriod.activities + .filter( + ({type, value}) => + type === 'status_change' && (value === 'medium' || value === 'high') + ) + .sort( + (a, b) => + new Date(a.dateCreated).getTime() - new Date(b.dateCreated).getTime() + ); + + const incidentEnd = openPeriod.end ?? Date.now(); + + const timeWindowMs = snubaQuery.timeWindow * 60 * 1000; + const incidentColor = + warningCondition && + statusChanges.some(({value}) => Number(value) === IncidentStatus.CRITICAL) + ? theme.red300 + : theme.yellow300; + + const incidentStartDate = new Date(openPeriod.start).getTime(); + const incidentCloseDate = openPeriod.end + ? new Date(openPeriod.end).getTime() + : lastPoint; + const incidentStartValue = dataArr.find( + point => new Date(point.name).getTime() >= incidentStartDate + ); + series.push( + createIncidentSeries( + openPeriod, + incidentColor, + incidentStartDate, + incidentStartValue, + seriesName ?? series[0]!.seriesName, + snubaQuery.aggregate + ) + ); + const areaStart = Math.max(new Date(openPeriod.start).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 = warningCondition ? 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 === 'high' ? 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 (selectedOpenPeriod && openPeriod.id === selectedOpenPeriod.id) { + const selectedIncidentColor = + incidentColor === theme.yellow300 ? theme.yellow100 : theme.red100; + + // Is areaSeries used anywhere? + areaSeries.push({ + seriesName: '', + type: 'line', + markArea: MarkArea({ + silent: true, + itemStyle: { + color: color(selectedIncidentColor).alpha(0.42).rgb().string(), + }, + data: [[{xAxis: incidentStartDate}, {xAxis: incidentCloseDate}]], + }), + data: [], + }); + } + }); + } + if (anomalies) { + series.push(...getAnomalyMarkerSeries(anomalies, {startDate, endDate, theme})); + } + let maxThresholdValue = 0; + if ( + detector.config.detectionType === 'static' && + typeof warningCondition?.comparison === 'number' + ) { + const warningThreshold = warningCondition.comparison; + const warningThresholdLine = createThresholdSeries(theme.yellow300, warningThreshold); + series.push(warningThresholdLine); + maxThresholdValue = Math.max(maxThresholdValue, warningThreshold); + } + + if ( + detector.config.detectionType === 'static' && + typeof criticalCondition?.comparison === 'number' + ) { + const criticalThreshold = criticalCondition.comparison; + const criticalThresholdLine = createThresholdSeries(theme.red300, criticalThreshold); + series.push(criticalThresholdLine); + maxThresholdValue = Math.max(maxThresholdValue, criticalThreshold); + } + + if ( + detector.config.detectionType === 'static' && + typeof resolutionCondition?.comparison === 'number' + ) { + const resolveThresholdLine = createThresholdSeries( + theme.green300, + resolutionCondition.comparison + ); + series.push(resolveThresholdLine); + maxThresholdValue = Math.max(maxThresholdValue, resolutionCondition.comparison); + } + + const yAxis: YAXisComponentOption = { + axisLabel: { + formatter: (value: number) => + alertAxisFormatter(value, timeseriesData[0]!.seriesName, snubaQuery.aggregate), + }, + max: isCrashFreeAlert(snubaQuery.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: any) { + 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, + detector: MetricDetector +): MetricDetectorChartData['timeseriesData'] { + const {aggregate} = detector.dataSources[0].queryObj.snubaQuery; + + return [ + { + seriesName: + AlertWizardAlertNames[ + getAlertTypeFromAggregateDataset({ + aggregate, + dataset: Dataset.SESSIONS, + }) + ], + data: getCrashFreeRateSeries( + response?.groups, + response?.intervals, + // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + SESSION_AGGREGATE_TO_FIELD[aggregate] + ), + }, + ]; +}