Skip to content

Commit 4e3eed6

Browse files
authored
chore(aci): enforce config schema without subclassing (#81979)
1 parent 7bf2d7d commit 4e3eed6

File tree

12 files changed

+129
-28
lines changed

12 files changed

+129
-28
lines changed

Diff for: src/sentry/incidents/grouptype.py

+1
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ class MetricAlertFire(GroupType):
2626
enable_escalation_detection = False
2727
detector_handler = MetricAlertDetectorHandler
2828
detector_validator = MetricAlertsDetectorValidator
29+
detector_config_schema = {} # TODO(colleen): update this

Diff for: src/sentry/issues/grouptype.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from dataclasses import dataclass, field
66
from datetime import timedelta
77
from enum import Enum, StrEnum
8-
from typing import TYPE_CHECKING, Any
8+
from typing import TYPE_CHECKING, Any, ClassVar
99

1010
import sentry_sdk
1111
from django.apps import apps
@@ -174,6 +174,7 @@ class GroupType:
174174
notification_config: NotificationConfig = NotificationConfig()
175175
detector_handler: type[DetectorHandler] | None = None
176176
detector_validator: type[BaseGroupTypeDetectorValidator] | None = None
177+
detector_config_schema: ClassVar[dict[str, Any]] = {}
177178

178179
def __init_subclass__(cls: type[GroupType], **kwargs: Any) -> None:
179180
super().__init_subclass__(**kwargs)

Diff for: src/sentry/testutils/fixtures.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from sentry.incidents.models.alert_rule import AlertRule, AlertRuleMonitorTypeInt
1313
from sentry.integrations.models.integration import Integration
1414
from sentry.integrations.models.organization_integration import OrganizationIntegration
15+
from sentry.issues.grouptype import ErrorGroupType
1516
from sentry.models.activity import Activity
1617
from sentry.models.environment import Environment
1718
from sentry.models.grouprelease import GroupRelease
@@ -635,12 +636,13 @@ def create_detector(
635636
self,
636637
*args,
637638
project=None,
639+
type=ErrorGroupType.slug,
638640
**kwargs,
639641
) -> Detector:
640642
if project is None:
641643
project = self.create_project(organization=self.organization)
642644

643-
return Factories.create_detector(*args, project=project, **kwargs)
645+
return Factories.create_detector(*args, project=project, type=type, **kwargs)
644646

645647
def create_detector_state(self, *args, **kwargs) -> DetectorState:
646648
return Factories.create_detector_state(*args, **kwargs)

Diff for: src/sentry/workflow_engine/models/detector.py

+11-5
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from django.conf import settings
88
from django.db import models
99
from django.db.models import UniqueConstraint
10+
from django.db.models.signals import pre_save
11+
from django.dispatch import receiver
1012

1113
from sentry.backup.scopes import RelocationScope
1214
from sentry.db.models import DefaultFieldsModel, FlexibleForeignKey, region_silo_model
@@ -60,10 +62,6 @@ class Detector(DefaultFieldsModel, OwnerModel, JSONConfigBase):
6062
# The user that created the detector
6163
created_by_id = HybridCloudForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete="SET_NULL")
6264

