Skip to content
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
45 changes: 43 additions & 2 deletions static/app/views/detectors/components/details/metric/chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -177,13 +179,48 @@ 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,
detectionType,
comparisonSeries,
});

const {anomalyThresholdSeries} = useMetricDetectorAnomalyThresholds({
detectorId: detector.id,
startTimestamp: metricTimestamps.start,
endTimestamp: metricTimestamps.end,
series,
});

const incidentPeriods = useMemo(() => {
return openPeriods.flatMap<IncidentPeriod>(period => [
createTriggerIntervalMarkerData({
Expand Down Expand Up @@ -247,13 +284,17 @@ export function useMetricDetectorChart({
}, [series, thresholdMaxValue]);

const additionalSeries = useMemo(() => {
const baseSeries = [...thresholdAdditionalSeries];
const baseSeries = [...thresholdAdditionalSeries, ...anomalyThresholdSeries];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Y-axis bounds don't account for anomaly threshold values

The maxValue calculation only considers series and thresholdMaxValue, but doesn't include values from the new anomalyThresholdSeries (specifically yhat_upper, yhat_lower, and value). The existing useMetricDetectorThresholdSeries hook returns a maxValue to ensure thresholds are visible, but useMetricDetectorAnomalyThresholds only returns the series without a max value. When the Y-axis max is explicitly set at line 306, anomaly threshold lines that exceed this bound will be clipped and not visible to users.

Additional Locations (1)

Fix in Cursor Fix in Web


// 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({
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AnomalyThresholdDataResponse>(
[
`/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]> = [];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are we including the seer value just for debugging with plans to remove it later?

Copy link
Member Author

@shayna-ch shayna-ch Dec 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep! there are some things that seem out of sync so im including it for now for debugging(this is why I changed the feature flag to be email only)


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};
}
Loading