Skip to content

feat(aci): Optimize anomaly loading, empty states #97535

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Aug 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function MetricDetectorChart({
const comparisonDelta =
detectionType === 'percent' ? detector.config.comparisonDelta : undefined;
const dataset = getDetectorDataset(snubaQuery.dataset, snubaQuery.eventTypes);
const {series, comparisonSeries, isLoading, isError} = useMetricDetectorSeries({
const {series, comparisonSeries, isLoading, error} = useMetricDetectorSeries({
dataset,
aggregate: snubaQuery.aggregate,
interval: snubaQuery.timeWindow,
Expand Down Expand Up @@ -140,7 +140,7 @@ function MetricDetectorChart({
);
}

if (isError) {
if (error) {
return (
<Flex style={{height: CHART_HEIGHT}} justify="center" align="center">
<ErrorPanel>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,52 @@
import {useMemo} from 'react';
import {Fragment, useMemo} from 'react';
import styled from '@emotion/styled';
import type {YAXisComponentOption} from 'echarts';

import {AreaChart} from 'sentry/components/charts/areaChart';
import ErrorPanel from 'sentry/components/charts/errorPanel';
import {CompactSelect} from 'sentry/components/core/compactSelect';
import {Flex} from 'sentry/components/core/layout';
import {Text} from 'sentry/components/core/text';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import Placeholder from 'sentry/components/placeholder';
import {IconWarning} from 'sentry/icons';
import {t} from 'sentry/locale';
import {t, tn} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {DataCondition} from 'sentry/types/workflowEngine/dataConditions';
import type {MetricDetectorConfig} from 'sentry/types/workflowEngine/detectors';
import {
AlertRuleSensitivity,
AlertRuleThresholdType,
TimePeriod,
} from 'sentry/views/alerts/rules/metric/types';
import {getBackendDataset} from 'sentry/views/detectors/components/forms/metric/metricFormData';
import type {DetectorDataset} from 'sentry/views/detectors/datasetConfig/types';
import {useIncidentMarkers} from 'sentry/views/detectors/hooks/useIncidentMarkers';
import {useMetricDetectorAnomalyPeriods} from 'sentry/views/detectors/hooks/useMetricDetectorAnomalyPeriods';
import {useMetricDetectorSeries} from 'sentry/views/detectors/hooks/useMetricDetectorSeries';
import {useMetricDetectorThresholdSeries} from 'sentry/views/detectors/hooks/useMetricDetectorThresholdSeries';
import {useTimePeriodSelection} from 'sentry/views/detectors/hooks/useTimePeriodSelection';

const CHART_HEIGHT = 180;

function ChartError() {
return (
<Flex style={{height: CHART_HEIGHT}} justify="center" align="center">
<ErrorPanel>
<IconWarning color="gray300" size="lg" />
<div>{t('Error loading chart data')}</div>
</ErrorPanel>
</Flex>
);
}

function ChartLoading() {
return (
<Flex style={{height: CHART_HEIGHT}} justify="center" align="center">
<Placeholder height={`${CHART_HEIGHT - 20}px`} />
</Flex>
);
}

interface MetricDetectorChartProps {
/**
* The aggregate function to use (e.g., "avg(span.duration)")
Expand Down Expand Up @@ -61,10 +85,6 @@ interface MetricDetectorChartProps {
* Used in anomaly detection
*/
sensitivity: AlertRuleSensitivity | undefined;
/**
* The time period for the chart data (optional, defaults to 7d)
*/
statsPeriod: TimePeriod;
/**
* Used in anomaly detection
*/
Expand All @@ -80,19 +100,24 @@ export function MetricDetectorChart({
projectId,
conditions,
detectionType,
statsPeriod,
comparisonDelta,
sensitivity,
thresholdType,
}: MetricDetectorChartProps) {
const {series, comparisonSeries, isLoading, isError} = useMetricDetectorSeries({
const {selectedTimePeriod, setSelectedTimePeriod, timePeriodOptions} =
useTimePeriodSelection({
dataset: getBackendDataset(dataset),
interval,
});

const {series, comparisonSeries, isLoading, error} = useMetricDetectorSeries({
dataset,
aggregate,
interval,
query,
environment,
projectId,
statsPeriod,
statsPeriod: selectedTimePeriod,
comparisonDelta,
});

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

const isAnomalyDetection = detectionType === 'dynamic';
const shouldFetchAnomalies =
isAnomalyDetection && !isLoading && !isError && series.length > 0;
isAnomalyDetection && !isLoading && !error && series.length > 0;

// Fetch anomaly data when detection type is dynamic and series data is ready
const {
anomalyPeriods,
isLoading: isLoadingAnomalies,
error: anomalyErrorObject,
error: anomalyError,
} = useMetricDetectorAnomalyPeriods({
series: shouldFetchAnomalies ? series : [],
isLoadingSeries: isLoading,
dataset,
aggregate,
query,
environment,
projectId,
statsPeriod,
timePeriod: interval,
statsPeriod: selectedTimePeriod,
interval,
thresholdType,
sensitivity,
enabled: shouldFetchAnomalies,
enabled: isAnomalyDetection,
});

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

const anomalyLoading = shouldFetchAnomalies ? isLoadingAnomalies : false;
const anomalyError = shouldFetchAnomalies ? anomalyErrorObject : null;

// Calculate y-axis bounds to ensure all thresholds are visible
const maxValue = useMemo(() => {
// Get max from series data
Expand Down Expand Up @@ -219,40 +242,80 @@ export function MetricDetectorChart({
return baseGrid;
}, [isAnomalyDetection, anomalyMarkerResult.incidentMarkerGrid]);

if (isLoading || anomalyLoading) {
return (
<Flex style={{height: CHART_HEIGHT}} justify="center" align="center">
<Placeholder height={`${CHART_HEIGHT - 20}px`} />
</Flex>
);
}

if (isError || anomalyError) {
return (
<Flex style={{height: CHART_HEIGHT}} justify="center" align="center">
<ErrorPanel>
<IconWarning color="gray300" size="lg" />
<div>{t('Error loading chart data')}</div>
</ErrorPanel>
</Flex>
);
}

return (
<AreaChart
isGroupedByDate
showTimeInTooltip
height={CHART_HEIGHT}
stacked={false}
series={series}
additionalSeries={additionalSeries}
yAxes={yAxes.length > 1 ? yAxes : undefined}
yAxis={yAxes.length === 1 ? yAxes[0] : undefined}
grid={grid}
xAxis={isAnomalyDetection ? anomalyMarkerResult.incidentMarkerXAxis : undefined}
ref={
isAnomalyDetection ? anomalyMarkerResult.connectIncidentMarkerChartRef : undefined
}
/>
<ChartContainer>
{isLoading ? (
<ChartLoading />
) : error ? (
<ChartError />
) : (
<AreaChart
isGroupedByDate
showTimeInTooltip
height={CHART_HEIGHT}
stacked={false}
series={series}
additionalSeries={additionalSeries}
yAxes={yAxes.length > 1 ? yAxes : undefined}
yAxis={yAxes.length === 1 ? yAxes[0] : undefined}
grid={grid}
xAxis={isAnomalyDetection ? anomalyMarkerResult.incidentMarkerXAxis : undefined}
ref={
isAnomalyDetection
? anomalyMarkerResult.connectIncidentMarkerChartRef
: undefined
}
/>
)}
<ChartFooter>
{shouldFetchAnomalies ? (
<Flex align="center" gap="sm">
{isLoadingAnomalies ? (
<Fragment>
<AnomalyLoadingIndicator size={18} />
<Text variant="muted">{t('Loading anomalies...')}</Text>
</Fragment>
) : anomalyError ? (
<Text variant="muted">{t('Error loading anomalies')}</Text>
) : anomalyPeriods.length === 0 ? (
<Text variant="muted">{t('No anomalies found for this time period')}</Text>
) : (
<Text variant="muted">
{tn('Found %s anomaly', 'Found %s anomalies', anomalyPeriods.length)}
</Text>
)}
</Flex>
) : (
<div />
)}
<CompactSelect
size="sm"
options={timePeriodOptions}
value={selectedTimePeriod}
onChange={opt => setSelectedTimePeriod(opt.value)}
triggerProps={{
borderless: true,
prefix: t('Display'),
}}
/>
</ChartFooter>
</ChartContainer>
);
}

const ChartContainer = styled('div')`
max-width: 1440px;
border-top: 1px solid ${p => p.theme.border};
`;

const ChartFooter = styled('div')`
display: flex;
justify-content: space-between;
align-items: center;
padding: ${p => `${p.theme.space.sm} 0 ${p.theme.space.sm} ${p.theme.space.lg}`};
border-top: 1px solid ${p => p.theme.border};
`;

const AnomalyLoadingIndicator = styled(LoadingIndicator)`
margin: 0;
`;
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import {useMemo} from 'react';
import styled from '@emotion/styled';

import {CompactSelect} from 'sentry/components/core/compactSelect';
import {t} from 'sentry/locale';
import {MetricDetectorChart} from 'sentry/views/detectors/components/forms/metric/metricDetectorChart';
import {
createConditions,
getBackendDataset,
METRIC_DETECTOR_FORM_FIELDS,
useMetricDetectorFormField,
} from 'sentry/views/detectors/components/forms/metric/metricFormData';
import {useTimePeriodSelection} from 'sentry/views/detectors/hooks/useTimePeriodSelection';

export function MetricDetectorPreviewChart() {
// Get all the form fields needed for the chart
Expand Down Expand Up @@ -47,12 +42,6 @@ export function MetricDetectorPreviewChart() {
METRIC_DETECTOR_FORM_FIELDS.thresholdType
);

const {selectedTimePeriod, setSelectedTimePeriod, timePeriodOptions} =
useTimePeriodSelection({
dataset: getBackendDataset(dataset),
interval,
});

// Create condition group from form data using the helper function
const conditions = useMemo(() => {
// Wait for a condition value to be defined
Expand All @@ -69,46 +58,18 @@ export function MetricDetectorPreviewChart() {
}, [conditionType, conditionValue, initialPriorityLevel, highThreshold, detectionType]);

return (
<ChartContainer>
<MetricDetectorChart
dataset={dataset}
aggregate={aggregateFunction}
interval={interval}
query={query}
environment={environment}
projectId={projectId}
conditions={conditions}
detectionType={detectionType}
statsPeriod={selectedTimePeriod}
comparisonDelta={detectionType === 'percent' ? conditionComparisonAgo : undefined}
sensitivity={sensitivity}
thresholdType={thresholdType}
/>
<ChartFooter>
<CompactSelect
size="sm"
options={timePeriodOptions}
value={selectedTimePeriod}
onChange={opt => setSelectedTimePeriod(opt.value)}
triggerProps={{
borderless: true,
prefix: t('Display'),
}}
/>
</ChartFooter>
</ChartContainer>
<MetricDetectorChart
dataset={dataset}
aggregate={aggregateFunction}
interval={interval}
query={query}
environment={environment}
projectId={projectId}
conditions={conditions}
detectionType={detectionType}
comparisonDelta={detectionType === 'percent' ? conditionComparisonAgo : undefined}
sensitivity={sensitivity}
thresholdType={thresholdType}
/>
);
}

const ChartContainer = styled('div')`
max-width: 1440px;
border-top: 1px solid ${p => p.theme.border};
`;

const ChartFooter = styled('div')`
display: flex;
justify-content: flex-end;
align-items: center;
padding: ${p => `${p.theme.space.sm} 0`};
border-top: 1px solid ${p => p.theme.border};
`;
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import {useMemo} from 'react';

import getDuration from 'sentry/utils/duration/getDuration';
import {TimeWindow} from 'sentry/views/alerts/rules/metric/types';
import {type MetricDetectorFormData} from 'sentry/views/detectors/components/forms/metric/metricFormData';
import type {MetricDetectorFormData} from 'sentry/views/detectors/components/forms/metric/metricFormData';
import {DetectorDataset} from 'sentry/views/detectors/datasetConfig/types';
import {isEapDataset} from 'sentry/views/detectors/datasetConfig/utils/isEapDataset';

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

const filteredIntervals = baseIntervals.filter(timeWindow => {
if (shouldExcludeSubHour) {
Expand All @@ -52,7 +50,8 @@ export function useIntervalChoices({dataset, detectionType}: UseIntervalChoicesP
if (isDynamicDetection) {
return dynamicIntervalChoices.includes(timeWindow);
}
if (isEAPDerivedDataset) {
// EAP-derived datasets (spans, logs) exclude 1-minute intervals
if (isEapDataset(dataset)) {
return timeWindow !== TimeWindow.ONE_MINUTE;
}
return true;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {DetectorDataset} from 'sentry/views/detectors/datasetConfig/types';

export function isEapDataset(dataset: DetectorDataset): boolean {
return dataset === DetectorDataset.SPANS || dataset === DetectorDataset.LOGS;
}
Loading
Loading