-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
feat(seer): Add deliver_feature_result RPC for Seer agent features #116734
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6cddc46
3130d25
2e44223
1c51a11
c07574c
129553a
0338b84
ccc50ca
0c0baaa
7dfbadf
e1d5fa8
1ef7231
0061638
e853a2c
7054125
bb03aea
33763d9
75de6cd
9bab14b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| """Registry for Seer feature result delivery handlers.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Any, Protocol | ||
|
|
||
| from sentry.seer.agent.types import FeatureRunStatus | ||
| from sentry.seer.night_shift.delivery import deliver_night_shift_result | ||
|
|
||
| __all__ = ["DELIVERY_HANDLERS", "FeatureDeliveryFn", "FeatureRunStatus"] | ||
|
|
||
|
|
||
| class FeatureDeliveryFn(Protocol): | ||
| def __call__( | ||
| self, | ||
| organization_id: int, | ||
| run_uuid: str, | ||
| status: FeatureRunStatus, | ||
| result: dict[str, Any] | None, | ||
| error: str | None, | ||
| ) -> None: ... | ||
|
|
||
|
|
||
| DELIVERY_HANDLERS: dict[str, FeatureDeliveryFn] = { | ||
| "night_shift": deliver_night_shift_result, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| """Shared types for Seer agent features.""" | ||
|
|
||
| from typing import Literal | ||
|
|
||
| FeatureRunStatus = Literal["completed", "error"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| """Delivery handler for night_shift feature results from Seer.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| from collections.abc import Mapping | ||
| from typing import Any | ||
|
|
||
| import sentry_sdk | ||
|
|
||
| from sentry.constants import SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT, ObjectStatus | ||
| from sentry.models.group import Group | ||
| from sentry.models.organization import Organization | ||
| from sentry.seer.agent.types import FeatureRunStatus | ||
| from sentry.seer.autofix.utils import AutofixStoppingPoint, bulk_read_preferences_from_sentry_db | ||
| from sentry.seer.models.night_shift import SeerNightShiftRun, SeerNightShiftRunResult | ||
| from sentry.seer.night_shift.models import TriageResponse | ||
| from sentry.tasks.seer.night_shift.models import TriageAction, TriageResult | ||
| from sentry.tasks.seer.night_shift.skip_cache import mark_skipped | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def deliver_night_shift_result( | ||
| organization_id: int, | ||
| run_uuid: str, | ||
| status: FeatureRunStatus, | ||
| result: dict[str, Any] | None, | ||
| error: str | None, | ||
| ) -> None: | ||
| """Process a night_shift result from Seer.""" | ||
| try: | ||
| run = SeerNightShiftRun.objects.select_related("organization", "seer_run").get( | ||
| organization_id=organization_id, | ||
| seer_run__uuid=run_uuid, | ||
| ) | ||
|
Comment on lines
+33
to
+36
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: The lookup in Suggested FixModify the query to not rely on the Prompt for AI Agent |
||
| except SeerNightShiftRun.DoesNotExist: | ||
| logger.warning( | ||
| "night_shift.delivery.missing_run", | ||
| extra={"organization_id": organization_id, "run_uuid": run_uuid}, | ||
| ) | ||
| return | ||
|
|
||
| if error: | ||
| run.update(extras={**(run.extras or {}), "error_message": error}) | ||
|
|
||
| log_extra: dict[str, object] = { | ||
| "organization_id": run.organization_id, | ||
| "run_id": run.id, | ||
| } | ||
|
|
||
| if status == "error" or result is None: | ||
| sentry_sdk.metrics.count( | ||
| "night_shift.triage_error", | ||
| 1, | ||
| attributes={"error_type": "delivery_error" if status == "error" else "no_artifact"}, | ||
| ) | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| logger.warning("night_shift.delivery.no_result", extra={**log_extra, "status": status}) | ||
| return | ||
|
|
||
| try: | ||
| triage_response = TriageResponse.parse_obj(result) | ||
|
sentry-warden[bot] marked this conversation as resolved.
|
||
| except Exception: | ||
| sentry_sdk.metrics.count( | ||
| "night_shift.triage_error", 1, attributes={"error_type": "invalid_artifact"} | ||
| ) | ||
| logger.exception("night_shift.delivery.invalid_result", extra=log_extra) | ||
| return | ||
|
|
||
| options = (run.extras or {}).get("options") or {} | ||
| dry_run = bool(options.get("dry_run", False)) | ||
|
|
||
| _process_verdicts( | ||
| run=run, | ||
| organization=run.organization, | ||
| triage_response=triage_response, | ||
| dry_run=dry_run, | ||
| log_extra=log_extra, | ||
| ) | ||
|
trevor-e marked this conversation as resolved.
|
||
|
|
||
|
|
||
| def _process_verdicts( | ||
| *, | ||
| run: SeerNightShiftRun, | ||
| organization: Organization, | ||
| triage_response: TriageResponse, | ||
| dry_run: bool, | ||
| log_extra: Mapping[str, object], | ||
| ) -> None: | ||
| """Mark SKIPs, fire autofix for fixable verdicts, persist result rows.""" | ||
| # Import here to avoid circular import | ||
| from sentry.tasks.seer.night_shift.cron import _run_autofix_for_candidates | ||
|
|
||
| group_ids = [v.group_id for v in triage_response.verdicts] | ||
| groups_by_id: dict[int, Group] = { | ||
| g.id: g | ||
| for g in Group.objects.filter( | ||
| id__in=group_ids, | ||
| project__organization_id=organization.id, | ||
| project__status=ObjectStatus.ACTIVE, | ||
| ).select_related("project") | ||
| } | ||
|
|
||
| unknown_group_ids = [gid for gid in group_ids if gid not in groups_by_id] | ||
| if unknown_group_ids: | ||
| logger.warning( | ||
| "night_shift.delivery.unknown_group_ids", | ||
| extra={**log_extra, "unknown_group_ids": unknown_group_ids}, | ||
| ) | ||
|
|
||
| for v in triage_response.verdicts: | ||
| if v.action == TriageAction.SKIP and v.group_id in groups_by_id: | ||
| mark_skipped(v.group_id) | ||
|
|
||
| # Convert verdicts to TriageResult objects for the shared function | ||
| fixable_candidates = [ | ||
| TriageResult(group=groups_by_id[v.group_id], action=v.action, reason=v.reason) | ||
| for v in triage_response.verdicts | ||
| if v.action in (TriageAction.AUTOFIX, TriageAction.ROOT_CAUSE_ONLY) | ||
| and v.group_id in groups_by_id | ||
| ] | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| sentry_sdk.metrics.distribution("night_shift.candidates_selected", len(fixable_candidates)) | ||
|
|
||
| results: list[SeerNightShiftRunResult] = [] | ||
| if not dry_run and fixable_candidates: | ||
| # Cache organization on each group's project to avoid N+1 queries | ||
| for group in groups_by_id.values(): | ||
| group.project.organization = organization | ||
|
|
||
| # Build stopping_point_by_project_id from project preferences (bulk query) | ||
| project_ids = {c.group.project_id for c in fixable_candidates} | ||
| preferences = bulk_read_preferences_from_sentry_db(organization.id, list(project_ids)) | ||
| default_stopping_point = AutofixStoppingPoint(SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT) | ||
| stopping_point_by_project_id = { | ||
|
sentry[bot] marked this conversation as resolved.
|
||
| pid: AutofixStoppingPoint( | ||
| pref.automated_run_stopping_point or SEER_AUTOMATED_RUN_STOPPING_POINT_DEFAULT | ||
| ) | ||
| if (pref := preferences.get(pid)) is not None | ||
| else default_stopping_point | ||
| for pid in project_ids | ||
| } | ||
|
sentry[bot] marked this conversation as resolved.
sentry[bot] marked this conversation as resolved.
|
||
|
|
||
| results = _run_autofix_for_candidates( | ||
| run=run, | ||
| candidates=fixable_candidates, | ||
| stopping_point_by_project_id=stopping_point_by_project_id, | ||
| log_extra=dict(log_extra), | ||
| ) | ||
|
|
||
| seer_run_id_by_group = {r.group_id: r.seer_run_id for r in results} | ||
| logger.info( | ||
| "night_shift.candidates_selected", | ||
| extra={ | ||
| **log_extra, | ||
| "num_verdicts": len(triage_response.verdicts), | ||
| "dry_run": dry_run, | ||
| "candidates": [ | ||
| { | ||
| "group_id": v.group_id, | ||
| "action": v.action, | ||
| "seer_run_id": seer_run_id_by_group.get(v.group_id), | ||
| } | ||
| for v in triage_response.verdicts | ||
| ], | ||
| }, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| """Wire types for the night_shift feature result payload from Seer.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from pydantic import BaseModel | ||
|
|
||
| from sentry.tasks.seer.night_shift.models import TriageAction | ||
|
|
||
|
|
||
| class _Base(BaseModel): | ||
| class Config: | ||
| extra = "ignore" | ||
|
|
||
|
|
||
| class TriageVerdict(_Base): | ||
| group_id: int | ||
| action: TriageAction | ||
| reason: str = "" | ||
|
|
||
|
|
||
| class TriageResponse(_Base): | ||
| verdicts: list[TriageVerdict] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Autofix runs inside Seer RPC
Medium Severity
deliver_feature_resultinvokes the night-shift handler synchronously in the Seer RPC request, and that handler can calltrigger_autofix_agentfor every fixable verdict. The prior cron path runs the same autofix loop inside a background task with a long processing deadline.Reviewed by Cursor Bugbot for commit 0c0baaa. Configure here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
our result handling is very lightweight and should work fine within the RPC