Skip to content

Commit 4ad63b2

Browse files
authored
feat(anomaly): add seer anomaly thresholds to metric monitor graph (#104074)
1 parent 8445c5a commit 4ad63b2

File tree

2 files changed

+216
-2
lines changed

2 files changed

+216
-2
lines changed

static/app/views/detectors/components/details/metric/chart.tsx

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
useIncidentMarkers,
3131
type IncidentPeriod,
3232
} from 'sentry/views/detectors/hooks/useIncidentMarkers';
33+
import {useMetricDetectorAnomalyThresholds} from 'sentry/views/detectors/hooks/useMetricDetectorAnomalyThresholds';
3334
import {useMetricDetectorSeries} from 'sentry/views/detectors/hooks/useMetricDetectorSeries';
3435
import {useMetricDetectorThresholdSeries} from 'sentry/views/detectors/hooks/useMetricDetectorThresholdSeries';
3536
import {useOpenPeriods} from 'sentry/views/detectors/hooks/useOpenPeriods';
@@ -156,6 +157,7 @@ export function useMetricDetectorChart({
156157
}: UseMetricDetectorChartProps): UseMetricDetectorChartResult {
157158
const navigate = useNavigate();
158159
const location = useLocation();
160+
159161
const detectionType = detector.config.detectionType;
160162
const comparisonDelta =
161163
detectionType === 'percent' ? detector.config.comparisonDelta : undefined;
@@ -179,6 +181,34 @@ export function useMetricDetectorChart({
179181
end,
180182
});
181183

184+
const metricTimestamps = useMemo(() => {
185+
const firstSeries = series[0];
186+
if (!firstSeries?.data.length) {
187+
return {start: undefined, end: undefined};
188+
}
189+
const data = firstSeries.data;
190+
const firstPoint = data[0];
191+
const lastPoint = data[data.length - 1];
192+
193+
if (!firstPoint || !lastPoint) {
194+
return {start: undefined, end: undefined};
195+
}
196+
197+
const firstTimestamp =
198+
typeof firstPoint.name === 'number'
199+
? firstPoint.name
200+
: new Date(firstPoint.name).getTime();
201+
const lastTimestamp =
202+
typeof lastPoint.name === 'number'
203+
? lastPoint.name
204+
: new Date(lastPoint.name).getTime();
205+
206+
return {
207+
start: Math.floor(firstTimestamp / 1000),
208+
end: Math.floor(lastTimestamp / 1000),
209+
};
210+
}, [series]);
211+
182212
const {maxValue: thresholdMaxValue, additionalSeries: thresholdAdditionalSeries} =
183213
useMetricDetectorThresholdSeries({
184214
conditions: detector.conditionGroup?.conditions,
@@ -187,6 +217,13 @@ export function useMetricDetectorChart({
187217
comparisonSeries,
188218
});
189219

220+
const {anomalyThresholdSeries} = useMetricDetectorAnomalyThresholds({
221+
detectorId: detector.id,
222+
startTimestamp: metricTimestamps.start,
223+
endTimestamp: metricTimestamps.end,
224+
series,
225+
});
226+
190227
const incidentPeriods = useMemo(() => {
191228
return openPeriods.flatMap<IncidentPeriod>(period => [
192229
createTriggerIntervalMarkerData({
@@ -227,13 +264,17 @@ export function useMetricDetectorChart({
227264
const {maxValue, minValue} = useDetectorChartAxisBounds({series, thresholdMaxValue});
228265

229266
const additionalSeries = useMemo(() => {
230-
const baseSeries = [...thresholdAdditionalSeries];
267+
const baseSeries = [...thresholdAdditionalSeries, ...anomalyThresholdSeries];
231268

232269
// Line series not working well with the custom series type
233270
baseSeries.push(openPeriodMarkerResult.incidentMarkerSeries as any);
234271

235272
return baseSeries;
236-
}, [thresholdAdditionalSeries, openPeriodMarkerResult.incidentMarkerSeries]);
273+
}, [
274+
thresholdAdditionalSeries,
275+
anomalyThresholdSeries,
276+
openPeriodMarkerResult.incidentMarkerSeries,
277+
]);
237278

238279
const yAxes = useMemo(() => {
239280
const {formatYAxisLabel, outputType} = getDetectorChartFormatters({
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import {useMemo} from 'react';
2+
import {useTheme} from '@emotion/react';
3+
import type {LineSeriesOption} from 'echarts';
4+
5+
import LineSeries from 'sentry/components/charts/series/lineSeries';
6+
import type {Series} from 'sentry/types/echarts';
7+
import {useApiQuery} from 'sentry/utils/queryClient';
8+
import type RequestError from 'sentry/utils/requestError/requestError';
9+
import useOrganization from 'sentry/utils/useOrganization';
10+
11+
interface AnomalyThresholdDataPoint {
12+
external_alert_id: number;
13+
timestamp: number;
14+
value: number;
15+
yhat_lower: number;
16+
yhat_upper: number;
17+
}
18+
19+
interface AnomalyThresholdDataResponse {
20+
data: AnomalyThresholdDataPoint[];
21+
}
22+
23+
interface UseMetricDetectorAnomalyThresholdsProps {
24+
detectorId: string;
25+
endTimestamp?: number;
26+
series?: Series[];
27+
startTimestamp?: number;
28+
}
29+
30+
interface UseMetricDetectorAnomalyThresholdsResult {
31+
anomalyThresholdSeries: LineSeriesOption[];
32+
error: RequestError | null;
33+
isLoading: boolean;
34+
}
35+
36+
/**
37+
* Fetches anomaly detection threshold data and transforms it into chart series
38+
*/
39+
export function useMetricDetectorAnomalyThresholds({
40+
detectorId,
41+
startTimestamp,
42+
endTimestamp,
43+
series = [],
44+
}: UseMetricDetectorAnomalyThresholdsProps): UseMetricDetectorAnomalyThresholdsResult {
45+
const organization = useOrganization();
46+
const theme = useTheme();
47+
48+
const hasAnomalyDataFlag = organization.features.includes(
49+
'anomaly-detection-threshold-data'
50+
);
51+
52+
const {
53+
data: anomalyData,
54+
isLoading,
55+
error,
56+
} = useApiQuery<AnomalyThresholdDataResponse>(
57+
[
58+
`/organizations/${organization.slug}/detectors/${detectorId}/anomaly-data/`,
59+
{
60+
query: {
61+
start: startTimestamp,
62+
end: endTimestamp,
63+
},
64+
},
65+
],
66+
{
67+
staleTime: 0,
68+
enabled:
69+
hasAnomalyDataFlag && Boolean(detectorId && startTimestamp && endTimestamp),
70+
}
71+
);
72+
73+
const anomalyThresholdSeries = useMemo(() => {
74+
if (!anomalyData?.data || anomalyData.data.length === 0 || series.length === 0) {
75+
return [];
76+
}
77+
78+
const data = anomalyData.data;
79+
const metricData = series[0]?.data;
80+
81+
if (!metricData || metricData.length === 0) {
82+
return [];
83+
}
84+
85+
const anomalyMap = new Map(data.map(point => [point.timestamp * 1000, point]));
86+
87+
const upperBoundData: Array<[number, number]> = [];
88+
const lowerBoundData: Array<[number, number]> = [];
89+
const seerValueData: Array<[number, number]> = [];
90+
91+
metricData.forEach(metricPoint => {
92+
const timestamp =
93+
typeof metricPoint.name === 'number'
94+
? metricPoint.name
95+
: new Date(metricPoint.name).getTime();
96+
const anomalyPoint = anomalyMap.get(timestamp);
97+
98+
if (anomalyPoint) {
99+
upperBoundData.push([timestamp, anomalyPoint.yhat_upper]);
100+
lowerBoundData.push([timestamp, anomalyPoint.yhat_lower]);
101+
seerValueData.push([timestamp, anomalyPoint.value]);
102+
}
103+
});
104+
105+
const lineColor = theme.red300;
106+
const seerValueColor = theme.yellow300;
107+
108+
return [
109+
LineSeries({
110+
name: 'Upper Threshold',
111+
data: upperBoundData,
112+
lineStyle: {
113+
color: lineColor,
114+
type: 'dashed',
115+
width: 1,
116+
dashOffset: 0,
117+
},
118+
areaStyle: {
119+
color: lineColor,
120+
opacity: 0.05,
121+
origin: 'end',
122+
},
123+
itemStyle: {color: lineColor},
124+
animation: false,
125+
animationThreshold: 1,
126+
animationDuration: 0,
127+
symbol: 'none',
128+
connectNulls: true,
129+
step: false,
130+
}),
131+
LineSeries({
132+
name: 'Lower Threshold',
133+
data: lowerBoundData,
134+
lineStyle: {
135+
color: lineColor,
136+
type: 'dashed',
137+
width: 1,
138+
dashOffset: 0,
139+
},
140+
areaStyle: {
141+
color: lineColor,
142+
opacity: 0.05,
143+
origin: 'start',
144+
},
145+
itemStyle: {color: lineColor},
146+
animation: false,
147+
animationThreshold: 1,
148+
animationDuration: 0,
149+
symbol: 'none',
150+
connectNulls: true,
151+
step: false,
152+
}),
153+
LineSeries({
154+
name: 'Seer Historical Value',
155+
data: seerValueData,
156+
lineStyle: {
157+
color: seerValueColor,
158+
type: 'solid',
159+
width: 2,
160+
},
161+
itemStyle: {color: seerValueColor},
162+
animation: false,
163+
animationThreshold: 1,
164+
animationDuration: 0,
165+
symbol: 'circle',
166+
symbolSize: 4,
167+
connectNulls: true,
168+
}),
169+
];
170+
}, [anomalyData, series, theme]);
171+
172+
return {anomalyThresholdSeries, isLoading, error};
173+
}

0 commit comments

Comments
 (0)