Skip to content

Commit 06fc0ef

Browse files
Revert "chore(crons): Move monitor update logic to be entirely inside the serializer (#97794)"
This reverts commit 5fd8341. Co-authored-by: wedamija <[email protected]>
1 parent 156d96b commit 06fc0ef

File tree

3 files changed

+130
-450
lines changed

3 files changed

+130
-450
lines changed

src/sentry/monitors/endpoints/base_monitor_details.py

Lines changed: 116 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,39 @@
11
from __future__ import annotations
22

33
from django.db import router, transaction
4-
from django.db.models import QuerySet
4+
from django.db.models import F, QuerySet
5+
from django.db.models.functions import TruncMinute
56
from django.utils.crypto import get_random_string
6-
from rest_framework import serializers
77
from rest_framework.request import Request
88
from rest_framework.response import Response
99

1010
from sentry import audit_log, quotas
1111
from sentry.api.base import BaseEndpointMixin
12+
from sentry.api.exceptions import ParameterValidationError
1213
from sentry.api.helpers.environments import get_environments
1314
from sentry.api.serializers import serialize
1415
from sentry.constants import ObjectStatus
1516
from sentry.deletions.models.scheduleddeletion import RegionScheduledDeletion
1617
from sentry.models.environment import Environment
1718
from sentry.models.project import Project
1819
from sentry.models.rule import Rule, RuleActivity, RuleActivityType
19-
from sentry.monitors.models import Monitor, MonitorEnvironment, MonitorStatus
20+
from sentry.monitors.models import (
21+
CheckInStatus,
22+
Monitor,
23+
MonitorCheckIn,
24+
MonitorEnvironment,
25+
MonitorStatus,
26+
)
2027
from sentry.monitors.serializers import MonitorSerializer
28+
from sentry.monitors.utils import (
29+
create_issue_alert_rule,
30+
get_checkin_margin,
31+
get_max_runtime,
32+
update_issue_alert_rule,
33+
)
2134
from sentry.monitors.validators import MonitorValidator
2235
from sentry.utils.auth import AuthenticatedHttpRequest
36+
from sentry.utils.outcomes import Outcome
2337

2438

