diff --git a/static/app/views/detectors/components/details/metric/chart.tsx b/static/app/views/detectors/components/details/metric/chart.tsx index 580de6a05fb9b4..da370a98c3bc36 100644 --- a/static/app/views/detectors/components/details/metric/chart.tsx +++ b/static/app/views/detectors/components/details/metric/chart.tsx @@ -29,6 +29,7 @@ import { useIncidentMarkers, type IncidentPeriod, } from 'sentry/views/detectors/hooks/useIncidentMarkers'; +import {useMetricDetectorAnomalyThresholds} from 'sentry/views/detectors/hooks/useMetricDetectorAnomalyThresholds'; import {useMetricDetectorSeries} from 'sentry/views/detectors/hooks/useMetricDetectorSeries'; import {useMetricDetectorThresholdSeries} from 'sentry/views/detectors/hooks/useMetricDetectorThresholdSeries'; import {useOpenPeriods} from 'sentry/views/detectors/hooks/useOpenPeriods'; @@ -155,6 +156,7 @@ export function useMetricDetectorChart({ }: UseMetricDetectorChartProps): UseMetricDetectorChartResult { const navigate = useNavigate(); const location = useLocation(); + const detectionType = detector.config.detectionType; const comparisonDelta = detectionType === 'percent' ? detector.config.comparisonDelta : undefined; @@ -177,6 +179,34 @@ export function useMetricDetectorChart({ end, }); + const metricTimestamps = useMemo(() => { + const firstSeries = series[0]; + if (!firstSeries?.data.length) { + return {start: undefined, end: undefined}; + } + const data = firstSeries.data; + const firstPoint = data[0]; + const lastPoint = data[data.length - 1]; + + if (!firstPoint || !lastPoint) { + return {start: undefined, end: undefined}; + } + + const firstTimestamp = + typeof firstPoint.name === 'number' + ? firstPoint.name + : new Date(firstPoint.name).getTime(); + const lastTimestamp = + typeof lastPoint.name === 'number' + ? lastPoint.name + : new Date(lastPoint.name).getTime(); + + return { + start: Math.floor(firstTimestamp / 1000), + end: Math.floor(lastTimestamp / 1000), + }; + }, [series]); + const {maxValue: thresholdMaxValue, additionalSeries: thresholdAdditionalSeries} = useMetricDetectorThresholdSeries({ conditions: detector.conditionGroup?.conditions, @@ -184,6 +214,13 @@ export function useMetricDetectorChart({ comparisonSeries, }); + const {anomalyThresholdSeries} = useMetricDetectorAnomalyThresholds({ + detectorId: detector.id, + startTimestamp: metricTimestamps.start, + endTimestamp: metricTimestamps.end, + series, + }); + const incidentPeriods = useMemo(() => { return openPeriods.flatMap(period => [ createTriggerIntervalMarkerData({ @@ -247,13 +284,17 @@ export function useMetricDetectorChart({ }, [series, thresholdMaxValue]); const additionalSeries = useMemo(() => { - const baseSeries = [...thresholdAdditionalSeries]; + const baseSeries = [...thresholdAdditionalSeries, ...anomalyThresholdSeries]; // Line series not working well with the custom series type baseSeries.push(openPeriodMarkerResult.incidentMarkerSeries as any); return baseSeries; - }, [thresholdAdditionalSeries, openPeriodMarkerResult.incidentMarkerSeries]); + }, [ + thresholdAdditionalSeries, + anomalyThresholdSeries, + openPeriodMarkerResult.incidentMarkerSeries, + ]); const yAxes = useMemo(() => { const {formatYAxisLabel} = getDetectorChartFormatters({ diff --git a/static/app/views/detectors/hooks/useMetricDetectorAnomalyThresholds.tsx b/static/app/views/detectors/hooks/useMetricDetectorAnomalyThresholds.tsx new file mode 100644 index 00000000000000..7b3e57453270fa --- /dev/null +++ b/static/app/views/detectors/hooks/useMetricDetectorAnomalyThresholds.tsx @@ -0,0 +1,173 @@ +import {useMemo} from 'react'; +import {useTheme} from '@emotion/react'; +import type {LineSeriesOption} from 'echarts'; + +import LineSeries from 'sentry/components/charts/series/lineSeries'; +import type {Series} from 'sentry/types/echarts'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import type RequestError from 'sentry/utils/requestError/requestError'; +import useOrganization from 'sentry/utils/useOrganization'; + +interface AnomalyThresholdDataPoint { + external_alert_id: number; + timestamp: number; + value: number; + yhat_lower: number; + yhat_upper: number; +} + +interface AnomalyThresholdDataResponse { + data: AnomalyThresholdDataPoint[]; +} + +interface UseMetricDetectorAnomalyThresholdsProps { + detectorId: string; + endTimestamp?: number; + series?: Series[]; + startTimestamp?: number; +} + +interface UseMetricDetectorAnomalyThresholdsResult { + anomalyThresholdSeries: LineSeriesOption[]; + error: RequestError | null; + isLoading: boolean; +} + +/** + * Fetches anomaly detection threshold data and transforms it into chart series + */ +export function useMetricDetectorAnomalyThresholds({ + detectorId, + startTimestamp, + endTimestamp, + series = [], +}: UseMetricDetectorAnomalyThresholdsProps): UseMetricDetectorAnomalyThresholdsResult { + const organization = useOrganization(); + const theme = useTheme(); + + const hasAnomalyDataFlag = organization.features.includes( + 'anomaly-detection-threshold-data' + ); + + const { + data: anomalyData, + isLoading, + error, + } = useApiQuery( + [ + `/organizations/${organization.slug}/detectors/${detectorId}/anomaly-data/`, + { + query: { + start: startTimestamp, + end: endTimestamp, + }, + }, + ], + { + staleTime: 0, + enabled: + hasAnomalyDataFlag && Boolean(detectorId && startTimestamp && endTimestamp), + } + ); + + const anomalyThresholdSeries = useMemo(() => { + if (!anomalyData?.data || anomalyData.data.length === 0 || series.length === 0) { + return []; + } + + const data = anomalyData.data; + const metricData = series[0]?.data; + + if (!metricData || metricData.length === 0) { + return []; + } + + const anomalyMap = new Map(data.map(point => [point.timestamp * 1000, point])); + + const upperBoundData: Array<[number, number]> = []; + const lowerBoundData: Array<[number, number]> = []; + const seerValueData: Array<[number, number]> = []; + + metricData.forEach(metricPoint => { + const timestamp = + typeof metricPoint.name === 'number' + ? metricPoint.name + : new Date(metricPoint.name).getTime(); + const anomalyPoint = anomalyMap.get(timestamp); + + if (anomalyPoint) { + upperBoundData.push([timestamp, anomalyPoint.yhat_upper]); + lowerBoundData.push([timestamp, anomalyPoint.yhat_lower]); + seerValueData.push([timestamp, anomalyPoint.value]); + } + }); + + const lineColor = theme.red300; + const seerValueColor = theme.yellow300; + + return [ + LineSeries({ + name: 'Upper Threshold', + data: upperBoundData, + lineStyle: { + color: lineColor, + type: 'dashed', + width: 1, + dashOffset: 0, + }, + areaStyle: { + color: lineColor, + opacity: 0.05, + origin: 'end', + }, + itemStyle: {color: lineColor}, + animation: false, + animationThreshold: 1, + animationDuration: 0, + symbol: 'none', + connectNulls: true, + step: false, + }), + LineSeries({ + name: 'Lower Threshold', + data: lowerBoundData, + lineStyle: { + color: lineColor, + type: 'dashed', + width: 1, + dashOffset: 0, + }, + areaStyle: { + color: lineColor, + opacity: 0.05, + origin: 'start', + }, + itemStyle: {color: lineColor}, + animation: false, + animationThreshold: 1, + animationDuration: 0, + symbol: 'none', + connectNulls: true, + step: false, + }), + LineSeries({ + name: 'Seer Historical Value', + data: seerValueData, + lineStyle: { + color: seerValueColor, + type: 'solid', + width: 2, + }, + itemStyle: {color: seerValueColor}, + animation: false, + animationThreshold: 1, + animationDuration: 0, + symbol: 'circle', + symbolSize: 4, + connectNulls: true, + }), + ]; + }, [anomalyData, series, theme]); + + return {anomalyThresholdSeries, isLoading, error}; +}