Skip to content

Commit 969976d

Browse files
authored
feat(aci): Add support for metric detector anomalies (#96776)
Has to make two additional requests. One to get a larger overall time window of the current time period and then send it to /anomalies to get back the periods that would've triggered. Adds a hook that can take an array of incidents/open periodos and display bubbles on the graph like below <img width="1094" height="435" alt="image" src="https://github.com/user-attachments/assets/36635688-13a8-4a88-b14e-4bda493b60a2" /> Todo in follow up: - split the preview and details charts - error state for anomalies - fix some cases where anomalies stats call fails with too many stat points requested - improve loading performance, i think i'm waiting for too many things, could show a different loading state for anomaly data.
1 parent 8e46404 commit 969976d

12 files changed

+1181
-47
lines changed

static/app/views/alerts/types.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export type CombinedAlerts = CombinedMetricIssueAlerts | UptimeAlert | CronRule;
116116

117117
export type Anomaly = {
118118
anomaly: {anomaly_score: number; anomaly_type: AnomalyType};
119-
timestamp: string | number;
119+
timestamp: number;
120120
value: number;
121121
};
122122

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

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import styled from '@emotion/styled';
22

3-
import {Flex} from 'sentry/components/core/layout';
43
import type {MetricDetector} from 'sentry/types/workflowEngine/detectors';
54
import type {TimePeriod} from 'sentry/views/alerts/rules/metric/types';
65
import {MetricDetectorChart} from 'sentry/views/detectors/components/forms/metric/metricDetectorChart';
@@ -34,24 +33,28 @@ export function MetricDetectorDetailsChart({
3433
detectionType === 'percent' ? detector.config.comparisonDelta : undefined;
3534

3635
return (
37-
<Flex direction="column" gap="xl">
38-
<ChartContainer>
39-
<ChartContainerBody>
40-
<MetricDetectorChart
41-
dataset={dataset}
42-
aggregate={aggregate}
43-
interval={interval}
44-
query={query}
45-
environment={environment}
46-
projectId={detector.projectId}
47-
conditions={conditions}
48-
detectionType={detectionType}
49-
statsPeriod={statsPeriod}
50-
comparisonDelta={comparisonDelta}
51-
/>
52-
</ChartContainerBody>
53-
</ChartContainer>
54-
</Flex>
36+
<ChartContainer>
37+
<ChartContainerBody>
38+
<MetricDetectorChart
39+
dataset={dataset}
40+
aggregate={aggregate}
41+
interval={interval}
42+
query={query}
43+
environment={environment}
44+
projectId={detector.projectId}
45+
conditions={conditions}
46+
detectionType={detectionType}
47+
statsPeriod={statsPeriod}
48+
comparisonDelta={comparisonDelta}
49+
sensitivity={
50+
'sensitivity' in detector.config ? detector.config.sensitivity : undefined
51+
}
52+
thresholdType={
53+
'thresholdType' in detector.config ? detector.config.thresholdType : undefined
54+
}
55+
/>
56+
</ChartContainerBody>
57+
</ChartContainer>
5558
);
5659
}
5760

static/app/views/detectors/components/forms/metric/metricDetectorChart.tsx

Lines changed: 120 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {useMemo} from 'react';
2+
import type {YAXisComponentOption} from 'echarts';
23

34
import {AreaChart} from 'sentry/components/charts/areaChart';
45
import ErrorPanel from 'sentry/components/charts/errorPanel';
@@ -9,8 +10,14 @@ import {t} from 'sentry/locale';
910
import {space} from 'sentry/styles/space';
1011
import type {DataCondition} from 'sentry/types/workflowEngine/dataConditions';
1112
import type {MetricDetectorConfig} from 'sentry/types/workflowEngine/detectors';
12-
import {TimePeriod} from 'sentry/views/alerts/rules/metric/types';
13+
import {
14+
AlertRuleSensitivity,
15+
AlertRuleThresholdType,
16+
TimePeriod,
17+
} from 'sentry/views/alerts/rules/metric/types';
1318
import type {DetectorDataset} from 'sentry/views/detectors/components/forms/metric/metricFormData';
19+
import {useIncidentMarkers} from 'sentry/views/detectors/hooks/useIncidentMarkers';
20+
import {useMetricDetectorAnomalyPeriods} from 'sentry/views/detectors/hooks/useMetricDetectorAnomalyPeriods';
1421
import {useMetricDetectorSeries} from 'sentry/views/detectors/hooks/useMetricDetectorSeries';
1522
import {useMetricDetectorThresholdSeries} from 'sentry/views/detectors/hooks/useMetricDetectorThresholdSeries';
1623

@@ -50,10 +57,18 @@ interface MetricDetectorChartProps {
5057
* The query filter string
5158
*/
5259
query: string;
60+
/**
61+
* Used in anomaly detection
62+
*/
63+
sensitivity: AlertRuleSensitivity | undefined;
5364
/**
5465
* The time period for the chart data (optional, defaults to 7d)
5566
*/
5667
statsPeriod: TimePeriod;
68+
/**
69+
* Used in anomaly detection
70+
*/
71+
thresholdType: AlertRuleThresholdType | undefined;
5772
}
5873

5974
export function MetricDetectorChart({
@@ -67,8 +82,10 @@ export function MetricDetectorChart({
6782
detectionType,
6883
statsPeriod,
6984
comparisonDelta,
85+
sensitivity,
86+
thresholdType,
7087
}: MetricDetectorChartProps) {
71-
const {series, comparisonSeries, isPending, isError} = useMetricDetectorSeries({
88+
const {series, comparisonSeries, isLoading, isError} = useMetricDetectorSeries({
7289
dataset,
7390
aggregate,
7491
interval,
@@ -79,13 +96,47 @@ export function MetricDetectorChart({
7996
comparisonDelta,
8097
});
8198

82-
const {maxValue: thresholdMaxValue, additionalSeries} =
99+
const {maxValue: thresholdMaxValue, additionalSeries: thresholdAdditionalSeries} =
83100
useMetricDetectorThresholdSeries({
84101
conditions,
85102
detectionType,
86103
comparisonSeries,
87104
});
88105

106+
const isAnomalyDetection = detectionType === 'dynamic';
107+
const shouldFetchAnomalies =
108+
isAnomalyDetection && !isLoading && !isError && series.length > 0;
109+
110+
// Fetch anomaly data when detection type is dynamic and series data is ready
111+
const {
112+
anomalyPeriods,
113+
isLoading: isLoadingAnomalies,
114+
error: anomalyErrorObject,
115+
} = useMetricDetectorAnomalyPeriods({
116+
series: shouldFetchAnomalies ? series : [],
117+
dataset,
118+
aggregate,
119+
query,
120+
environment,
121+
projectId,
122+
statsPeriod,
123+
timePeriod: interval,
124+
thresholdType,
125+
sensitivity,
126+
enabled: shouldFetchAnomalies,
127+
});
128+
129+
// Create anomaly marker rendering from pre-grouped anomaly periods
130+
const anomalyMarkerResult = useIncidentMarkers({
131+
incidents: anomalyPeriods,
132+
seriesName: t('Anomalies'),
133+
seriesId: '__anomaly_marker__',
134+
yAxisIndex: 1, // Use index 1 to avoid conflict with main chart axis
135+
});
136+
137+
const anomalyLoading = shouldFetchAnomalies ? isLoadingAnomalies : false;
138+
const anomalyError = shouldFetchAnomalies ? anomalyErrorObject : null;
139+
89140
// Calculate y-axis bounds to ensure all thresholds are visible
90141
const maxValue = useMemo(() => {
91142
// Get max from series data
@@ -111,15 +162,72 @@ export function MetricDetectorChart({
111162
return roundedMax + padding;
112163
}, [series, thresholdMaxValue]);
113164

114-
if (isPending) {
165+
const additionalSeries = useMemo(() => {
166+
const baseSeries = [...thresholdAdditionalSeries];
167+
168+
if (isAnomalyDetection && anomalyMarkerResult.incidentMarkerSeries) {
169+
// Line series not working well with the custom series type
170+
baseSeries.push(anomalyMarkerResult.incidentMarkerSeries as any);
171+
}
172+
173+
return baseSeries;
174+
}, [
175+
isAnomalyDetection,
176+
thresholdAdditionalSeries,
177+
anomalyMarkerResult.incidentMarkerSeries,
178+
]);
179+
180+
const yAxes = useMemo(() => {
181+
const mainYAxis: YAXisComponentOption = {
182+
max: maxValue > 0 ? maxValue : undefined,
183+
min: 0,
184+
axisLabel: {
185+
// Hide the maximum y-axis label to avoid showing arbitrary threshold values
186+
showMaxLabel: false,
187+
},
188+
// Disable the y-axis grid lines
189+
splitLine: {show: false},
190+
};
191+
192+
const axes: YAXisComponentOption[] = [mainYAxis];
193+
194+
// Add anomaly marker Y-axis if available
195+
if (isAnomalyDetection && anomalyMarkerResult.incidentMarkerYAxis) {
196+
axes.push(anomalyMarkerResult.incidentMarkerYAxis);
197+
}
198+
199+
return axes;
200+
}, [maxValue, isAnomalyDetection, anomalyMarkerResult.incidentMarkerYAxis]);
201+
202+
// Prepare grid with anomaly marker adjustments
203+
const grid = useMemo(() => {
204+
const baseGrid = {
205+
left: space(0.25),
206+
right: space(0.25),
207+
top: space(1.5),
208+
bottom: space(1),
209+
};
210+
211+
// Apply anomaly marker grid adjustments if available
212+
if (isAnomalyDetection && anomalyMarkerResult.incidentMarkerGrid) {
213+
return {
214+
...baseGrid,
215+
...anomalyMarkerResult.incidentMarkerGrid,
216+
};
217+
}
218+
219+
return baseGrid;
220+
}, [isAnomalyDetection, anomalyMarkerResult.incidentMarkerGrid]);
221+
222+
if (isLoading || anomalyLoading) {
115223
return (
116224
<Flex style={{height: CHART_HEIGHT}} justify="center" align="center">
117225
<Placeholder height={`${CHART_HEIGHT - 20}px`} />
118226
</Flex>
119227
);
120228
}
121229

122-
if (isError) {
230+
if (isError || anomalyError) {
123231
return (
124232
<Flex style={{height: CHART_HEIGHT}} justify="center" align="center">
125233
<ErrorPanel>
@@ -138,22 +246,13 @@ export function MetricDetectorChart({
138246
stacked={false}
139247
series={series}
140248
additionalSeries={additionalSeries}
141-
yAxis={{
142-
max: maxValue > 0 ? maxValue : undefined,
143-
min: 0,
144-
axisLabel: {
145-
// Hide the maximum y-axis label to avoid showing arbitrary threshold values
146-
showMaxLabel: false,
147-
},
148-
// Disable the y-axis grid lines
149-
splitLine: {show: false},
150-
}}
151-
grid={{
152-
left: space(0.25),
153-
right: space(0.25),
154-
top: space(1.5),
155-
bottom: space(1),
156-
}}
249+
yAxes={yAxes.length > 1 ? yAxes : undefined}
250+
yAxis={yAxes.length === 1 ? yAxes[0] : undefined}
251+
grid={grid}
252+
xAxis={isAnomalyDetection ? anomalyMarkerResult.incidentMarkerXAxis : undefined}
253+
ref={
254+
isAnomalyDetection ? anomalyMarkerResult.connectIncidentMarkerChartRef : undefined
255+
}
157256
/>
158257
);
159258
}

static/app/views/detectors/components/forms/metric/metricFormData.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,8 @@ export const DEFAULT_THRESHOLD_METRIC_FORM_DATA = {
148148
conditionComparisonAgo: 60 * 60, // One hour in seconds
149149

150150
// Default dynamic fields
151-
sensitivity: AlertRuleSensitivity.LOW,
152-
thresholdType: AlertRuleThresholdType.ABOVE,
151+
sensitivity: AlertRuleSensitivity.MEDIUM,
152+
thresholdType: AlertRuleThresholdType.ABOVE_AND_BELOW,
153153

154154
dataset: DetectorDataset.SPANS,
155155
aggregateFunction: 'avg(span.duration)',

static/app/views/detectors/components/forms/metric/previewChart.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ export function MetricDetectorPreviewChart() {
4242
const conditionComparisonAgo = useMetricDetectorFormField(
4343
METRIC_DETECTOR_FORM_FIELDS.conditionComparisonAgo
4444
);
45+
const sensitivity = useMetricDetectorFormField(METRIC_DETECTOR_FORM_FIELDS.sensitivity);
46+
const thresholdType = useMetricDetectorFormField(
47+
METRIC_DETECTOR_FORM_FIELDS.thresholdType
48+
);
4549

4650
const {selectedTimePeriod, setSelectedTimePeriod, timePeriodOptions} =
4751
useTimePeriodSelection({
@@ -77,6 +81,8 @@ export function MetricDetectorPreviewChart() {
7781
detectionType={detectionType}
7882
statsPeriod={selectedTimePeriod}
7983
comparisonDelta={detectionType === 'percent' ? conditionComparisonAgo : undefined}
84+
sensitivity={sensitivity}
85+
thresholdType={thresholdType}
8086
/>
8187
<ChartFooter>
8288
<CompactSelect

static/app/views/detectors/edit.spec.tsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,5 +510,82 @@ describe('DetectorEdit', () => {
510510
// Verify interval changed to 15 minutes
511511
expect(await screen.findByText('15 minutes')).toBeInTheDocument();
512512
});
513+
514+
it('calls anomaly API when using dynamic detection', async () => {
515+
MockApiClient.addMockResponse({
516+
url: `/organizations/${organization.slug}/detectors/${mockDetector.id}/`,
517+
body: mockDetector,
518+
});
519+
520+
// Current data for chart
521+
MockApiClient.addMockResponse({
522+
url: `/organizations/${organization.slug}/events-stats/`,
523+
match: [MockApiClient.matchQuery({statsPeriod: '9998m'})],
524+
body: {
525+
data: [
526+
[1609459200000, [{count: 100}]],
527+
[1609462800000, [{count: 120}]],
528+
[1609466400000, [{count: 90}]],
529+
[1609470000000, [{count: 150}]],
530+
],
531+
},
532+
});
533+
534+
// Historical data
535+
MockApiClient.addMockResponse({
536+
url: `/organizations/${organization.slug}/events-stats/`,
537+
match: [MockApiClient.matchQuery({statsPeriod: '35d'})],
538+
body: {
539+
data: [
540+
[1607459200000, [{count: 80}]],
541+
[1607462800000, [{count: 95}]],
542+
[1607466400000, [{count: 110}]],
543+
[1607470000000, [{count: 75}]],
544+
],
545+
},
546+
});
547+
548+
const anomalyRequest = MockApiClient.addMockResponse({
549+
url: `/organizations/${organization.slug}/events/anomalies/`,
550+
method: 'POST',
551+
body: [],
552+
});
553+
554+
render(<DetectorEdit />, {
555+
organization,
556+
initialRouterConfig,
557+
});
558+
559+
expect(await screen.findByRole('link', {name})).toBeInTheDocument();
560+
561+
await userEvent.click(screen.getByRole('radio', {name: 'Dynamic'}));
562+
563+
await waitFor(() => {
564+
expect(anomalyRequest).toHaveBeenCalled();
565+
});
566+
const payload = anomalyRequest.mock.calls[0][1];
567+
expect(payload.data).toEqual({
568+
config: {
569+
direction: 'both',
570+
expected_seasonality: 'auto',
571+
sensitivity: 'medium',
572+
time_period: 15,
573+
},
574+
current_data: [
575+
[1609459200000, {count: 100}],
576+
[1609462800000, {count: 120}],
577+
[1609466400000, {count: 90}],
578+
[1609470000000, {count: 150}],
579+
],
580+
historical_data: [
581+
[1607459200000, {count: 80}],
582+
[1607462800000, {count: 95}],
583+
[1607466400000, {count: 110}],
584+
[1607470000000, {count: 75}],
585+
],
586+
organization_id: organization.id,
587+
project_id: project.id,
588+
});
589+
});
513590
});
514591
});

0 commit comments

Comments
 (0)