Skip to content

Commit be91152

Browse files
Merge 35ab3a3 into 37b9207
2 parents 37b9207 + 35ab3a3 commit be91152

File tree

3 files changed

+341
-1
lines changed

3 files changed

+341
-1
lines changed

migrations_lockfile.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,6 @@ social_auth: 0003_social_auth_json_field
3939

4040
tempest: 0003_use_encrypted_char_field
4141

42-
uptime: 0054_delete_bad_assertions
42+
uptime: 0055_backfill_2xx_status_assertion
4343

4444
workflow_engine: 0110_owner_team_break_fk
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Generated by Django 5.2.11 on 2026-02-20 10:53
2+
import copy
3+
import logging
4+
from typing import Any
5+
6+
from django.db import migrations
7+
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
8+
from django.db.migrations.state import StateApps
9+
10+
from sentry.new_migrations.migrations import CheckedMigration
11+
from sentry.utils.query import RangeQuerySetWrapperWithProgressBar
12+
13+
logger = logging.getLogger(__name__)
14+
15+
DEFAULT_STATUS_CODE_CHECKS = [
16+
{"op": "status_code_check", "operator": {"cmp": "greater_than"}, "value": 199},
17+
{"op": "status_code_check", "operator": {"cmp": "less_than"}, "value": 300},
18+
]
19+
20+
21+
def _has_any_status_code_check(node: dict[str, Any]) -> bool:
22+
op = node.get("op")
23+
if op == "status_code_check":
24+
return True
25+
elif op in ("and", "or"):
26+
return any(_has_any_status_code_check(child) for child in node.get("children", []))
27+
elif op == "not":
28+
operand = node.get("operand")
29+
return _has_any_status_code_check(operand) if operand else False
30+
return False
31+
32+
33+
def ensure_2xx_status_checks(
34+
assertion: dict[str, Any] | None,
35+
) -> tuple[dict[str, Any], bool]:
36+
"""
37+
Ensure the assertion contains at least one status_code_check. If none exists,
38+
add the default >199 and <300 pair. Returns (assertion, was_modified).
39+
"""
40+
if not assertion or not assertion.get("root"):
41+
return {"root": {"op": "and", "children": copy.deepcopy(DEFAULT_STATUS_CODE_CHECKS)}}, True
42+
43+
root = assertion["root"]
44+
if _has_any_status_code_check(root):
45+
return assertion, False
46+
47+
# No status code check found — add the pair
48+
if root.get("op") == "and":
49+
new_children = list(root.get("children", [])) + copy.deepcopy(DEFAULT_STATUS_CODE_CHECKS)
50+
return {"root": {**root, "children": new_children}}, True
51+
else:
52+
# Root is not an "and" group — wrap it
53+
return {
54+
"root": {"op": "and", "children": [root, *copy.deepcopy(DEFAULT_STATUS_CODE_CHECKS)]}
55+
}, True
56+
57+
58+
def backfill_2xx_status_assertion(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None:
59+
UptimeSubscription = apps.get_model("uptime", "UptimeSubscription")
60+
61+
subscriptions = UptimeSubscription.objects.all()
62+
63+
for subscription in RangeQuerySetWrapperWithProgressBar(subscriptions):
64+
new_assertion, was_modified = ensure_2xx_status_checks(subscription.assertion)
65+
if was_modified:
66+
subscription.assertion = new_assertion
67+
subscription.save(update_fields=["assertion"])
68+
69+
70+
class Migration(CheckedMigration):
71+
# This flag is used to mark that a migration shouldn't be automatically run in production.
72+
# This should only be used for operations where it's safe to run the migration after your
73+
# code has deployed. So this should not be used for most operations that alter the schema
74+
# of a table.
75+
# Here are some things that make sense to mark as post deployment:
76+
# - Large data migrations. Typically we want these to be run manually so that they can be
77+
# monitored and not block the deploy for a long period of time while they run.
78+
# - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
79+
# run this outside deployments so that we don't block them. Note that while adding an index
80+
# is a schema change, it's completely safe to run the operation after the code has deployed.
81+
# Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment
82+
83+
is_post_deployment = True
84+
85+
dependencies = [
86+
("uptime", "0054_delete_bad_assertions"),
87+
]
88+
89+
operations = [
90+
migrations.RunPython(
91+
backfill_2xx_status_assertion,
92+
migrations.RunPython.noop,
93+
hints={"tables": ["uptime_uptimesubscription"]},
94+
)
95+
]
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import importlib
2+
import uuid
3+
4+
import pytest
5+
6+
from sentry.testutils.cases import TestMigrations
7+
8+
migration = importlib.import_module("sentry.uptime.migrations.0055_backfill_2xx_status_assertion")
9+
ensure_2xx_status_checks = migration.ensure_2xx_status_checks
10+
11+
GT_199 = {"op": "status_code_check", "operator": {"cmp": "greater_than"}, "value": 199}
12+
LT_300 = {"op": "status_code_check", "operator": {"cmp": "less_than"}, "value": 300}
13+
14+
15+
class TestEnsure2xxStatusChecks:
16+
def test_none_assertion(self) -> None:
17+
result, was_modified = ensure_2xx_status_checks(None)
18+
assert was_modified
19+
assert result == {"root": {"op": "and", "children": [GT_199, LT_300]}}
20+
21+
def test_empty_dict(self) -> None:
22+
result, was_modified = ensure_2xx_status_checks({})
23+
assert was_modified
24+
assert result == {"root": {"op": "and", "children": [GT_199, LT_300]}}
25+
26+
def test_already_has_status_code_check_at_root(self) -> None:
27+
assertion = {
28+
"root": {"op": "status_code_check", "operator": {"cmp": "equals"}, "value": 200}
29+
}
30+
result, was_modified = ensure_2xx_status_checks(assertion)
31+
assert not was_modified
32+
assert result == assertion
33+
34+
def test_already_has_status_code_check_nested_in_and(self) -> None:
35+
assertion = {
36+
"root": {
37+
"op": "and",
38+
"children": [
39+
{"op": "status_code_check", "operator": {"cmp": "equals"}, "value": 200},
40+
{
41+
"op": "json_path",
42+
"operator": {"cmp": "equals"},
43+
"value": "$.status",
44+
"operand": {"jsonpath_op": "literal", "value": "ok"},
45+
},
46+
],
47+
}
48+
}
49+
result, was_modified = ensure_2xx_status_checks(assertion)
50+
assert not was_modified
51+
assert result == assertion
52+
53+
def test_already_has_status_code_check_nested_in_or(self) -> None:
54+
assertion = {
55+
"root": {
56+
"op": "or",
57+
"children": [
58+
{"op": "status_code_check", "operator": {"cmp": "equals"}, "value": 200},
59+
{"op": "status_code_check", "operator": {"cmp": "equals"}, "value": 204},
60+
],
61+
}
62+
}
63+
result, was_modified = ensure_2xx_status_checks(assertion)
64+
assert not was_modified
65+
assert result == assertion
66+
67+
def test_already_has_status_code_check_inside_not(self) -> None:
68+
assertion = {
69+
"root": {
70+
"op": "not",
71+
"operand": {"op": "status_code_check", "operator": {"cmp": "equals"}, "value": 404},
72+
}
73+
}
74+
result, was_modified = ensure_2xx_status_checks(assertion)
75+
assert not was_modified
76+
assert result == assertion
77+
78+
def test_already_has_status_code_check_deeply_nested(self) -> None:
79+
assertion = {
80+
"root": {
81+
"op": "and",
82+
"children": [
83+
{
84+
"op": "or",
85+
"children": [
86+
{
87+
"op": "status_code_check",
88+
"operator": {"cmp": "equals"},
89+
"value": 200,
90+
},
91+
{
92+
"op": "status_code_check",
93+
"operator": {"cmp": "equals"},
94+
"value": 204,
95+
},
96+
],
97+
}
98+
],
99+
}
100+
}
101+
result, was_modified = ensure_2xx_status_checks(assertion)
102+
assert not was_modified
103+
assert result == assertion
104+
105+
def test_no_status_code_check_and_root(self) -> None:
106+
json_path_check = {
107+
"op": "json_path",
108+
"operator": {"cmp": "equals"},
109+
"value": "$.status",
110+
"operand": {"jsonpath_op": "literal", "value": "ok"},
111+
}
112+
assertion = {"root": {"op": "and", "children": [json_path_check]}}
113+
result, was_modified = ensure_2xx_status_checks(assertion)
114+
assert was_modified
115+
assert result == {"root": {"op": "and", "children": [json_path_check, GT_199, LT_300]}}
116+
117+
def test_no_status_code_check_non_and_root(self) -> None:
118+
json_path_check = {
119+
"op": "json_path",
120+
"operator": {"cmp": "equals"},
121+
"value": "$.status",
122+
"operand": {"jsonpath_op": "literal", "value": "ok"},
123+
}
124+
assertion = {"root": json_path_check}
125+
result, was_modified = ensure_2xx_status_checks(assertion)
126+
assert was_modified
127+
assert result == {"root": {"op": "and", "children": [json_path_check, GT_199, LT_300]}}
128+
129+
130+
@pytest.mark.skip(reason="TestMigrations is slow; run explicitly")
131+
class Backfill2xxStatusAssertionMigrationTest(TestMigrations):
132+
migrate_from = "0054_delete_bad_assertions"
133+
migrate_to = "0055_backfill_2xx_status_assertion"
134+
app = "uptime"
135+
136+
def setup_initial_state(self) -> None:
137+
self.organization = self.create_organization(name="test-org")
138+
self.project = self.create_project(organization=self.organization)
139+
140+
self.null_assertion_sub = self.create_uptime_subscription(
141+
subscription_id=uuid.uuid4().hex,
142+
interval_seconds=300,
143+
region_slugs=["default"],
144+
)
145+
146+
self.existing_status_code_sub = self.create_uptime_subscription(
147+
subscription_id=uuid.uuid4().hex,
148+
interval_seconds=300,
149+
region_slugs=["default"],
150+
assertion={
151+
"root": {
152+
"op": "and",
153+
"children": [
154+
{"op": "status_code_check", "operator": {"cmp": "equals"}, "value": 200}
155+
],
156+
}
157+
},
158+
)
159+
160+
self.json_path_only_sub = self.create_uptime_subscription(
161+
subscription_id=uuid.uuid4().hex,
162+
interval_seconds=300,
163+
region_slugs=["default"],
164+
assertion={
165+
"root": {
166+
"op": "and",
167+
"children": [
168+
{
169+
"op": "json_path",
170+
"operator": {"cmp": "equals"},
171+
"value": "$.status",
172+
"operand": {"jsonpath_op": "literal", "value": "ok"},
173+
}
174+
],
175+
}
176+
},
177+
)
178+
179+
self.non_and_root_sub = self.create_uptime_subscription(
180+
subscription_id=uuid.uuid4().hex,
181+
interval_seconds=300,
182+
region_slugs=["default"],
183+
assertion={
184+
"root": {
185+
"op": "json_path",
186+
"operator": {"cmp": "equals"},
187+
"value": "$.status",
188+
"operand": {"jsonpath_op": "literal", "value": "ok"},
189+
}
190+
},
191+
)
192+
193+
def test_migration(self) -> None:
194+
# null assertion gets the default >199 AND <300 pair
195+
self.null_assertion_sub.refresh_from_db()
196+
assert self.null_assertion_sub.assertion == {
197+
"root": {"op": "and", "children": [GT_199, LT_300]}
198+
}
199+
200+
# existing status_code_check is left untouched
201+
self.existing_status_code_sub.refresh_from_db()
202+
assert self.existing_status_code_sub.assertion == {
203+
"root": {
204+
"op": "and",
205+
"children": [
206+
{"op": "status_code_check", "operator": {"cmp": "equals"}, "value": 200}
207+
],
208+
}
209+
}
210+
211+
# json_path only — pair appended, existing child preserved
212+
self.json_path_only_sub.refresh_from_db()
213+
assert self.json_path_only_sub.assertion == {
214+
"root": {
215+
"op": "and",
216+
"children": [
217+
{
218+
"op": "json_path",
219+
"operator": {"cmp": "equals"},
220+
"value": "$.status",
221+
"operand": {"jsonpath_op": "literal", "value": "ok"},
222+
},
223+
GT_199,
224+
LT_300,
225+
],
226+
}
227+
}
228+
229+
# non-and root — wrapped in a new and group
230+
self.non_and_root_sub.refresh_from_db()
231+
assert self.non_and_root_sub.assertion == {
232+
"root": {
233+
"op": "and",
234+
"children": [
235+
{
236+
"op": "json_path",
237+
"operator": {"cmp": "equals"},
238+
"value": "$.status",
239+
"operand": {"jsonpath_op": "literal", "value": "ok"},
240+
},
241+
GT_199,
242+
LT_300,
243+
],
244+
}
245+
}

0 commit comments

Comments
 (0)