From 64d18e69332ad60eaf30b8d26dba8f49b9943c3d Mon Sep 17 00:00:00 2001 From: Shayna Chambless Date: Wed, 26 Nov 2025 09:36:57 -0800 Subject: [PATCH 1/2] testing data --- .../components/details/metric/chart.tsx | 177 +++++++++++++++++- 1 file changed, 174 insertions(+), 3 deletions(-) diff --git a/static/app/views/detectors/components/details/metric/chart.tsx b/static/app/views/detectors/components/details/metric/chart.tsx index 580de6a05fb9b4..53f98230d4ce68 100644 --- a/static/app/views/detectors/components/details/metric/chart.tsx +++ b/static/app/views/detectors/components/details/metric/chart.tsx @@ -1,11 +1,12 @@ import {useMemo} from 'react'; -import {type Theme} from '@emotion/react'; +import {useTheme, type Theme} from '@emotion/react'; import styled from '@emotion/styled'; import type {YAXisComponentOption} from 'echarts'; import {AreaChart, type AreaChartProps} from 'sentry/components/charts/areaChart'; import {defaultFormatAxisLabel} from 'sentry/components/charts/components/tooltip'; import ErrorPanel from 'sentry/components/charts/errorPanel'; +import LineSeries from 'sentry/components/charts/series/lineSeries'; import {useChartZoom} from 'sentry/components/charts/useChartZoom'; import {Alert} from 'sentry/components/core/alert'; import {Flex} from 'sentry/components/core/layout'; @@ -16,9 +17,11 @@ import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {GroupOpenPeriod} from 'sentry/types/group'; import type {MetricDetector, SnubaQuery} from 'sentry/types/workflowEngine/detectors'; +import {useApiQuery} from 'sentry/utils/queryClient'; import type RequestError from 'sentry/utils/requestError/requestError'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; +import useOrganization from 'sentry/utils/useOrganization'; import { buildDetectorZoomQuery, computeZoomRangeMs, @@ -39,6 +42,18 @@ interface IncidentTooltipContext { theme: Theme; } +interface AnomalyThresholdDataPoint { + external_alert_id: number; + timestamp: number; + value: number; + yhat_lower: number; + yhat_upper: number; +} + +interface AnomalyThresholdDataResponse { + data: AnomalyThresholdDataPoint[]; +} + function incidentSeriesTooltip(ctx: IncidentTooltipContext) { const startTime = defaultFormatAxisLabel(ctx.period.start, true, false, true, false); const endTime = ctx.period.end @@ -155,6 +170,9 @@ export function useMetricDetectorChart({ }: UseMetricDetectorChartProps): UseMetricDetectorChartResult { const navigate = useNavigate(); const location = useLocation(); + const theme = useTheme(); + const organization = useOrganization(); + const detectionType = detector.config.detectionType; const comparisonDelta = detectionType === 'percent' ? detector.config.comparisonDelta : undefined; @@ -177,6 +195,56 @@ 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 hasAnomalyDataFlag = organization.features.includes( + 'anomaly-detection-threshold-data' + ); + + const {data: anomalyData} = useApiQuery( + [ + `/organizations/${organization.slug}/detectors/${detector.id}/anomaly-data/`, + { + query: { + start: metricTimestamps.start ? metricTimestamps.start : undefined, + end: metricTimestamps.end, + }, + }, + ], + { + staleTime: 0, + enabled: + hasAnomalyDataFlag && + Boolean(detector.id && metricTimestamps.start && metricTimestamps.end), + } + ); + const {maxValue: thresholdMaxValue, additionalSeries: thresholdAdditionalSeries} = useMetricDetectorThresholdSeries({ conditions: detector.conditionGroup?.conditions, @@ -184,6 +252,105 @@ export function useMetricDetectorChart({ comparisonSeries, }); + 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]> = []; + + 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]); + const incidentPeriods = useMemo(() => { return openPeriods.flatMap(period => [ createTriggerIntervalMarkerData({ @@ -247,13 +414,17 @@ export function useMetricDetectorChart({ }, [series, thresholdMaxValue]); const additionalSeries = useMemo(() => { - const baseSeries = [...thresholdAdditionalSeries]; + const baseSeries = [...thresholdAdditionalSeries, ...anomalyThresholdSeries]; // 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({ From 247535b0d0d9e30cf5d2756dac81fb422aec8b9e Mon Sep 17 00:00:00 2001 From: Shayna Chambless Date: Wed, 26 Nov 2025 13:34:19 -0800 Subject: [PATCH 2/2] fix --- .../components/details/metric/chart.tsx | 146 +-------------- .../useMetricDetectorAnomalyThresholds.tsx | 173 ++++++++++++++++++ 2 files changed, 181 insertions(+), 138 deletions(-) create mode 100644 static/app/views/detectors/hooks/useMetricDetectorAnomalyThresholds.tsx diff --git a/static/app/views/detectors/components/details/metric/chart.tsx b/static/app/views/detectors/components/details/metric/chart.tsx index 53f98230d4ce68..da370a98c3bc36 100644 --- a/static/app/views/detectors/components/details/metric/chart.tsx +++ b/static/app/views/detectors/components/details/metric/chart.tsx @@ -1,12 +1,11 @@ import {useMemo} from 'react'; -import {useTheme, type Theme} from '@emotion/react'; +import {type Theme} from '@emotion/react'; import styled from '@emotion/styled'; import type {YAXisComponentOption} from 'echarts'; import {AreaChart, type AreaChartProps} from 'sentry/components/charts/areaChart'; import {defaultFormatAxisLabel} from 'sentry/components/charts/components/tooltip'; import ErrorPanel from 'sentry/components/charts/errorPanel'; -import LineSeries from 'sentry/components/charts/series/lineSeries'; import {useChartZoom} from 'sentry/components/charts/useChartZoom'; import {Alert} from 'sentry/components/core/alert'; import {Flex} from 'sentry/components/core/layout'; @@ -17,11 +16,9 @@ import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {GroupOpenPeriod} from 'sentry/types/group'; import type {MetricDetector, SnubaQuery} from 'sentry/types/workflowEngine/detectors'; -import {useApiQuery} from 'sentry/utils/queryClient'; import type RequestError from 'sentry/utils/requestError/requestError'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; -import useOrganization from 'sentry/utils/useOrganization'; import { buildDetectorZoomQuery, computeZoomRangeMs, @@ -32,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'; @@ -42,18 +40,6 @@ interface IncidentTooltipContext { theme: Theme; } -interface AnomalyThresholdDataPoint { - external_alert_id: number; - timestamp: number; - value: number; - yhat_lower: number; - yhat_upper: number; -} - -interface AnomalyThresholdDataResponse { - data: AnomalyThresholdDataPoint[]; -} - function incidentSeriesTooltip(ctx: IncidentTooltipContext) { const startTime = defaultFormatAxisLabel(ctx.period.start, true, false, true, false); const endTime = ctx.period.end @@ -170,8 +156,6 @@ export function useMetricDetectorChart({ }: UseMetricDetectorChartProps): UseMetricDetectorChartResult { const navigate = useNavigate(); const location = useLocation(); - const theme = useTheme(); - const organization = useOrganization(); const detectionType = detector.config.detectionType; const comparisonDelta = @@ -223,28 +207,6 @@ export function useMetricDetectorChart({ }; }, [series]); - const hasAnomalyDataFlag = organization.features.includes( - 'anomaly-detection-threshold-data' - ); - - const {data: anomalyData} = useApiQuery( - [ - `/organizations/${organization.slug}/detectors/${detector.id}/anomaly-data/`, - { - query: { - start: metricTimestamps.start ? metricTimestamps.start : undefined, - end: metricTimestamps.end, - }, - }, - ], - { - staleTime: 0, - enabled: - hasAnomalyDataFlag && - Boolean(detector.id && metricTimestamps.start && metricTimestamps.end), - } - ); - const {maxValue: thresholdMaxValue, additionalSeries: thresholdAdditionalSeries} = useMetricDetectorThresholdSeries({ conditions: detector.conditionGroup?.conditions, @@ -252,104 +214,12 @@ export function useMetricDetectorChart({ comparisonSeries, }); - 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]> = []; - - 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]); + const {anomalyThresholdSeries} = useMetricDetectorAnomalyThresholds({ + detectorId: detector.id, + startTimestamp: metricTimestamps.start, + endTimestamp: metricTimestamps.end, + series, + }); const incidentPeriods = useMemo(() => { return openPeriods.flatMap(period => [ diff --git a/static/app/views/detectors/hooks/useMetricDetectorAnomalyThresholds.tsx b/static/app/views/detectors/hooks/useMetricDetectorAnomalyThresholds.tsx new file mode 100644 index 00000000000000..7b3e57453270fa --- /dev/null +++ b/static/app/views/detectors/hooks/useMetricDetectorAnomalyThresholds.tsx @@ -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( + [ + `/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]> = []; + + 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}; +}