Repository: getsentry/sentry
Pull request: feat(monitors): Adds MonitorEnvironment to check_monitor + update endpoint
Adds `MonitorEnvironment` to `check_monitor` and check in update endpoint.

Closes https://github.com/getsentry/sentry/issues/45022

Problem statement:
Crons: Update `check_monitor` task to dual write into monitor environments
The job needs to be updated to query on the monitor environments, both for missed checkins and for checkins that have run for too long.

It will need to dual write into both the monitor and monitor environment.

Hints from issue discussion:
Also make sure that we don't change environment on `PUT` update calls to a specific monitor

Commit message: Merge branch 'master' into rjo100/check-monitor-monitorenv

Patch to review:
diff --git a/src/sentry/monitors/consumers/check_in.py b/src/sentry/monitors/consumers/check_in.py
index fc4b1b211f2b0f..7aea582c559247 100644
--- a/src/sentry/monitors/consumers/check_in.py
+++ b/src/sentry/monitors/consumers/check_in.py
@@ -10,8 +10,14 @@
 from arroyo.types import Commit, Message, Partition
 from django.db import transaction
 
-from sentry.models import Project
-from sentry.monitors.models import CheckInStatus, Monitor, MonitorCheckIn, MonitorStatus
+from sentry.models import Environment, Project
+from sentry.monitors.models import (
+    CheckInStatus,
+    Monitor,
+    MonitorCheckIn,
+    MonitorEnvironment,
+    MonitorStatus,
+)
 from sentry.signals import first_cron_checkin_received, first_cron_monitor_created
 from sentry.utils import json
 from sentry.utils.dates import to_datetime
@@ -37,6 +43,15 @@ def process_message(message: Message[KafkaPayload]) -> None:
                 logger.debug("monitor does not exist: %s", params["monitor_id"])
                 return
 