2539
class MonitorDetailsMixin(BaseEndpointMixin):
@@ -43,26 +57,118 @@ def update_monitor(
4357
"""
4458
Update a monitor.
4559
"""
60+
# set existing values as validator will overwrite
61+
existing_config = monitor.config
62+
existing_margin = existing_config.get("checkin_margin")
63+
existing_max_runtime = existing_config.get("max_runtime")
64+
4665
validator = MonitorValidator(
4766
data=request.data,
4867
partial=True,
49-
instance=monitor,
68+
instance={
69+
"name": monitor.name,
70+
"slug": monitor.slug,
71+
"status": monitor.status,
72+
"config": monitor.config,
73+
"project": project,
74+
},
5075
context={
5176
"organization": project.organization,
5277
"access": request.access,
53-
"request": request,
5478
"monitor": monitor,
5579
},
5680
)
5781
if not validator.is_valid():
5882
return self.respond(validator.errors, status=400)
5983

60-
try:
61-
updated_monitor = validator.save()
62-
except serializers.ValidationError as e:
63-
return self.respond(e.detail, status=400)
84+
result = validator.save()
85+
86+
params = {}
87+
if "name" in result:
88+
params["name"] = result["name"]
89+
if "slug" in result:
90+
params["slug"] = result["slug"]
91+
if "status" in result:
92+
params["status"] = result["status"]
93+
if "is_muted" in result:
94+
params["is_muted"] = result["is_muted"]
95+
if "owner" in result:
96+
owner = result["owner"]
97+
params["owner_user_id"] = None
98+
params["owner_team_id"] = None
99+
if owner and owner.is_user:
100+
params["owner_user_id"] = owner.id
101+
elif owner and owner.is_team:
102+
params["owner_team_id"] = owner.id
103+
if "config" in result:
104+
params["config"] = result["config"]
105+
106+
# update timeouts + expected next check-in, as appropriate
107+
checkin_margin = result["config"].get("checkin_margin")
108+
if checkin_margin != existing_margin:
109+
MonitorEnvironment.objects.filter(monitor_id=monitor.id).update(
110+
next_checkin_latest=F("next_checkin") + get_checkin_margin(checkin_margin)
111+
)
112+
113+
max_runtime = result["config"].get("max_runtime")
114+
if max_runtime != existing_max_runtime:
115+
MonitorCheckIn.objects.filter(
116+
monitor_id=monitor.id, status=CheckInStatus.IN_PROGRESS
117+
).update(timeout_at=TruncMinute(F("date_added")) + get_max_runtime(max_runtime))
118+
119+
if "project" in result and result["project"].id != monitor.project_id:
120+
raise ParameterValidationError("existing monitors may not be moved between projects")
121+
122+
# Attempt to assign a monitor seat
123+
if params["status"] == ObjectStatus.ACTIVE and monitor.status != ObjectStatus.ACTIVE:
124+
outcome = quotas.backend.assign_monitor_seat(monitor)
125+
# The MonitorValidator checks if a seat assignment is available.
126+
# This protects against a race condition
127+
if outcome != Outcome.ACCEPTED:
128+
raise ParameterValidationError("Failed to enable monitor, please try again")
129+
130+
# Attempt to unassign the monitor seat
131+
if params["status"] == ObjectStatus.DISABLED and monitor.status != ObjectStatus.DISABLED:
132+
quotas.backend.disable_monitor_seat(monitor)
133+
134+
# Update monitor slug in billing
135+
if "slug" in result:
136+
quotas.backend.update_monitor_slug(monitor.slug, params["slug"], monitor.project_id)
137+
138+
if params:
139+
monitor.update(**params)
140+
self.create_audit_entry(
141+
request=request,
142+
organization=project.organization,
143+
target_object=monitor.id,
144+
event=audit_log.get_event_id("MONITOR_EDIT"),
145+
data=monitor.get_audit_log_data(),
146+
)
147+
148+
# Update alert rule after in case slug or name changed
149+
if "alert_rule" in result:
150+
# Check to see if rule exists
151+
issue_alert_rule = monitor.get_issue_alert_rule()
152+
# If rule exists, update as necessary
153+
if issue_alert_rule:
154+
issue_alert_rule_id = update_issue_alert_rule(
155+
request, project, monitor, issue_alert_rule, result["alert_rule"]
156+
)
157+
# If rule does not exist, create
158+
else:
159+
issue_alert_rule_id = create_issue_alert_rule(
160+
request, project, monitor, result["alert_rule"]
161+
)
162+
163+
if issue_alert_rule_id:
164+
# If config is not sent, use existing config to update alert_rule_id
165+
if "config" not in params:
166+
params["config"] = monitor.config
167+
168+
params["config"]["alert_rule_id"] = issue_alert_rule_id
169+
monitor.update(**params)
64170

65-
return self.respond(serialize(updated_monitor, request.user))
171+
return self.respond(serialize(monitor, request.user))
66172

67173
def delete_monitor(self, request: Request, project: Project, monitor: Monitor) -> Response:
68174
"""

src/sentry/monitors/validators.py

Lines changed: 14 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
import re
2-
from typing import Any, Literal
2+
from typing import Literal
33

44
import sentry_sdk
55
from cronsim import CronSimError
66
from django.core.exceptions import ValidationError
7-
from django.db.models import F
8-
from django.db.models.functions import TruncMinute
97
from django.utils import timezone
108
from drf_spectacular.types import OpenApiTypes
119
from drf_spectacular.utils import extend_schema_field, extend_schema_serializer
1210
from rest_framework import serializers
1311

14-
from sentry import audit_log, quotas
12+
from sentry import quotas
1513
from sentry.api.fields.actor import ActorField
1614
from sentry.api.fields.empty_integer import EmptyIntegerField
1715
from sentry.api.fields.sentry_slug import SentrySerializerSlugField
@@ -20,25 +18,11 @@
2018
from sentry.constants import ObjectStatus
2119
from sentry.db.models import BoundedPositiveIntegerField
2220
from sentry.db.models.fields.slug import DEFAULT_SLUG_MAX_LENGTH
23-
from sentry.models.project import Project
2421
from sentry.monitors.constants import MAX_MARGIN, MAX_THRESHOLD, MAX_TIMEOUT
25-
from sentry.monitors.models import (
26-
CheckInStatus,
27-
Monitor,
28-
MonitorCheckIn,
29-
MonitorEnvironment,
30-
ScheduleType,
31-
)
22+
from sentry.monitors.models import CheckInStatus, Monitor, ScheduleType
3223
from sentry.monitors.schedule import get_next_schedule, get_prev_schedule
3324
from sentry.monitors.types import CrontabSchedule, slugify_monitor_slug
34-
from sentry.monitors.utils import (
35-
create_issue_alert_rule,
36-
get_checkin_margin,
37-
get_max_runtime,
38-
signal_monitor_created,
39-
update_issue_alert_rule,
40-
)
41-
from sentry.utils.audit import create_audit_entry
25+
from sentry.monitors.utils import create_issue_alert_rule, signal_monitor_created
4226
from sentry.utils.dates import AVAILABLE_TIMEZONES
4327
from sentry.utils.outcomes import Outcome
4428

@@ -178,7 +162,7 @@ def bind(self, *args, **kwargs):
178162
super().bind(*args, **kwargs)
179163
# Inherit instance data when used as a nested serializer
180164
if self.parent.instance:
181-
self.instance = self.parent.instance.config
165+
self.instance = self.parent.instance.get("config")
182166
self.partial = self.parent.partial
183167

184168
def validate_schedule_type(self, value):
@@ -316,7 +300,7 @@ def validate_slug(self, value):
316300

317301
value = slugify_monitor_slug(value)
318302
# Ignore if slug is equal to current value
319-
if self.instance and value == self.instance.slug:
303+
if self.instance and value == self.instance.get("slug"):
320304
return value
321305

322306
if Monitor.objects.filter(
@@ -325,6 +309,14 @@ def validate_slug(self, value):
325309
raise ValidationError(f'The slug "{value}" is already in use.')
326310
return value
327311

312+
def update(self, instance, validated_data):
313+
config = instance.get("config", {})
314+
config.update(validated_data.get("config", {}))
315+
instance.update(validated_data)
316+
if "config" in instance or "config" in validated_data:
317+
instance["config"] = config
318+
return instance
319+
328320
def create(self, validated_data):
329321
project = validated_data.get("project", self.context.get("project"))
330322
organization = self.context["organization"]
@@ -369,118 +361,6 @@ def create(self, validated_data):
369361
monitor.update(config=config)
370362
return monitor
371363

372-
def update(self, instance, validated_data):
373-
"""
374-
Update an existing Monitor instance.
375-
"""
376-
if "project" in validated_data and validated_data["project"].id != instance.project_id:
377-
raise serializers.ValidationError(
378-
{"detail": {"message": "existing monitors may not be moved between projects"}}
379-
)
380-
381-
existing_config = instance.config.copy()
382-
existing_margin = existing_config.get("checkin_margin")
383-
existing_max_runtime = existing_config.get("max_runtime")
384-
existing_slug = instance.slug
385-
386-
params: dict[str, Any] = {}
387-
if "owner" in validated_data:
388-
owner = validated_data["owner"]
389-
params["owner_user_id"] = None
390-
params["owner_team_id"] = None
391-
if owner and owner.is_user:
392-
params["owner_user_id"] = owner.id
393-
elif owner and owner.is_team:
394-
params["owner_team_id"] = owner.id
395-
396-
if "name" in validated_data:
397-
params["name"] = validated_data["name"]
398-
if "slug" in validated_data:
399-
params["slug"] = validated_data["slug"]
400-
if "status" in validated_data:
401-
params["status"] = validated_data["status"]
402-
if "is_muted" in validated_data:
403-
params["is_muted"] = validated_data["is_muted"]
404-
if "config" in validated_data:
405-
params["config"] = validated_data["config"]
406-
407-
if "status" in params:
408-
# Attempt to assign a monitor seat
409-
if params["status"] == ObjectStatus.ACTIVE and instance.status != ObjectStatus.ACTIVE:
410-
outcome = quotas.backend.assign_monitor_seat(instance)
411-
# The MonitorValidator checks if a seat assignment is available.
412-
# This protects against a race condition
413-
if outcome != Outcome.ACCEPTED:
414-
raise serializers.ValidationError(
415-
{"status": "Failed to enable monitor due to quota limits"}
416-
)
417-
418-
# Attempt to unassign the monitor seat
419-
if (
420-
params["status"] == ObjectStatus.DISABLED
421-
and instance.status != ObjectStatus.DISABLED
422-
):
423-
quotas.backend.disable_monitor_seat(instance)
424-
425-
if params:
426-
instance.update(**params)
427-
create_audit_entry(
428-
request=self.context["request"],
429-
organization_id=instance.organization_id,
430-
target_object=instance.id,
431-
event=audit_log.get_event_id("MONITOR_EDIT"),
432-
data=instance.get_audit_log_data(),
433-
)
434-
435-
# Update monitor slug in billing
436-
if "slug" in params:
437-
quotas.backend.update_monitor_slug(existing_slug, params["slug"], instance.project_id)
438-
439-
if "config" in validated_data:
440-
new_config = validated_data["config"]
441-
checkin_margin = new_config.get("checkin_margin")
442-
if checkin_margin != existing_margin:
443-
MonitorEnvironment.objects.filter(monitor_id=instance.id).update(
444-
next_checkin_latest=F("next_checkin") + get_checkin_margin(checkin_margin)
445-
)
446-
447-
max_runtime = new_config.get("max_runtime")
448-
if max_runtime != existing_max_runtime:
449-
MonitorCheckIn.objects.filter(
450-
monitor_id=instance.id, status=CheckInStatus.IN_PROGRESS
451-
).update(timeout_at=TruncMinute(F("date_added")) + get_max_runtime(max_runtime))
452-
453-
# Update alert rule after in case slug or name changed
454-
if "alert_rule" in validated_data:
455-
alert_rule_data = validated_data["alert_rule"]
456-
request = self.context.get("request")
457-
if not request:
458-
return instance
459-
460-
project = Project.objects.get(id=instance.project_id)
461-
462-
# Check to see if rule exists
463-
issue_alert_rule = instance.get_issue_alert_rule()
464-
# If rule exists, update as necessary
465-
if issue_alert_rule:
466-
issue_alert_rule_id = update_issue_alert_rule(
467-
request, project, instance, issue_alert_rule, alert_rule_data
468-
)
469-
# If rule does not exist, create
470-
else:
471-
# Type assertion for mypy - create_issue_alert_rule expects AuthenticatedHttpRequest
472-
# but in tests we might have a regular Request object
473-
issue_alert_rule_id = create_issue_alert_rule(
474-
request, project, instance, alert_rule_data
475-
)
476-
477-
if issue_alert_rule_id:
478-
# If config is not sent, use existing config to update alert_rule_id
479-
instance.config["alert_rule_id"] = issue_alert_rule_id
480-
instance.update(config=instance.config)
481-
482-
return instance
483-
484364

485365
class TraceContextValidator(serializers.Serializer):
486366
trace_id = serializers.UUIDField(format="hex")

0 commit comments

Comments
 (0)