63-
@property
64-
def CONFIG_SCHEMA(self) -> dict[str, Any]:
65-
raise NotImplementedError('Subclasses must define a "CONFIG_SCHEMA" attribute')
66-
6765
class Meta(OwnerModel.Meta):
6866
constraints = OwnerModel.Meta.constraints + [
6967
UniqueConstraint(
@@ -83,7 +81,6 @@ def detector_handler(self) -> DetectorHandler | None:
8381
logger.error(
8482
"No registered grouptype for detector",
8583
extra={
86-
"group_type": str(group_type),
8784
"detector_id": self.id,
8885
"detector_type": self.type,
8986
},
@@ -105,3 +102,12 @@ def detector_handler(self) -> DetectorHandler | None:
105102
def get_audit_log_data(self) -> dict[str, Any]:
106103
# TODO: Create proper audit log data for the detector, group and conditions
107104
return {}
105+
106+
107+
@receiver(pre_save, sender=Detector)
108+
def enforce_config_schema(sender, instance: Detector, **kwargs):
109+
group_type = instance.group_type
110+
if not group_type:
111+
raise ValueError(f"No group type found with type {instance.type}")
112+
config_schema = group_type.detector_config_schema
113+
instance.validate_config(config_schema)

Diff for: src/sentry/workflow_engine/models/json_config.py

+2-7
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from abc import abstractproperty
21
from typing import Any
32

43
from django.db import models
@@ -8,13 +7,9 @@
87
class JSONConfigBase(models.Model):
98
config = models.JSONField(db_default={})
109

11-
@abstractproperty
12-
def CONFIG_SCHEMA(self) -> dict[str, Any]:
13-
pass
14-
15-
def validate_config(self) -> None:
10+
def validate_config(self, schema: dict[str, Any]) -> None:
1611
try:
17-
validate(self.config, self.CONFIG_SCHEMA)
12+
validate(self.config, schema)
1813
except ValidationError as e:
1914
raise ValidationError(f"Invalid config: {e.message}")
2015

Diff for: src/sentry/workflow_engine/models/workflow.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from django.conf import settings
44
from django.db import models
5+
from django.db.models.signals import pre_save
6+
from django.dispatch import receiver
57

68
from sentry.backup.scopes import RelocationScope
79
from sentry.db.models import DefaultFieldsModel, FlexibleForeignKey, region_silo_model, sane_repr
@@ -35,8 +37,9 @@ class Workflow(DefaultFieldsModel, OwnerModel, JSONConfigBase):
3537
created_by_id = HybridCloudForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete="SET_NULL")
3638

3739
@property
38-
def CONFIG_SCHEMA(self) -> dict[str, Any]:
39-
raise NotImplementedError('Subclasses must define a "CONFIG_SCHEMA" attribute')
40+
def config_schema(self) -> dict[str, Any]:
41+
# TODO: fill in
42+
return {}
4043

4144
__repr__ = sane_repr("name", "organization_id")
4245

@@ -60,3 +63,8 @@ def evaluate_trigger_conditions(self, evt: GroupEvent) -> bool:
6063

6164
evaluation, _ = evaluate_condition_group(self.when_condition_group, evt)
6265
return evaluation
66+
67+
68+
@receiver(pre_save, sender=Workflow)
69+
def enforce_config_schema(sender, instance: Workflow, **kwargs):
70+
instance.validate_config(instance.config_schema)

Diff for: src/sentry/workflow_engine/processors/detector.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
import logging
44

55
from sentry.eventstore.models import GroupEvent
6+
from sentry.issues.grouptype import ErrorGroupType
67
from sentry.issues.issue_occurrence import IssueOccurrence
78
from sentry.issues.producer import PayloadType, produce_occurrence_to_kafka
89
from sentry.workflow_engine.handlers.detector import DetectorEvaluationResult
910
from sentry.workflow_engine.models import DataPacket, Detector
10-
from sentry.workflow_engine.types import DetectorGroupKey, DetectorType
11+
from sentry.workflow_engine.types import DetectorGroupKey
1112

1213
logger = logging.getLogger(__name__)
1314

@@ -17,7 +18,7 @@ def get_detector_by_event(evt: GroupEvent) -> Detector:
1718
issue_occurrence = evt.occurrence
1819

1920
if issue_occurrence is None:
20-
detector = Detector.objects.get(project_id=evt.project_id, type=DetectorType.ERROR)
21+
detector = Detector.objects.get(project_id=evt.project_id, type=ErrorGroupType.slug)
2122
else:
2223
detector = Detector.objects.get(id=issue_occurrence.evidence_data.get("detector_id", None))
2324

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from dataclasses import dataclass
2+
from unittest.mock import PropertyMock, patch
3+
4+
import pytest
5+
from jsonschema import ValidationError
6+
7+
from sentry.issues.grouptype import GroupCategory, GroupType
8+
from tests.sentry.issues.test_grouptype import BaseGroupTypeTest
9+
10+
11+
class TestJsonConfigBase(BaseGroupTypeTest):
12+
def setUp(self):
13+
super().setUp()
14+
self.correct_config = {
15+
"username": "user123",
16+
"email": "[email protected]",
17+
"fullName": "John Doe",
18+
"age": 30,
19+
"location": "Cityville",
20+
"interests": ["Travel", "Technology"],
21+
}
22+
23+
@dataclass(frozen=True)
24+
class TestGroupType(GroupType):
25+
type_id = 1
26+
slug = "test"
27+
description = "Test"
28+
category = GroupCategory.ERROR.value
29+
detector_config_schema = self.example_schema
30+
31+
@pytest.fixture(autouse=True)
32+
def initialize_configs(self):
33+
self.example_schema = {
34+
"$id": "https://example.com/user-profile.schema.json",
35+
"$schema": "https://json-schema.org/draft/2020-12/schema",
36+
"description": "A representation of a user profile",
37+
"type": "object",
38+
"required": ["username", "email"],
39+
"properties": {
40+
"username": {"type": "string"},
41+
"email": {"type": "string", "format": "email"},
42+
"fullName": {"type": "string"},
43+
"age": {"type": "integer", "minimum": 0},
44+
"location": {"type": "string"},
45+
"interests": {"type": "array", "items": {"type": "string"}},
46+
},
47+
}
48+
with (
49+
patch(
50+
"sentry.workflow_engine.models.Workflow.config_schema",
51+
return_value=self.example_schema,
52+
new_callable=PropertyMock,
53+
),
54+
):
55+
# Run test case
56+
yield
57+
58+
59+
class TestDetectorConfig(TestJsonConfigBase):
60+
def test_detector_no_registration(self):
61+
with pytest.raises(ValueError):
62+
self.create_detector(name="test_detector", type="no_registration")
63+
64+
def test_detector_mismatched_schema(self):
65+
with pytest.raises(ValidationError):
66+
self.create_detector(name="test_detector", type="test", config={"hi": "there"})
67+
68+
def test_detector_correct_schema(self):
69+
self.create_detector(name="test_detector", type="test", config=self.correct_config)
70+
71+
72+
class TestWorkflowConfig(TestJsonConfigBase):
73+
def test_workflow_mismatched_schema(self):
74+
with pytest.raises(ValidationError):
75+
self.create_workflow(
76+
organization=self.organization, name="test_workflow", config={"hi": "there"}
77+
)
78+
79+
def test_workflow_correct_schema(self):
80+
self.create_workflow(
81+
organization=self.organization, name="test_workflow", config=self.correct_config
82+
)

Diff for: tests/sentry/workflow_engine/models/test_workflow.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
from sentry.workflow_engine.types import DetectorType
21
from tests.sentry.workflow_engine.test_base import BaseWorkflowTest
32

43

54
class WorkflowTest(BaseWorkflowTest):
65
def setUp(self):
76
self.workflow, self.detector, self.detector_workflow, self.data_condition_group = (
8-
self.create_detector_and_workflow(detector_type=DetectorType.ERROR)
7+
self.create_detector_and_workflow()
98
)
109
self.data_condition = self.data_condition_group.conditions.first()
1110
self.group, self.event, self.group_event = self.create_group_event()

Diff for: tests/sentry/workflow_engine/processors/test_detector.py

+10-3
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,16 @@ def test_state_results_multi_group(self, mock_produce_occurrence_to_kafka):
121121
)
122122

123123
def test_no_issue_type(self):
124-
detector = self.create_detector(type="invalid slug")
124+
detector = self.create_detector(type=self.handler_state_type.slug)
125125
data_packet = self.build_data_packet()
126-
with mock.patch("sentry.workflow_engine.models.detector.logger") as mock_logger:
126+
with (
127+
mock.patch("sentry.workflow_engine.models.detector.logger") as mock_logger,
128+
mock.patch(
129+
"sentry.workflow_engine.models.Detector.group_type",
130+
return_value=None,
131+
new_callable=mock.PropertyMock,
132+
),
133+
):
127134
results = process_detectors(data_packet, [detector])
128135
assert mock_logger.error.call_args[0][0] == "No registered grouptype for detector"
129136
assert results == []
@@ -326,7 +333,7 @@ def test_above_below_threshold(self):
326333
}
327334