+            environment_name = params.get("environment")
+            if not environment_name:
+                environment_name = "production"
+            environment = Environment.get_or_create(project=monitor.project, name=environment_name)
+
+            monitor_environment = MonitorEnvironment.objects.get(
+                monitor=monitor, environment=environment
+            )
+
             status = getattr(CheckInStatus, params["status"].upper())
             duration = (
                 int(params["duration"] * 1000) if params.get("duration") is not None else None
@@ -46,7 +61,7 @@ def process_message(message: Message[KafkaPayload]) -> None:
                 check_in = MonitorCheckIn.objects.select_for_update().get(
                     guid=params["check_in_id"],
                     project_id=project_id,
-                    monitor=monitor,
+                    monitor_environment=monitor_environment,
                 )
 
                 if duration is None:
@@ -64,6 +79,7 @@ def process_message(message: Message[KafkaPayload]) -> None:
                 check_in = MonitorCheckIn.objects.create(
                     project_id=project_id,
                     monitor=monitor,
+                    monitor_environment=monitor_environment,
                     guid=params["check_in_id"],
                     duration=duration,
                     status=status,
@@ -85,6 +101,7 @@ def process_message(message: Message[KafkaPayload]) -> None:
 
             if check_in.status == CheckInStatus.ERROR and monitor.status != MonitorStatus.DISABLED:
                 monitor.mark_failed(start_time)
+                monitor_environment.mark_failed(start_time)
                 return
 
             monitor_params = {
@@ -98,6 +115,9 @@ def process_message(message: Message[KafkaPayload]) -> None:
             Monitor.objects.filter(id=monitor.id).exclude(last_checkin__gt=start_time).update(
                 **monitor_params
             )
+            MonitorEnvironment.objects.filter(id=monitor_environment.id).exclude(
+                last_checkin__gt=start_time
+            ).update(**monitor_params)
     except Exception:
         # Skip this message and continue processing in the consumer.
         logger.exception("Failed to process check-in", exc_info=True)
diff --git a/src/sentry/monitors/endpoints/monitor_checkin_details.py b/src/sentry/monitors/endpoints/monitor_checkin_details.py
index 73a8003a6e0144..222ac2a2afc809 100644
--- a/src/sentry/monitors/endpoints/monitor_checkin_details.py
+++ b/src/sentry/monitors/endpoints/monitor_checkin_details.py
@@ -21,7 +21,7 @@
 from sentry.apidocs.parameters import GLOBAL_PARAMS, MONITOR_PARAMS
 from sentry.apidocs.utils import inline_sentry_response_serializer
 from sentry.models import ProjectKey
-from sentry.monitors.models import CheckInStatus, Monitor, MonitorStatus
+from sentry.monitors.models import CheckInStatus, Monitor, MonitorEnvironment, MonitorStatus
 
 from .base import MonitorCheckInEndpoint
 
@@ -115,10 +115,14 @@ def put(self, request: Request, project, monitor, checkin) -> Response:
             duration = int((current_datetime - checkin.date_added).total_seconds() * 1000)
             params["duration"] = duration
 
+        monitor_environment = checkin.monitor_environment
+
         with transaction.atomic():
             checkin.update(**params)
             if checkin.status == CheckInStatus.ERROR:
-                if not monitor.mark_failed(current_datetime):
+                monitor_failed = monitor.mark_failed(current_datetime)
+                monitor_environment.mark_failed(current_datetime)
+                if not monitor_failed:
                     return self.respond(serialize(checkin, request.user), status=208)
             else:
                 monitor_params = {
@@ -130,5 +134,8 @@ def put(self, request: Request, project, monitor, checkin) -> Response:
                 Monitor.objects.filter(id=monitor.id).exclude(
                     last_checkin__gt=current_datetime
                 ).update(**monitor_params)
+                MonitorEnvironment.objects.filter(id=monitor_environment.id).exclude(
+                    last_checkin__gt=checkin.date_added
+                ).update(**monitor_params)
 
         return self.respond(serialize(checkin, request.user))
diff --git a/tests/sentry/monitors/endpoints/test_monitor_checkin_details.py b/tests/sentry/monitors/endpoints/test_monitor_checkin_details.py
index ed706035b98a03..cf22741dd5a3fa 100644
--- a/tests/sentry/monitors/endpoints/test_monitor_checkin_details.py
+++ b/tests/sentry/monitors/endpoints/test_monitor_checkin_details.py
@@ -3,10 +3,12 @@
 from django.urls import reverse
 from django.utils import timezone
 
+from sentry.models import Environment
 from sentry.monitors.models import (
     CheckInStatus,
     Monitor,
     MonitorCheckIn,
+    MonitorEnvironment,
     MonitorStatus,
     MonitorType,
 )
@@ -46,11 +48,26 @@ def _create_monitor(self):
             date_added=timezone.now() - timedelta(minutes=1),
         )
 
+    def _create_monitor_environment(self, monitor):
+        environment = Environment.get_or_create(project=self.project, name="production")
+
+        monitorenvironment_defaults = {
+            "status": monitor.status,
+            "next_checkin": monitor.next_checkin,
+            "last_checkin": monitor.last_checkin,
+        }
+
+        return MonitorEnvironment.objects.get_or_create(
+            monitor=monitor, environment=environment, defaults=monitorenvironment_defaults
+        )[0]
+
     def test_noop_in_progress(self):
         for path_func in self._get_path_functions():
             monitor = self._create_monitor()
+            monitor_environment = self._create_monitor_environment(monitor)
             checkin = MonitorCheckIn.objects.create(
                 monitor=monitor,
+                monitor_environment=monitor_environment,
                 project_id=self.project.id,
                 date_added=monitor.date_added,
                 status=CheckInStatus.IN_PROGRESS,
@@ -67,8 +84,12 @@ def test_noop_in_progress(self):
     def test_passing(self):
         for path_func in self._get_path_functions():
             monitor = self._create_monitor()
+            monitor_environment = self._create_monitor_environment(monitor)
             checkin = MonitorCheckIn.objects.create(
-                monitor=monitor, project_id=self.project.id, date_added=monitor.date_added
+                monitor=monitor,
+                monitor_environment=monitor_environment,
+                project_id=self.project.id,
+                date_added=monitor.date_added,
             )
 
             path = path_func(monitor.guid, checkin.guid)
@@ -83,11 +104,20 @@ def test_passing(self):
             assert monitor.status == MonitorStatus.OK
             assert monitor.last_checkin > checkin.date_added
 
+            monitor_environment = MonitorEnvironment.objects.get(id=monitor_environment.id)
+            assert monitor_environment.next_checkin > checkin.date_added
+            assert monitor_environment.status == MonitorStatus.OK
+            assert monitor_environment.last_checkin > checkin.date_added
+
     def test_failing(self):
         for path_func in self._get_path_functions():
             monitor = self._create_monitor()
+            monitor_environment = self._create_monitor_environment(monitor)
             checkin = MonitorCheckIn.objects.create(
-                monitor=monitor, project_id=self.project.id, date_added=monitor.date_added
+                monitor=monitor,
+                monitor_environment=monitor_environment,
+                project_id=self.project.id,
+                date_added=monitor.date_added,
             )
 
             path = path_func(monitor.guid, checkin.guid)
@@ -102,23 +132,32 @@ def test_failing(self):
             assert monitor.status == MonitorStatus.ERROR
             assert monitor.last_checkin > checkin.date_added
 
+            monitor_environment = MonitorEnvironment.objects.get(id=monitor_environment.id)
+            assert monitor_environment.next_checkin > checkin.date_added
+            assert monitor_environment.status == MonitorStatus.ERROR
+            assert monitor_environment.last_checkin > checkin.date_added
+
     def test_latest_returns_last_unfinished(self):
         for path_func in self._get_path_functions():
             monitor = self._create_monitor()
+            monitor_environment = self._create_monitor_environment(monitor)
             checkin = MonitorCheckIn.objects.create(
                 monitor=monitor,
+                monitor_environment=monitor_environment,
                 project_id=self.project.id,
                 date_added=monitor.date_added - timedelta(minutes=2),
                 status=CheckInStatus.IN_PROGRESS,
             )
             checkin2 = MonitorCheckIn.objects.create(
                 monitor=monitor,
+                monitor_environment=monitor_environment,
                 project_id=self.project.id,
                 date_added=monitor.date_added - timedelta(minutes=1),
                 status=CheckInStatus.IN_PROGRESS,
             )
             checkin3 = MonitorCheckIn.objects.create(
                 monitor=monitor,
+                monitor_environment=monitor_environment,
                 project_id=self.project.id,
                 date_added=monitor.date_added,
                 status=CheckInStatus.OK,
@@ -142,11 +181,18 @@ def test_latest_returns_last_unfinished(self):
             assert monitor.status == MonitorStatus.OK
             assert monitor.last_checkin > checkin2.date_added
 
+            monitor_environment = MonitorEnvironment.objects.get(id=monitor_environment.id)
+            assert monitor_environment.next_checkin > checkin2.date_added
+            assert monitor_environment.status == MonitorStatus.OK
+            assert monitor_environment.last_checkin > checkin2.date_added
+
     def test_latest_with_no_unfinished_checkin(self):
         for path_func in self._get_path_functions():
             monitor = self._create_monitor()
+            monitor_environment = self._create_monitor_environment(monitor)
             MonitorCheckIn.objects.create(
                 monitor=monitor,
+                monitor_environment=monitor_environment,
                 project_id=self.project.id,
                 date_added=monitor.date_added,
                 status=CheckInStatus.OK,
@@ -159,8 +205,10 @@ def test_latest_with_no_unfinished_checkin(self):
     def test_invalid_checkin_id(self):
         for path_func in self._get_path_functions():
             monitor = self._create_monitor()
+            monitor_environment = self._create_monitor_environment(monitor)
             MonitorCheckIn.objects.create(
                 monitor=monitor,
+                monitor_environment=monitor_environment,
                 project_id=self.project.id,
                 date_added=monitor.date_added,
                 status=CheckInStatus.OK,


Code review comments:
diff_hunk: @@ -46,7 +61,7 @@ def process_message(message: Message[KafkaPayload]) -> None:
                 check_in = MonitorCheckIn.objects.select_for_update().get(
                     guid=params["check_in_id"],
                     project_id=project_id,
-                    monitor=monitor,
+                    monitor_environment=monitor_environment,
path: src/sentry/monitors/consumers/check_in.py
text: @author:
this will have to be reverted as the data will not have been backfilled yet