Skip to content

Commit 65062c8

Browse files
authored
feat(aci): Optimize anomaly loading, empty states (#97535)
- fixes fetching historical data for EAP datasets - Loads both current and historical series of data empty <img width="1036" height="335" alt="Screenshot 2025-08-08 at 3 44 35 PM" src="https://github.com/user-attachments/assets/8a80c0c8-a61a-418c-bad2-69f5b18f7281" /> loading <img width="1027" height="348" alt="Screenshot 2025-08-08 at 2 51 12 PM" src="https://github.com/user-attachments/assets/ce9cbb0d-d0e6-4584-a37c-08bb75a0d05f" /> found <img width="1040" height="348" alt="Screenshot 2025-08-08 at 2 49 39 PM" src="https://github.com/user-attachments/assets/2762168b-b7a1-46d3-a767-7fd3f0b62237" />
1 parent a75d72a commit 65062c8

File tree

10 files changed

+217
-151
lines changed

10 files changed

+217
-151
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ function MetricDetectorChart({
4040
const comparisonDelta =
4141
detectionType === 'percent' ? detector.config.comparisonDelta : undefined;
4242
const dataset = getDetectorDataset(snubaQuery.dataset, snubaQuery.eventTypes);
43-
const {series, comparisonSeries, isLoading, isError} = useMetricDetectorSeries({
43+
const {series, comparisonSeries, isLoading, error} = useMetricDetectorSeries({
4444
dataset,
4545
aggregate: snubaQuery.aggregate,
4646
interval: snubaQuery.timeWindow,
@@ -140,7 +140,7 @@ function MetricDetectorChart({
140140
);
141141
}
142142

143-
if (isError) {
143+
if (error) {
144144
return (
145145
<Flex style={{height: CHART_HEIGHT}} justify="center" align="center">
146146
<ErrorPanel>

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

Lines changed: 115 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,52 @@
1-
import {useMemo} from 'react';
1+
import {Fragment, useMemo} from 'react';
2+
import styled from '@emotion/styled';
23
import type {YAXisComponentOption} from 'echarts';
34

45
import {AreaChart} from 'sentry/components/charts/areaChart';
56
import ErrorPanel from 'sentry/components/charts/errorPanel';
7+
import {CompactSelect} from 'sentry/components/core/compactSelect';
68
import {Flex} from 'sentry/components/core/layout';
9+
import {Text} from 'sentry/components/core/text';
10+
import LoadingIndicator from 'sentry/components/loadingIndicator';
711
import Placeholder from 'sentry/components/placeholder';
812
import {IconWarning} from 'sentry/icons';
9-
import {t} from 'sentry/locale';
13+
import {t, tn} from 'sentry/locale';
1014
import {space} from 'sentry/styles/space';
1115
import type {DataCondition} from 'sentry/types/workflowEngine/dataConditions';
1216
import type {MetricDetectorConfig} from 'sentry/types/workflowEngine/detectors';
1317
import {
1418
AlertRuleSensitivity,
1519
AlertRuleThresholdType,
16-
TimePeriod,
1720
} from 'sentry/views/alerts/rules/metric/types';
21+
import {getBackendDataset} from 'sentry/views/detectors/components/forms/metric/metricFormData';
1822
import type {DetectorDataset} from 'sentry/views/detectors/datasetConfig/types';
1923
import {useIncidentMarkers} from 'sentry/views/detectors/hooks/useIncidentMarkers';
2024
import {useMetricDetectorAnomalyPeriods} from 'sentry/views/detectors/hooks/useMetricDetectorAnomalyPeriods';
2125
import {useMetricDetectorSeries} from 'sentry/views/detectors/hooks/useMetricDetectorSeries';
2226
import {useMetricDetectorThresholdSeries} from 'sentry/views/detectors/hooks/useMetricDetectorThresholdSeries';
27+
import {useTimePeriodSelection} from 'sentry/views/detectors/hooks/useTimePeriodSelection';
2328

2429
const CHART_HEIGHT = 180;
2530

31+
function ChartError() {
32+
return (
33+
<Flex style={{height: CHART_HEIGHT}} justify="center" align="center">
34+
<ErrorPanel>
35+
<IconWarning color="gray300" size="lg" />
36+
<div>{t('Error loading chart data')}</div>
37+
</ErrorPanel>
38+
</Flex>
39+
);
40+
}
41+
42+
function ChartLoading() {
43+
return (
44+
<Flex style={{height: CHART_HEIGHT}} justify="center" align="center">
45+
<Placeholder height={`${CHART_HEIGHT - 20}px`} />
46+
</Flex>
47+
);
48+
}
49+
2650
interface MetricDetectorChartProps {
2751
/**
2852
* The aggregate function to use (e.g., "avg(span.duration)")
@@ -61,10 +85,6 @@ interface MetricDetectorChartProps {
6185
* Used in anomaly detection
6286
*/
6387
sensitivity: AlertRuleSensitivity | undefined;
64-
/**
65-
* The time period for the chart data (optional, defaults to 7d)
66-
*/
67-
statsPeriod: TimePeriod;
6888
/**
6989
* Used in anomaly detection
7090
*/
@@ -80,19 +100,24 @@ export function MetricDetectorChart({
80100
projectId,
81101
conditions,
82102
detectionType,
83-
statsPeriod,
84103
comparisonDelta,
85104
sensitivity,
86105
thresholdType,
87106
}: MetricDetectorChartProps) {
88-
const {series, comparisonSeries, isLoading, isError} = useMetricDetectorSeries({
107+
const {selectedTimePeriod, setSelectedTimePeriod, timePeriodOptions} =
108+
useTimePeriodSelection({
109+
dataset: getBackendDataset(dataset),
110+
interval,
111+
});
112+
113+
const {series, comparisonSeries, isLoading, error} = useMetricDetectorSeries({
89114
dataset,
90115
aggregate,
91116
interval,
92117
query,
93118
environment,
94119
projectId,
95-
statsPeriod,
120+
statsPeriod: selectedTimePeriod,
96121
comparisonDelta,
97122
});
98123

@@ -105,25 +130,26 @@ export function MetricDetectorChart({
105130

106131
const isAnomalyDetection = detectionType === 'dynamic';
107132
const shouldFetchAnomalies =
108-
isAnomalyDetection && !isLoading && !isError && series.length > 0;
133+
isAnomalyDetection && !isLoading && !error && series.length > 0;
109134

110135
// Fetch anomaly data when detection type is dynamic and series data is ready
111136
const {
112137
anomalyPeriods,
113138
isLoading: isLoadingAnomalies,
114-
error: anomalyErrorObject,
139+
error: anomalyError,
115140
} = useMetricDetectorAnomalyPeriods({
116141
series: shouldFetchAnomalies ? series : [],
142+
isLoadingSeries: isLoading,
117143
dataset,
118144
aggregate,
119145
query,
120146
environment,
121147
projectId,
122-
statsPeriod,
123-
timePeriod: interval,
148+
statsPeriod: selectedTimePeriod,
149+
interval,
124150
thresholdType,
125151
sensitivity,
126-
enabled: shouldFetchAnomalies,
152+
enabled: isAnomalyDetection,
127153
});
128154

129155
// Create anomaly marker rendering from pre-grouped anomaly periods
@@ -134,9 +160,6 @@ export function MetricDetectorChart({
134160
yAxisIndex: 1, // Use index 1 to avoid conflict with main chart axis
135161
});
136162

137-
const anomalyLoading = shouldFetchAnomalies ? isLoadingAnomalies : false;
138-
const anomalyError = shouldFetchAnomalies ? anomalyErrorObject : null;
139-
140163
// Calculate y-axis bounds to ensure all thresholds are visible
141164
const maxValue = useMemo(() => {
142165
// Get max from series data
@@ -219,40 +242,80 @@ export function MetricDetectorChart({
219242
return baseGrid;
220243
}, [isAnomalyDetection, anomalyMarkerResult.incidentMarkerGrid]);
221244

222-
if (isLoading || anomalyLoading) {
223-
return (
224-
<Flex style={{height: CHART_HEIGHT}} justify="center" align="center">
225-
<Placeholder height={`${CHART_HEIGHT - 20}px`} />
226-
</Flex>
227-
);
228-
}
229-
230-
if (isError || anomalyError) {
231-
return (
232-
<Flex style={{height: CHART_HEIGHT}} justify="center" align="center">
233-
<ErrorPanel>
234-
<IconWarning color="gray300" size="lg" />
235-
<div>{t('Error loading chart data')}</div>
236-
</ErrorPanel>
237-
</Flex>
238-
);
239-
}
240-
241245
return (
242-
<AreaChart
243-
isGroupedByDate
244-
showTimeInTooltip
245-
height={CHART_HEIGHT}
246-
stacked={false}
247-
series={series}
248-
additionalSeries={additionalSeries}
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-
}
256-
/>
246+
<ChartContainer>
247+
{isLoading ? (
248+
<ChartLoading />
249+
) : error ? (
250+
<ChartError />
251+
) : (
252+
<AreaChart
253+
isGroupedByDate
254+
showTimeInTooltip
255+
height={CHART_HEIGHT}
256+
stacked={false}
257+
series={series}
258+
additionalSeries={additionalSeries}
259+
yAxes={yAxes.length > 1 ? yAxes : undefined}
260+
yAxis={yAxes.length === 1 ? yAxes[0] : undefined}
261+
grid={grid}
262+
xAxis={isAnomalyDetection ? anomalyMarkerResult.incidentMarkerXAxis : undefined}
263+
ref={
264+
isAnomalyDetection
265+
? anomalyMarkerResult.connectIncidentMarkerChartRef
266+
: undefined
267+
}
268+
/>
269+
)}
270+
<ChartFooter>
271+
{shouldFetchAnomalies ? (
272+
<Flex align="center" gap="sm">
273+
{isLoadingAnomalies ? (
274+
<Fragment>
275+
<AnomalyLoadingIndicator size={18} />
276+
<Text variant="muted">{t('Loading anomalies...')}</Text>
277+
</Fragment>
278+
) : anomalyError ? (
279+
<Text variant="muted">{t('Error loading anomalies')}</Text>
280+
) : anomalyPeriods.length === 0 ? (
281+
<Text variant="muted">{t('No anomalies found for this time period')}</Text>
282+
) : (
283+
<Text variant="muted">
284+
{tn('Found %s anomaly', 'Found %s anomalies', anomalyPeriods.length)}
285+
</Text>
286+
)}
287+
</Flex>
288+
) : (
289+
<div />
290+
)}
291+
<CompactSelect
292+
size="sm"
293+
options={timePeriodOptions}
294+
value={selectedTimePeriod}
295+
onChange={opt => setSelectedTimePeriod(opt.value)}
296+
triggerProps={{
297+
borderless: true,
298+
prefix: t('Display'),
299+
}}
300+
/>
301+
</ChartFooter>
302+
</ChartContainer>
257303
);
258304
}
305+
306+
const ChartContainer = styled('div')`
307+
max-width: 1440px;
308+
border-top: 1px solid ${p => p.theme.border};
309+
`;
310+
311+
const ChartFooter = styled('div')`
312+
display: flex;
313+
justify-content: space-between;
314+
align-items: center;
315+
padding: ${p => `${p.theme.space.sm} 0 ${p.theme.space.sm} ${p.theme.space.lg}`};
316+
border-top: 1px solid ${p => p.theme.border};
317+
`;
318+
319+
const AnomalyLoadingIndicator = styled(LoadingIndicator)`
320+
margin: 0;
321+
`;
Lines changed: 13 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
11
import {useMemo} from 'react';
2-
import styled from '@emotion/styled';
32

4-
import {CompactSelect} from 'sentry/components/core/compactSelect';
5-
import {t} from 'sentry/locale';
63
import {MetricDetectorChart} from 'sentry/views/detectors/components/forms/metric/metricDetectorChart';
74
import {
85
createConditions,
9-
getBackendDataset,
106
METRIC_DETECTOR_FORM_FIELDS,
117
useMetricDetectorFormField,
128
} from 'sentry/views/detectors/components/forms/metric/metricFormData';
13-
import {useTimePeriodSelection} from 'sentry/views/detectors/hooks/useTimePeriodSelection';
149

1510
export function MetricDetectorPreviewChart() {
1611
// Get all the form fields needed for the chart
@@ -47,12 +42,6 @@ export function MetricDetectorPreviewChart() {
4742
METRIC_DETECTOR_FORM_FIELDS.thresholdType
4843
);
4944

50-
const {selectedTimePeriod, setSelectedTimePeriod, timePeriodOptions} =
51-
useTimePeriodSelection({
52-
dataset: getBackendDataset(dataset),
53-
interval,
54-
});
55-
5645
// Create condition group from form data using the helper function
5746
const conditions = useMemo(() => {
5847
// Wait for a condition value to be defined
@@ -69,46 +58,18 @@ export function MetricDetectorPreviewChart() {
6958
}, [conditionType, conditionValue, initialPriorityLevel, highThreshold, detectionType]);
7059

7160
return (
72-
<ChartContainer>
73-
<MetricDetectorChart
74-
dataset={dataset}
75-
aggregate={aggregateFunction}
76-
interval={interval}
77-
query={query}
78-
environment={environment}
79-
projectId={projectId}
80-
conditions={conditions}
81-
detectionType={detectionType}
82-
statsPeriod={selectedTimePeriod}
83-
comparisonDelta={detectionType === 'percent' ? conditionComparisonAgo : undefined}
84-
sensitivity={sensitivity}
85-
thresholdType={thresholdType}
86-
/>
87-
<ChartFooter>
88-
<CompactSelect
89-
size="sm"
90-
options={timePeriodOptions}
91-
value={selectedTimePeriod}
92-
onChange={opt => setSelectedTimePeriod(opt.value)}
93-
triggerProps={{
94-
borderless: true,
95-
prefix: t('Display'),
96-
}}
97-
/>
98-
</ChartFooter>
99-
</ChartContainer>
61+
<MetricDetectorChart
62+
dataset={dataset}
63+
aggregate={aggregateFunction}
64+
interval={interval}
65+
query={query}
66+
environment={environment}
67+
projectId={projectId}
68+
conditions={conditions}
69+
detectionType={detectionType}
70+
comparisonDelta={detectionType === 'percent' ? conditionComparisonAgo : undefined}
71+
sensitivity={sensitivity}
72+
thresholdType={thresholdType}
73+
/>
10074
);
10175
}
102-
103-
const ChartContainer = styled('div')`
104-
max-width: 1440px;
105-
border-top: 1px solid ${p => p.theme.border};
106-
`;
107-
108-
const ChartFooter = styled('div')`
109-
display: flex;
110-
justify-content: flex-end;
111-
align-items: center;
112-
padding: ${p => `${p.theme.space.sm} 0`};
113-
border-top: 1px solid ${p => p.theme.border};
114-
`;

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import {useMemo} from 'react';
22

33
import getDuration from 'sentry/utils/duration/getDuration';
44
import {TimeWindow} from 'sentry/views/alerts/rules/metric/types';
5-
import {type MetricDetectorFormData} from 'sentry/views/detectors/components/forms/metric/metricFormData';
5+
import type {MetricDetectorFormData} from 'sentry/views/detectors/components/forms/metric/metricFormData';
66
import {DetectorDataset} from 'sentry/views/detectors/datasetConfig/types';
7+
import {isEapDataset} from 'sentry/views/detectors/datasetConfig/utils/isEapDataset';
78

89
const baseIntervals: TimeWindow[] = [
910
TimeWindow.ONE_MINUTE,
@@ -41,9 +42,6 @@ export function useIntervalChoices({dataset, detectionType}: UseIntervalChoicesP
4142
// 4. Everything else → All intervals allowed
4243
const shouldExcludeSubHour = dataset === DetectorDataset.RELEASES;
4344
const isDynamicDetection = detectionType === 'dynamic';
44-
// EAP-derived datasets (spans, logs) exclude 1-minute intervals
45-
const isEAPDerivedDataset =
46-
dataset === DetectorDataset.SPANS || dataset === DetectorDataset.LOGS;
4745

4846
const filteredIntervals = baseIntervals.filter(timeWindow => {
4947
if (shouldExcludeSubHour) {
@@ -52,7 +50,8 @@ export function useIntervalChoices({dataset, detectionType}: UseIntervalChoicesP
5250
if (isDynamicDetection) {
5351
return dynamicIntervalChoices.includes(timeWindow);
5452
}
55-
if (isEAPDerivedDataset) {
53+
// EAP-derived datasets (spans, logs) exclude 1-minute intervals
54+
if (isEapDataset(dataset)) {
5655
return timeWindow !== TimeWindow.ONE_MINUTE;
5756
}
5857
return true;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import {DetectorDataset} from 'sentry/views/detectors/datasetConfig/types';
2+
3+
export function isEapDataset(dataset: DetectorDataset): boolean {
4+
return dataset === DetectorDataset.SPANS || dataset === DetectorDataset.LOGS;
5+
}

0 commit comments

Comments
 (0)