328335
def test_no_condition_group(self):
329-
detector = self.create_detector()
336+
detector = self.create_detector(type=self.handler_type.slug)
330337
handler = MockDetectorStateHandler(detector)
331338
with mock.patch(
332339
"sentry.workflow_engine.handlers.detector.stateful.metrics"

Diff for: tests/sentry/workflow_engine/processors/test_workflow.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from unittest import mock
22

3+
from sentry.incidents.grouptype import MetricAlertFire
34
from sentry.workflow_engine.models import DataConditionGroup
45
from sentry.workflow_engine.models.data_condition import Condition
56
from sentry.workflow_engine.processors.workflow import evaluate_workflow_triggers, process_workflows
6-
from sentry.workflow_engine.types import DetectorType
77
from tests.sentry.workflow_engine.test_base import BaseWorkflowTest
88

99

@@ -14,13 +14,12 @@ def setUp(self):
1414
self.detector,
1515
self.detector_workflow,
1616
self.workflow_triggers,
17-
) = self.create_detector_and_workflow()
17+
) = self.create_detector_and_workflow(detector_type=MetricAlertFire.slug)
1818

1919
self.error_workflow, self.error_detector, self.detector_workflow_error, _ = (
2020
self.create_detector_and_workflow(
2121
name_prefix="error",
2222
workflow_triggers=self.create_data_condition_group(),
23-
detector_type=DetectorType.ERROR,
2423
)
2524
)
2625

Diff for: tests/sentry/workflow_engine/test_base.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from uuid import uuid4
33

44
from sentry.eventstore.models import Event, GroupEvent
5+
from sentry.issues.grouptype import ErrorGroupType
56
from sentry.models.group import Group
67
from sentry.snuba.models import SnubaQuery
78
from sentry.testutils.cases import TestCase
@@ -14,7 +15,6 @@
1415
Workflow,
1516
)
1617
from sentry.workflow_engine.models.data_condition import Condition
17-
from sentry.workflow_engine.types import DetectorType
1818
from tests.sentry.issues.test_utils import OccurrenceTestMixin
1919

2020

@@ -66,7 +66,7 @@ def create_detector_and_workflow(
6666
self,
6767
name_prefix="test",
6868
workflow_triggers: DataConditionGroup | None = None,
69-
detector_type: DetectorType | str = "TestDetector",
69+
detector_type: str = ErrorGroupType.slug,
7070
**kwargs,
7171
) -> tuple[Workflow, Detector, DetectorWorkflow, DataConditionGroup]:
7272
workflow_triggers = workflow_triggers or self.create_data_condition_group()

0 commit comments

Comments
 (0)