diff --git a/static/app/types/workflowEngine/dataConditions.tsx b/static/app/types/workflowEngine/dataConditions.tsx index 69fcdf001d1fa1..783e19aa3a57dc 100644 --- a/static/app/types/workflowEngine/dataConditions.tsx +++ b/static/app/types/workflowEngine/dataConditions.tsx @@ -50,6 +50,7 @@ export enum DataConditionType { EVENT_FREQUENCY = 'event_frequency', EVENT_UNIQUE_USER_FREQUENCY = 'event_unique_user_frequency', PERCENT_SESSIONS = 'percent_sessions', + ANOMALY_DETECTION = 'anomaly_detection', } export enum DataConditionGroupLogicType { diff --git a/static/app/types/workflowEngine/detectors.tsx b/static/app/types/workflowEngine/detectors.tsx index 755e84bcdbf7c3..7b21b748b94efc 100644 --- a/static/app/types/workflowEngine/detectors.tsx +++ b/static/app/types/workflowEngine/detectors.tsx @@ -80,21 +80,17 @@ export type DetectorType = | 'uptime_domain_failure' | 'issue_stream'; -interface BaseMetricDetectorConfig { - thresholdPeriod: number; -} - /** * Configuration for static/threshold-based detection */ -interface MetricDetectorConfigStatic extends BaseMetricDetectorConfig { +interface MetricDetectorConfigStatic { detectionType: 'static'; } /** * Configuration for percentage-based change detection */ -interface MetricDetectorConfigPercent extends BaseMetricDetectorConfig { +interface MetricDetectorConfigPercent { comparisonDelta: number; detectionType: 'percent'; } @@ -102,11 +98,8 @@ interface MetricDetectorConfigPercent extends BaseMetricDetectorConfig { /** * Configuration for dynamic/anomaly detection */ -interface MetricDetectorConfigDynamic extends BaseMetricDetectorConfig { +interface MetricDetectorConfigDynamic { detectionType: 'dynamic'; - seasonality?: 'auto' | 'daily' | 'weekly' | 'monthly'; - sensitivity?: AlertRuleSensitivity; - thresholdType?: AlertRuleThresholdType; } export type MetricDetectorConfig = @@ -233,7 +226,7 @@ export interface MetricCondition { /** * See AnomalyDetectionHandler */ -interface AnomalyDetectionComparison { +export interface AnomalyDetectionComparison { seasonality: | 'auto' | 'hourly' @@ -243,8 +236,8 @@ interface AnomalyDetectionComparison { | 'hourly_weekly' | 'hourly_daily_weekly' | 'daily_weekly'; - sensitivity: 'low' | 'medium' | 'high'; - threshold_type: 0 | 1 | 2; + sensitivity: AlertRuleSensitivity; + thresholdType: AlertRuleThresholdType; } type MetricDataCondition = AnomalyDetectionComparison | number; diff --git a/static/app/views/detectors/components/forms/metric/metric.tsx b/static/app/views/detectors/components/forms/metric/metric.tsx index 16ae23de66714b..05daadd16bb89d 100644 --- a/static/app/views/detectors/components/forms/metric/metric.tsx +++ b/static/app/views/detectors/components/forms/metric/metric.tsx @@ -584,10 +584,14 @@ function DetectSection() { )} - - {t('Resolve')} - - + {detectionType !== 'dynamic' && ( + + + {t('Resolve')} + + + + )} ); diff --git a/static/app/views/detectors/components/forms/metric/metricFormData.tsx b/static/app/views/detectors/components/forms/metric/metricFormData.tsx index 0f1e404ddd7b70..bc622b7dfff406 100644 --- a/static/app/views/detectors/components/forms/metric/metricFormData.tsx +++ b/static/app/views/detectors/components/forms/metric/metricFormData.tsx @@ -5,6 +5,7 @@ import { DetectorPriorityLevel, } from 'sentry/types/workflowEngine/dataConditions'; import type { + AnomalyDetectionComparison, Detector, MetricCondition, MetricConditionGroup, @@ -183,6 +184,22 @@ interface NewDataSource { timeWindow: number; } +function createAnomalyDetectionCondition( + data: Pick +): NewConditionGroup['conditions'] { + return [ + { + type: DataConditionType.ANOMALY_DETECTION, + comparison: { + sensitivity: data.sensitivity, + seasonality: 'auto' as const, + thresholdType: data.thresholdType, + }, + conditionResult: DetectorPriorityLevel.HIGH, + }, + ]; +} + /** * Creates escalation conditions based on priority level and available thresholds */ @@ -303,7 +320,11 @@ function createDataSource(data: MetricDetectorFormData): NewDataSource { export function metricDetectorFormDataToEndpointPayload( data: MetricDetectorFormData ): MetricDetectorUpdatePayload { - const conditions = createConditions(data); + const conditions = + data.detectionType === 'dynamic' + ? createAnomalyDetectionCondition(data) + : createConditions(data); + const dataSource = createDataSource(data); // Create config based on detection type @@ -311,22 +332,18 @@ export function metricDetectorFormDataToEndpointPayload( switch (data.detectionType) { case 'percent': config = { - thresholdPeriod: 1, detectionType: 'percent', comparisonDelta: data.conditionComparisonAgo || 3600, }; break; case 'dynamic': config = { - thresholdPeriod: 1, detectionType: 'dynamic', - sensitivity: data.sensitivity, }; break; case 'static': default: config = { - thresholdPeriod: 1, detectionType: 'static', }; break; @@ -423,6 +440,24 @@ function processDetectorConditions( }; } +function getAnomalyCondition(detector: MetricDetector): AnomalyDetectionComparison { + const anomalyCondition = detector.conditionGroup?.conditions?.find( + condition => condition.type === DataConditionType.ANOMALY_DETECTION + ); + + const comparison = anomalyCondition?.comparison; + if (typeof comparison === 'object') { + return comparison; + } + + // Fallback to default values + return { + sensitivity: AlertRuleSensitivity.MEDIUM, + seasonality: 'auto', + thresholdType: AlertRuleThresholdType.ABOVE_AND_BELOW, + }; +} + /** * Converts a Detector to MetricDetectorFormData for editing */ @@ -444,6 +479,7 @@ export function metricSavedDetectorToFormData( : DetectorDataset.SPANS; const datasetConfig = getDatasetConfig(dataset); + const anomalyCondition = getAnomalyCondition(detector); return { // Core detector fields @@ -471,15 +507,8 @@ export function metricSavedDetectorToFormData( ? detector.config.comparisonDelta : DEFAULT_THRESHOLD_METRIC_FORM_DATA.conditionComparisonAgo, - // Dynamic fields - extract from config for dynamic detectors - sensitivity: - detector.config.detectionType === 'dynamic' && defined(detector.config.sensitivity) - ? detector.config.sensitivity - : DEFAULT_THRESHOLD_METRIC_FORM_DATA.sensitivity, - thresholdType: - detector.config.detectionType === 'dynamic' && - defined(detector.config.thresholdType) - ? detector.config.thresholdType - : DEFAULT_THRESHOLD_METRIC_FORM_DATA.thresholdType, + // Dynamic fields - extract from anomaly detection condition for dynamic detectors + sensitivity: anomalyCondition.sensitivity, + thresholdType: anomalyCondition.thresholdType, }; } diff --git a/static/app/views/detectors/edit.spec.tsx b/static/app/views/detectors/edit.spec.tsx index a7064c6f6e18c0..3aee57abd96c0a 100644 --- a/static/app/views/detectors/edit.spec.tsx +++ b/static/app/views/detectors/edit.spec.tsx @@ -24,7 +24,12 @@ import { DataConditionType, DetectorPriorityLevel, } from 'sentry/types/workflowEngine/dataConditions'; -import {Dataset, EventTypes} from 'sentry/views/alerts/rules/metric/types'; +import { + AlertRuleSensitivity, + AlertRuleThresholdType, + Dataset, + EventTypes, +} from 'sentry/views/alerts/rules/metric/types'; import {SnubaQueryType} from 'sentry/views/detectors/components/forms/metric/metricFormData'; import DetectorEdit from 'sentry/views/detectors/edit'; @@ -312,6 +317,9 @@ describe('DetectorEdit', () => { projectId: project.id, type: 'metric_issue', workflowIds: mockDetector.workflowIds, + config: { + detectionType: 'static', + }, dataSources: [ { environment: 'production', @@ -330,7 +338,6 @@ describe('DetectorEdit', () => { ], logicType: 'any', }, - config: {detectionType: 'static', thresholdPeriod: 1}, }, }) ); @@ -627,6 +634,56 @@ describe('DetectorEdit', () => { expect(await screen.findByText('15 minutes')).toBeInTheDocument(); }); + it('prefills thresholdType from anomaly detection condition when editing dynamic detector', async () => { + const dynamicDetector = MetricDetectorFixture({ + name: 'Dynamic Detector', + projectId: project.id, + config: { + detectionType: 'dynamic', + }, + conditionGroup: { + id: 'cg-dynamic', + logicType: DataConditionGroupLogicType.ANY, + conditions: [ + { + id: 'c-anomaly', + type: DataConditionType.ANOMALY_DETECTION, + comparison: { + sensitivity: AlertRuleSensitivity.HIGH, + seasonality: 'auto', + thresholdType: AlertRuleThresholdType.BELOW, + }, + conditionResult: DetectorPriorityLevel.HIGH, + }, + ], + }, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/detectors/${dynamicDetector.id}/`, + body: dynamicDetector, + }); + + render(, { + organization, + initialRouterConfig: { + route: '/organizations/:orgId/monitors/:detectorId/edit/', + location: { + pathname: `/organizations/${organization.slug}/monitors/${dynamicDetector.id}/edit/`, + }, + }, + }); + + expect( + await screen.findByRole('link', {name: 'Dynamic Detector'}) + ).toBeInTheDocument(); + + expect(screen.getByRole('radio', {name: 'Dynamic'})).toBeChecked(); + + // Verify thresholdType field is prefilled with "Below" + expect(screen.getByText('Below')).toBeInTheDocument(); + }); + it('calls anomaly API when using dynamic detection', async () => { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/detectors/${mockDetector.id}/`, diff --git a/static/app/views/detectors/list/allMonitors.spec.tsx b/static/app/views/detectors/list/allMonitors.spec.tsx index 97fda38969a583..4192369a965a22 100644 --- a/static/app/views/detectors/list/allMonitors.spec.tsx +++ b/static/app/views/detectors/list/allMonitors.spec.tsx @@ -53,7 +53,6 @@ describe('DetectorsList', () => { config: { detectionType: 'percent', comparisonDelta: 10, - thresholdPeriod: 10, }, conditionGroup: { id: '1', diff --git a/static/app/views/detectors/new-setting.spec.tsx b/static/app/views/detectors/new-setting.spec.tsx index ebd72ccd4bc029..2fbc48cf2ad6e5 100644 --- a/static/app/views/detectors/new-setting.spec.tsx +++ b/static/app/views/detectors/new-setting.spec.tsx @@ -197,7 +197,6 @@ describe('DetectorEdit', () => { }, config: { detectionType: 'static', - thresholdPeriod: 1, }, dataSources: [ { @@ -282,7 +281,7 @@ describe('DetectorEdit', () => { ], logicType: 'any', }, - config: {detectionType: 'static', thresholdPeriod: 1}, + config: {detectionType: 'static'}, dataSources: [ { aggregate: 'count_unique(tags[sentry:user])', @@ -352,7 +351,7 @@ describe('DetectorEdit', () => { ], logicType: 'any', }, - config: {detectionType: 'static', thresholdPeriod: 1}, + config: {detectionType: 'static'}, dataSources: [ { aggregate: 'count()', @@ -570,15 +569,23 @@ describe('DetectorEdit', () => { projectId: project.id, owner: null, workflowIds: [], - // Dynamic detection should have empty conditions (no resolution thresholds) + // Dynamic detection should have anomaly detection condition conditionGroup: { - conditions: [], + conditions: [ + { + type: 'anomaly_detection', + comparison: { + sensitivity: 'high', + seasonality: 'auto', + thresholdType: 0, + }, + conditionResult: 75, + }, + ], logicType: 'any', }, config: { detectionType: 'dynamic', - sensitivity: 'high', - thresholdPeriod: 1, }, dataSources: [ { diff --git a/tests/js/fixtures/detectors.ts b/tests/js/fixtures/detectors.ts index d3585daaddebec..5f1d61f71cdf06 100644 --- a/tests/js/fixtures/detectors.ts +++ b/tests/js/fixtures/detectors.ts @@ -78,7 +78,6 @@ export function MetricDetectorFixture( name: 'detector', config: { detectionType: 'static', - thresholdPeriod: 1, }, type: 'metric_issue', enabled: true,