Skip to content

Commit 7ae3663

Browse files
trevor-eclaude
andauthored
feat(seer): Add Night Shift nightly autofix cron scaffolding (#112429)
Add the scaffolding for a nightly cron job ("Night Shift") that fans out per-org to trigger Seer Autofix on top issues. This decouples automation from the hot `post_process` pipeline, enabling better issue selection and serving lower-volume orgs. The scheduler iterates active orgs using batched feature flag checks (`batch_has_for_organizations`) to avoid N+1, applies deterministic jitter to spread load, and dispatches per-org worker tasks. The worker task is currently a stub that just logs — the issue selection and autofix triggering logic will be added in a follow-up once we've decided on the approach. **What's included:** - `schedule_night_shift()` — cron scheduler task (daily at 10:00 AM UTC / ~2-3 AM PT) - `run_night_shift_for_org()` — per-org worker task (stub for now) - Feature flag: `organizations:seer-night-shift` (flagpole, disabled by default) - Options: `seer.night_shift.enable` (global killswitch), `seer.night_shift.issues_per_org` - `NIGHT_SHIFT` referrer and automation source enums - Cron schedule entry in `TASKWORKER_REGION_SCHEDULES` **Not included (follow-ups):** - Issue selection logic in the worker task - Options-automator config (must deploy this PR first) Refs https://www.notion.so/sentry/Seer-Night-Shift-3338b10e4b5d807e80a6fbd6d70b3f60 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 62099ad commit 7ae3663

File tree

6 files changed

+216
-0
lines changed

6 files changed

+216
-0
lines changed

src/sentry/conf/server.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,7 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str:
991991
"sentry.tasks.seer.explorer_index",
992992
"sentry.tasks.seer.context_engine_index",
993993
"sentry.tasks.seer.lightweight_rca_cluster",
994+
"sentry.tasks.seer.night_shift",
994995
# Used for tests
995996
"sentry.taskworker.tasks.examples",
996997
)
@@ -1173,6 +1174,11 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str:
11731174
# Run once a month at midnight
11741175
"schedule": crontab("0", "0", "*", "1", "*"),
11751176
},
1177+
"seer-night-shift": {
1178+
"task": "seer:sentry.tasks.seer.night_shift.schedule_night_shift",
1179+
# Run daily at 10:00 AM UTC (2/3 AM Pacific)
1180+
"schedule": crontab("0", "10", "*", "*", "*"),
1181+
},
11761182
"refresh-artifact-bundles-in-use": {
11771183
"task": "attachments:sentry.debug_files.tasks.refresh_artifact_bundles_in_use",
11781184
"schedule": crontab("*/1", "*", "*", "*", "*"),

src/sentry/features/temporary.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,8 @@ def register_temporary_features(manager: FeatureManager) -> None:
300300
manager.add("organizations:seer-explorer", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
301301
# Enable Seer Explorer Index job
302302
manager.add("organizations:seer-explorer-index", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
303+
# Enable Seer Night Shift nightly autofix cron
304+
manager.add("organizations:seer-night-shift", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
303305
# Enable streaming responses for Seer Explorer
304306
manager.add("organizations:seer-explorer-streaming", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
305307
# Enable context engine for Seer Explorer

src/sentry/options/defaults.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1373,6 +1373,17 @@
13731373
default=0.0,
13741374
flags=FLAG_MODIFIABLE_RATE | FLAG_AUTOMATOR_MODIFIABLE,
13751375
)
1376+
register(
1377+
"seer.night_shift.enable",
1378+
type=Bool,
1379+
default=False,
1380+
flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE,
1381+
)
1382+
register(
1383+
"seer.night_shift.issues_per_org",
1384+
default=5,
1385+
flags=FLAG_AUTOMATOR_MODIFIABLE,
1386+
)
13761387

13771388
# Supergroups / Lightweight RCA
13781389
register(

src/sentry/seer/autofix/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,12 @@ class AutofixReferrer(enum.StrEnum):
5454
ISSUE_SUMMARY_POST_PROCESS_FIXABILITY = "issue_summary.post_process_fixability"
5555
SLACK = "slack"
5656
ON_COMPLETION_HOOK = "autofix.on_completion_hook"
57+
NIGHT_SHIFT = "night_shift"
5758
UNKNOWN = "unknown"
5859

5960

6061
class SeerAutomationSource(enum.Enum):
6162
ISSUE_DETAILS = "issue_details"
6263
ALERT = "alert"
6364
POST_PROCESS = "post_process"
65+
NIGHT_SHIFT = "night_shift"
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from collections.abc import Sequence
5+
from datetime import timedelta
6+
7+
import sentry_sdk
8+
9+
from sentry import features, options
10+
from sentry.models.organization import Organization, OrganizationStatus
11+
from sentry.tasks.base import instrumented_task
12+
from sentry.taskworker.namespaces import seer_tasks
13+
from sentry.utils.iterators import chunked
14+
from sentry.utils.query import RangeQuerySetWrapper
15+
16+
logger = logging.getLogger("sentry.tasks.seer.night_shift")
17+
18+
NIGHT_SHIFT_DISPATCH_STEP_SECONDS = 37
19+
NIGHT_SHIFT_SPREAD_DURATION = timedelta(hours=4)
20+
21+
FEATURE_NAMES = [
22+
"organizations:seer-night-shift",
23+
"organizations:gen-ai-features",
24+
]
25+
26+
27+
@instrumented_task(
28+
name="sentry.tasks.seer.night_shift.schedule_night_shift",
29+
namespace=seer_tasks,
30+
processing_deadline_duration=15 * 60,
31+
)
32+
def schedule_night_shift() -> None:
33+
"""
34+
Nightly scheduler: iterates active orgs in batches, checks feature flags
35+
in bulk, and dispatches per-org worker tasks with jitter.
36+
"""
37+
if not options.get("seer.night_shift.enable"):
38+
return
39+
40+
spread_seconds = int(NIGHT_SHIFT_SPREAD_DURATION.total_seconds())
41+
batch_index = 0
42+
43+
for org_batch in chunked(
44+
RangeQuerySetWrapper[Organization](
45+
Organization.objects.filter(status=OrganizationStatus.ACTIVE),
46+
step=1000,
47+
),
48+
100,
49+
):
50+
for org in _get_eligible_orgs_from_batch(org_batch):
51+
if bool(org.get_option("sentry:hide_ai_features")):
52+
continue
53+
54+
delay = (batch_index * NIGHT_SHIFT_DISPATCH_STEP_SECONDS) % spread_seconds
55+
56+
run_night_shift_for_org.apply_async(
57+
args=[org.id],
58+
countdown=delay,
59+
)
60+
batch_index += 1
61+
62+
logger.info(
63+
"night_shift.schedule_complete",
64+
extra={"orgs_dispatched": batch_index},
65+
)
66+
67+
68+
@instrumented_task(
69+
name="sentry.tasks.seer.night_shift.run_night_shift_for_org",
70+
namespace=seer_tasks,
71+
processing_deadline_duration=5 * 60,
72+
)
73+
def run_night_shift_for_org(organization_id: int) -> None:
74+
try:
75+
organization = Organization.objects.get(
76+
id=organization_id, status=OrganizationStatus.ACTIVE
77+
)
78+
except Organization.DoesNotExist:
79+
return
80+
81+
sentry_sdk.set_tags(
82+
{
83+
"organization_id": organization.id,
84+
"organization_slug": organization.slug,
85+
}
86+
)
87+
88+
logger.info(
89+
"night_shift.org_dispatched",
90+
extra={
91+
"organization_id": organization_id,
92+
"organization_slug": organization.slug,
93+
},
94+
)
95+
96+
97+
def _get_eligible_orgs_from_batch(
98+
orgs: Sequence[Organization],
99+
) -> list[Organization]:
100+
"""
101+
Check feature flags for a batch of orgs using batch_has_for_organizations.
102+
Returns orgs that have all required feature flags enabled.
103+
"""
104+
eligible = list(orgs)
105+
106+
for feature_name in FEATURE_NAMES:
107+
batch_result = features.batch_has_for_organizations(feature_name, eligible)
108+
if batch_result is None:
109+
raise RuntimeError(f"batch_has_for_organizations returned None for {feature_name}")
110+
111+
eligible = [org for org in eligible if batch_result.get(f"organization:{org.id}", False)]
112+
113+
if not eligible:
114+
return []
115+
116+
return eligible
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from unittest.mock import patch
2+
3+
from sentry.tasks.seer.night_shift import (
4+
run_night_shift_for_org,
5+
schedule_night_shift,
6+
)
7+
from sentry.testutils.cases import TestCase
8+
from sentry.testutils.pytest.fixtures import django_db_all
9+
10+
11+
@django_db_all
12+
class TestScheduleNightShift(TestCase):
13+
def test_disabled_by_option(self) -> None:
14+
with (
15+
self.options({"seer.night_shift.enable": False}),
16+
patch("sentry.tasks.seer.night_shift.run_night_shift_for_org") as mock_worker,
17+
):
18+
schedule_night_shift()
19+
mock_worker.apply_async.assert_not_called()
20+
21+
def test_dispatches_eligible_orgs(self) -> None:
22+
org = self.create_organization()
23+
24+
with (
25+
self.options({"seer.night_shift.enable": True}),
26+
self.feature(
27+
{
28+
"organizations:seer-night-shift": [org.slug],
29+
"organizations:gen-ai-features": [org.slug],
30+
}
31+
),
32+
patch("sentry.tasks.seer.night_shift.run_night_shift_for_org") as mock_worker,
33+
):
34+
schedule_night_shift()
35+
mock_worker.apply_async.assert_called_once()
36+
assert mock_worker.apply_async.call_args.kwargs["args"] == [org.id]
37+
38+
def test_skips_ineligible_orgs(self) -> None:
39+
self.create_organization()
40+
41+
with (
42+
self.options({"seer.night_shift.enable": True}),
43+
patch("sentry.tasks.seer.night_shift.run_night_shift_for_org") as mock_worker,
44+
):
45+
schedule_night_shift()
46+
mock_worker.apply_async.assert_not_called()
47+
48+
def test_skips_orgs_with_hidden_ai(self) -> None:
49+
org = self.create_organization()
50+
org.update_option("sentry:hide_ai_features", True)
51+
52+
with (
53+
self.options({"seer.night_shift.enable": True}),
54+
self.feature(
55+
{
56+
"organizations:seer-night-shift": [org.slug],
57+
"organizations:gen-ai-features": [org.slug],
58+
}
59+
),
60+
patch("sentry.tasks.seer.night_shift.run_night_shift_for_org") as mock_worker,
61+
):
62+
schedule_night_shift()
63+
mock_worker.apply_async.assert_not_called()
64+
65+
66+
@django_db_all
67+
class TestRunNightShiftForOrg(TestCase):
68+
def test_logs_for_valid_org(self) -> None:
69+
org = self.create_organization()
70+
71+
with patch("sentry.tasks.seer.night_shift.logger") as mock_logger:
72+
run_night_shift_for_org(org.id)
73+
mock_logger.info.assert_called_once()
74+
assert mock_logger.info.call_args.args[0] == "night_shift.org_dispatched"
75+
76+
def test_nonexistent_org(self) -> None:
77+
with patch("sentry.tasks.seer.night_shift.logger") as mock_logger:
78+
run_night_shift_for_org(999999999)
79+
mock_logger.info.assert_not_called()

0 commit comments

Comments
 (0)