Skip to content
Merged
2 changes: 2 additions & 0 deletions src/sentry/seer/autofix/issue_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,14 @@
SeerAutomationSource.ISSUE_DETAILS: "issue_summary_fixability",
SeerAutomationSource.ALERT: "issue_summary_on_alert_fixability",
SeerAutomationSource.POST_PROCESS: "issue_summary_on_post_process_fixability",
SeerAutomationSource.NIGHT_SHIFT: "night_shift",
}

referrer_map = {
SeerAutomationSource.ISSUE_DETAILS: AutofixReferrer.ISSUE_SUMMARY_FIXABILITY,
SeerAutomationSource.ALERT: AutofixReferrer.ISSUE_SUMMARY_ALERT_FIXABILITY,
SeerAutomationSource.POST_PROCESS: AutofixReferrer.ISSUE_SUMMARY_POST_PROCESS_FIXABILITY,
SeerAutomationSource.NIGHT_SHIFT: AutofixReferrer.NIGHT_SHIFT,
}

STOPPING_POINT_HIERARCHY = {
Expand Down
5 changes: 4 additions & 1 deletion src/sentry/seer/endpoints/admin_night_shift_trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,14 @@ def post(self, request: Request) -> Response:
except (ValueError, TypeError):
return Response({"detail": "organization_id must be a valid integer"}, status=400)

run_night_shift_for_org.apply_async(args=[organization_id])
dry_run = bool(request.data.get("dry_run", False))

run_night_shift_for_org.apply_async(args=[organization_id], kwargs={"dry_run": dry_run})

return Response(
{
"success": True,
"organization_id": organization_id,
"dry_run": dry_run,
}
)
70 changes: 64 additions & 6 deletions src/sentry/tasks/seer/night_shift/cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,24 @@

from sentry import features, options
from sentry.constants import ObjectStatus
from sentry.models.group import Group
from sentry.models.organization import Organization, OrganizationStatus
from sentry.models.project import Project
from sentry.seer.autofix.constants import AutofixAutomationTuningSettings
from sentry.seer.autofix.constants import (
AutofixAutomationTuningSettings,
SeerAutomationSource,
)
from sentry.seer.autofix.issue_summary import (
_trigger_autofix_task,
auto_run_source_map,
referrer_map,
)
from sentry.seer.autofix.utils import bulk_read_preferences
from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunIssue
from sentry.seer.models.seer_api_models import SeerProjectPreference
from sentry.tasks.base import instrumented_task
from sentry.tasks.seer.night_shift.agentic_triage import agentic_triage_strategy
from sentry.tasks.seer.night_shift.models import TriageAction
from sentry.taskworker.namespaces import seer_tasks
from sentry.utils.iterators import chunked
from sentry.utils.query import RangeQuerySetWrapper
Expand Down Expand Up @@ -76,7 +87,7 @@ def schedule_night_shift() -> None:
namespace=seer_tasks,
processing_deadline_duration=5 * 60,
)
def run_night_shift_for_org(organization_id: int) -> None:
def run_night_shift_for_org(organization_id: int, dry_run: bool = False) -> None:
try:
organization = Organization.objects.get(
id=organization_id, status=OrganizationStatus.ACTIVE
Expand All @@ -94,7 +105,7 @@ def run_night_shift_for_org(organization_id: int) -> None:
start_time = time.monotonic()

try:
eligible_projects = _get_eligible_projects(organization)
eligible_projects, preferences = _get_eligible_projects(organization)
if not eligible_projects:
logger.info(
"night_shift.no_eligible_projects",
Expand Down Expand Up @@ -160,6 +171,7 @@ def run_night_shift_for_org(organization_id: int) -> None:
"run_id": run.id,
"num_eligible_projects": len(eligible_projects),
"num_candidates": len(candidates),
"dry_run": dry_run,
"candidates": [
{
"group_id": c.group.id,
Expand All @@ -170,6 +182,16 @@ def run_night_shift_for_org(organization_id: int) -> None:
},
)

autofix_triggered = 0
if not dry_run:
for c in candidates:
if c.action == TriageAction.AUTOFIX:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did we want to handle ROOT_CAUSE_ONLY or leave it out for now?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea spoke offline and going to ignore for now.

pref = preferences.get(c.group.project_id)
stopping_point = pref.automated_run_stopping_point if pref else None
if _trigger_autofix_for_candidate(c.group, organization, stopping_point):
autofix_triggered += 1
sentry_sdk.metrics.count("night_shift.autofix_triggered", autofix_triggered)


def _get_eligible_orgs_from_batch(
orgs: Sequence[Organization],
Expand All @@ -193,21 +215,57 @@ def _get_eligible_orgs_from_batch(
return eligible


def _get_eligible_projects(organization: Organization) -> list[Project]:
def _trigger_autofix_for_candidate(
group: Group, organization: Organization, stopping_point: str | None
) -> bool:
"""Trigger autofix for a single candidate identified as fixable by night shift triage.

Returns True if the autofix task was dispatched.
"""
try:
event = group.get_latest_event()
if not event:
logger.warning(
"night_shift.no_event_for_autofix",
extra={"group_id": group.id, "organization_id": organization.id},
)
return False

_trigger_autofix_task.delay(
group_id=group.id,
event_id=event.event_id,
user_id=None,
auto_run_source=auto_run_source_map[SeerAutomationSource.NIGHT_SHIFT],
referrer=referrer_map[SeerAutomationSource.NIGHT_SHIFT],
stopping_point=stopping_point,
)
return True
except Exception:
logger.exception(
"night_shift.autofix_trigger_failed",
extra={"group_id": group.id, "organization_id": organization.id},
)
return False

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing existing autofix run check before triggering

Medium Severity

_trigger_autofix_for_candidate dispatches _trigger_autofix_task without first checking whether an autofix run already exists for the group. The regular automation flow in run_automation calls get_autofix_state(group_id=..., organization_id=...) and returns early if a run exists. The PR description states "Per candidate, we check for an existing autofix run" but this guard is absent from the implementation. This can cause duplicate autofix runs for the same group, wasting resources and potentially creating conflicting state.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1b5add6. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's slight potential for a race condition but not a big deal right now. Our issues query checks that the field is null.



def _get_eligible_projects(
organization: Organization,
) -> tuple[list[Project], dict[int, SeerProjectPreference | None]]:
"""Return active projects that have automation enabled and connected repos."""
project_map = {
p.id: p
for p in Project.objects.filter(organization=organization, status=ObjectStatus.ACTIVE)
}
if not project_map:
return []
return [], {}

preferences = bulk_read_preferences(organization, list(project_map))

return [
projects = [
project_map[pid]
for pid, pref in preferences.items()
if pref is not None
and pref.repositories
and pref.autofix_automation_tuning != AutofixAutomationTuningSettings.OFF
]
return projects, preferences
16 changes: 14 additions & 2 deletions static/gsAdmin/views/seerAdminPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {PageHeader} from 'admin/components/pageHeader';

export function SeerAdminPage() {
const [organizationId, setOrganizationId] = useState<string>('');
const [dryRun, setDryRun] = useState<boolean>(false);
const regions = ConfigStore.get('regions');
const [region, setRegion] = useState<Region | null>(regions[0] ?? null);

Expand All @@ -24,12 +25,15 @@ export function SeerAdminPage() {
return fetchMutation({
url: '/internal/seer/night-shift/trigger/',
method: 'POST',
data: {organization_id: parseInt(organizationId, 10)},
data: {organization_id: parseInt(organizationId, 10), dry_run: dryRun},
options: {host: region?.url},
});
},
onSuccess: () => {
addSuccessMessage(`Night shift run triggered for organization ${organizationId}`);
const mode = dryRun ? ' (dry run)' : '';
addSuccessMessage(
`Night shift run triggered for organization ${organizationId}${mode}`
);
setOrganizationId('');
},
onError: () => {
Expand Down Expand Up @@ -96,6 +100,14 @@ export function SeerAdminPage() {
onChange={e => setOrganizationId(e.target.value)}
placeholder="Enter organization ID"
/>
<Flex as="label" gap="sm" align="center">
<input
type="checkbox"
checked={dryRun}
onChange={e => setDryRun(e.target.checked)}
/>
<Text>Dry run (triage only, no autofix triggered)</Text>
</Flex>
<Button
priority="primary"
type="submit"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ def test_trigger_night_shift(self) -> None:

assert response.data["success"] is True
assert response.data["organization_id"] == self.organization.id
mock_task.apply_async.assert_called_once_with(args=[self.organization.id])
mock_task.apply_async.assert_called_once_with(
args=[self.organization.id], kwargs={"dry_run": False}
)

def test_missing_organization_id(self) -> None:
response = self.get_response()
Expand Down
78 changes: 77 additions & 1 deletion tests/sentry/tasks/seer/test_night_shift.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ def test_filters_by_automation_and_repos(self) -> None:
self.create_project(organization=org)

with self.feature("organizations:seer-project-settings-read-from-sentry"):
assert _get_eligible_projects(org) == [eligible]
projects, preferences = _get_eligible_projects(org)
assert projects == [eligible]
assert eligible.id in preferences


@django_db_all
Expand Down Expand Up @@ -264,6 +266,80 @@ def test_triage_error_records_error_message(self) -> None:
assert run.error_message == "Night shift run failed"
assert not SeerNightShiftRunIssue.objects.filter(run=run).exists()

def test_triggers_autofix_for_fixable_candidates(self) -> None:
org = self.create_organization()
project = self.create_project(organization=org)
self._make_eligible(project)

group = self._store_event_and_update_group(
project, "fixable", seer_fixability_score=0.9, times_seen=5
)

fake_client = FakeExplorerClient([group.id], action="autofix")
with (
self.feature("organizations:seer-project-settings-read-from-sentry"),
patch(
"sentry.tasks.seer.night_shift.agentic_triage.SeerExplorerClient",
return_value=fake_client,
),
patch("sentry.tasks.seer.night_shift.cron._trigger_autofix_task") as mock_autofix_task,
):
run_night_shift_for_org(org.id)

mock_autofix_task.delay.assert_called_once()
call_kwargs = mock_autofix_task.delay.call_args.kwargs
assert call_kwargs["group_id"] == group.id
assert call_kwargs["user_id"] is None
assert call_kwargs["auto_run_source"] == "night_shift"

def test_dry_run_skips_autofix(self) -> None:
org = self.create_organization()
project = self.create_project(organization=org)
self._make_eligible(project)

group = self._store_event_and_update_group(
project, "fixable", seer_fixability_score=0.9, times_seen=5
)

fake_client = FakeExplorerClient([group.id], action="autofix")
with (
self.feature("organizations:seer-project-settings-read-from-sentry"),
patch(
"sentry.tasks.seer.night_shift.agentic_triage.SeerExplorerClient",
return_value=fake_client,
),
patch("sentry.tasks.seer.night_shift.cron._trigger_autofix_task") as mock_autofix_task,
):
run_night_shift_for_org(org.id, dry_run=True)

mock_autofix_task.delay.assert_not_called()

# Candidates should still be saved to DB
run = SeerNightShiftRun.objects.get(organization=org)
assert SeerNightShiftRunIssue.objects.filter(run=run).count() == 1

def test_skips_autofix_for_non_autofix_candidates(self) -> None:
org = self.create_organization()
project = self.create_project(organization=org)
self._make_eligible(project)

group = self._store_event_and_update_group(
project, "skip-me", seer_fixability_score=0.9, times_seen=5
)

fake_client = FakeExplorerClient([group.id], action="root_cause_only")
with (
self.feature("organizations:seer-project-settings-read-from-sentry"),
patch(
"sentry.tasks.seer.night_shift.agentic_triage.SeerExplorerClient",
return_value=fake_client,
),
patch("sentry.tasks.seer.night_shift.cron._trigger_autofix_task") as mock_autofix_task,
):
run_night_shift_for_org(org.id)

mock_autofix_task.delay.assert_not_called()

def test_empty_candidates_creates_run_with_no_issues(self) -> None:
org = self.create_organization()
project = self.create_project(organization=org)
Expand Down
Loading