Skip to content

Commit 343b5c1

Browse files
wedamijaandrewshie-sentry
authored andcommitted
feat(crons): Add CronMonitorDataSourceHandler (#97548)
Adding a new `DataSourceHandler` so that we can treat cron `Monitor` rows as a `DataSource` <!-- Describe your PR here. -->
1 parent 3e42f76 commit 343b5c1

File tree

3 files changed

+141
-2
lines changed

3 files changed

+141
-2
lines changed

src/sentry/monitors/models.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import zoneinfo
66
from collections.abc import Sequence
77
from datetime import datetime
8-
from typing import TYPE_CHECKING, Any, ClassVar, Self
8+
from typing import TYPE_CHECKING, Any, ClassVar, Self, override
99
from uuid import uuid4
1010

1111
import jsonschema
@@ -34,12 +34,17 @@
3434
from sentry.db.models.fields.slug import DEFAULT_SLUG_MAX_LENGTH, SentrySlugField
3535
from sentry.db.models.manager.base import BaseManager
3636
from sentry.db.models.utils import slugify_instance
37+
from sentry.deletions.base import ModelRelation
3738
from sentry.locks import locks
3839
from sentry.models.environment import Environment
40+
from sentry.models.organization import Organization
3941
from sentry.models.rule import Rule, RuleSource
40-
from sentry.monitors.types import CrontabSchedule, IntervalSchedule
42+
from sentry.monitors.types import DATA_SOURCE_CRON_MONITOR, CrontabSchedule, IntervalSchedule
4143
from sentry.types.actor import Actor
4244
from sentry.utils.retries import TimedRetryPolicy
45+
from sentry.workflow_engine.models import DataSource
46+
from sentry.workflow_engine.registry import data_source_type_registry
47+
from sentry.workflow_engine.types import DataSourceTypeHandler
4348

4449
logger = logging.getLogger(__name__)
4550

@@ -790,3 +795,41 @@ class MonitorEnvBrokenDetection(Model):
790795
class Meta:
791796
app_label = "monitors"
792797
db_table = "sentry_monitorenvbrokendetection"
798+
799+
800+
@data_source_type_registry.register(DATA_SOURCE_CRON_MONITOR)
801+
class CronMonitorDataSourceHandler(DataSourceTypeHandler[Monitor]):
802+
@staticmethod
803+
def bulk_get_query_object(
804+
data_sources: list[DataSource],
805+
) -> dict[int, Monitor | None]:
806+
monitor_ids: list[int] = []
807+
808+
for ds in data_sources:
809+
try:
810+
monitor_ids.append(int(ds.source_id))
811+
except ValueError:
812+
logger.exception(
813+
"Invalid DataSource.source_id fetching Monitor",
814+
extra={"id": ds.id, "source_id": ds.source_id},
815+
)
816+
817+
qs_lookup = {
818+
str(monitor.id): monitor for monitor in Monitor.objects.filter(id__in=monitor_ids)
819+
}
820+
return {ds.id: qs_lookup.get(ds.source_id) for ds in data_sources}
821+
822+
@staticmethod
823+
def related_model(instance) -> list[ModelRelation]:
824+
return [ModelRelation(Monitor, {"id": instance.source_id})]
825+
826+
@override
827+
@staticmethod
828+
def get_instance_limit(org: Organization) -> int | None:
829+
return None
830+
831+
@override
832+
@staticmethod
833+
def get_current_instance_count(org: Organization) -> int:
834+
# We don't have a limit at the moment, so no need to count.
835+
raise NotImplementedError

src/sentry/monitors/types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
from sentry.db.models.fields.slug import DEFAULT_SLUG_MAX_LENGTH
1212

13+
DATA_SOURCE_CRON_MONITOR = "cron_monitor"
14+
1315

1416
class CheckinTrace(TypedDict):
1517
trace_id: str

tests/sentry/monitors/test_models.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
from datetime import datetime, timezone
2+
from unittest import mock
23

34
import pytest
45
from django.conf import settings
56
from django.test.utils import override_settings
67

78
from sentry.monitors.models import (
9+
CronMonitorDataSourceHandler,
810
Monitor,
911
MonitorEnvironment,
1012
MonitorEnvironmentLimitsExceeded,
1113
MonitorLimitsExceeded,
1214
ScheduleType,
1315
)
16+
from sentry.monitors.types import DATA_SOURCE_CRON_MONITOR
1417
from sentry.monitors.validators import ConfigValidator
1518
from sentry.testutils.cases import TestCase
19+
from sentry.workflow_engine.models import DataSource
1620

1721

1822
class MonitorTestCase(TestCase):
@@ -270,3 +274,93 @@ def test_config_validator(self) -> None:
270274
validated_config["bad_key"] = 100
271275
monitor.config = validated_config
272276
assert monitor.get_validated_config() is None
277+
278+
279+
class CronMonitorDataSourceHandlerTest(TestCase):
280+
def setUp(self):
281+
super().setUp()
282+
self.monitor = self.create_monitor(
283+
project=self.project,
284+
name="Test Monitor",
285+
)
286+
287+
self.data_source = DataSource.objects.create(
288+
type=DATA_SOURCE_CRON_MONITOR,
289+
source_id=str(self.monitor.id),
290+
organization_id=self.organization.id,
291+
)
292+
293+
def test_bulk_get_query_object(self):
294+
result = CronMonitorDataSourceHandler.bulk_get_query_object([self.data_source])
295+
assert result[self.data_source.id] == self.monitor
296+
297+
def test_bulk_get_query_object__multiple_monitors(self):
298+
monitor2 = self.create_monitor(
299+
project=self.project,
300+
name="Test Monitor 2",
301+
)
302+
data_source2 = DataSource.objects.create(
303+
type=DATA_SOURCE_CRON_MONITOR,
304+
source_id=str(monitor2.id),
305+
organization_id=self.organization.id,
306+
)
307+
308+
data_sources = [self.data_source, data_source2]
309+
result = CronMonitorDataSourceHandler.bulk_get_query_object(data_sources)
310+
311+
assert result[self.data_source.id] == self.monitor
312+
assert result[data_source2.id] == monitor2
313+
314+
def test_bulk_get_query_object__incorrect_data_source(self):
315+
ds_with_invalid_monitor_id = DataSource.objects.create(
316+
type=DATA_SOURCE_CRON_MONITOR,
317+
source_id="not_an_int",
318+
organization_id=self.organization.id,
319+
)
320+
321+
with mock.patch("sentry.monitors.models.logger.exception") as mock_logger:
322+
data_sources = [self.data_source, ds_with_invalid_monitor_id]
323+
result = CronMonitorDataSourceHandler.bulk_get_query_object(data_sources)
324+
325+
assert result[self.data_source.id] == self.monitor
326+
assert result[ds_with_invalid_monitor_id.id] is None
327+
328+
mock_logger.assert_called_once_with(
329+
"Invalid DataSource.source_id fetching Monitor",
330+
extra={
331+
"id": ds_with_invalid_monitor_id.id,
332+
"source_id": ds_with_invalid_monitor_id.source_id,
333+
},
334+
)
335+
336+
def test_bulk_get_query_object__missing_monitor(self):
337+
ds_with_deleted_monitor = DataSource.objects.create(
338+
type=DATA_SOURCE_CRON_MONITOR,
339+
source_id="99999999",
340+
organization_id=self.organization.id,
341+
)
342+
343+
data_sources = [self.data_source, ds_with_deleted_monitor]
344+
result = CronMonitorDataSourceHandler.bulk_get_query_object(data_sources)
345+
346+
assert result[self.data_source.id] == self.monitor
347+
assert result[ds_with_deleted_monitor.id] is None
348+
349+
def test_bulk_get_query_object__empty_list(self):
350+
result = CronMonitorDataSourceHandler.bulk_get_query_object([])
351+
assert result == {}
352+
353+
def test_related_model(self):
354+
relations = CronMonitorDataSourceHandler.related_model(self.data_source)
355+
assert len(relations) == 1
356+
relation = relations[0]
357+
358+
assert relation.params["model"] == Monitor
359+
assert relation.params["query"] == {"id": self.data_source.source_id}
360+
361+
def test_get_instance_limit(self):
362+
assert CronMonitorDataSourceHandler.get_instance_limit(self.organization) is None
363+
364+
def test_get_current_instance_count(self):
365+
with pytest.raises(NotImplementedError):
366+
CronMonitorDataSourceHandler.get_current_instance_count(self.organization)

0 commit comments

Comments
 (0)