Skip to content

chore(seer): Use seer quotas endpoints #91937

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

Merged
merged 4 commits into from
May 22, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/sentry/api/endpoints/group_ai_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.api.bases.group import GroupAiEndpoint
from sentry.autofix.utils import SeerAutomationSource
from sentry.models.group import Group
from sentry.seer.issue_summary import get_issue_summary
from sentry.types.ratelimit import RateLimit, RateLimitCategory
Expand Down Expand Up @@ -37,7 +38,10 @@ def post(self, request: Request, group: Group) -> Response:
force_event_id = data.get("event_id", None)

summary_data, status_code = get_issue_summary(
group=group, user=request.user, force_event_id=force_event_id, source="issue_details"
group=group,
user=request.user,
force_event_id=force_event_id,
source=SeerAutomationSource.ISSUE_DETAILS,
Comment on lines +41 to +44
Copy link
Member Author

Choose a reason for hiding this comment

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

switching to an enum for cleanliness

)

return Response(summary_data, status=status_code)
11 changes: 10 additions & 1 deletion src/sentry/api/endpoints/group_autofix_setup_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
from django.conf import settings
from rest_framework.response import Response

from sentry import quotas
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.api.bases.group import GroupAiEndpoint
from sentry.autofix.utils import get_autofix_repos_from_project_code_mappings
from sentry.constants import ObjectStatus
from sentry.constants import DataCategory, ObjectStatus
from sentry.integrations.services.integration import integration_service
from sentry.models.group import Group
from sentry.models.organization import Organization
Expand Down Expand Up @@ -123,6 +124,11 @@ def get(self, request: Request, group: Group) -> Response:
if not user_acknowledgement: # If the user has acknowledged, the org must have too.
org_acknowledgement = get_seer_org_acknowledgement(org_id=org.id)

# TODO return BOTH trial status and autofix quota
has_autofix_quota: bool = quotas.backend.has_available_reserved_budget(
org_id=org.id, data_category=DataCategory.SEER_AUTOFIX
)

return Response(
{
"integration": {
Expand All @@ -134,5 +140,8 @@ def get(self, request: Request, group: Group) -> Response:
"orgHasAcknowledged": org_acknowledgement,
"userHasAcknowledged": user_acknowledgement,
},
"billing": {
"hasAutofixQuota": has_autofix_quota,
},
}
)
Copy link
Member Author

Choose a reason for hiding this comment

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

The plan is to return billing and trial information in the setup check so we can display different things on the UI

6 changes: 6 additions & 0 deletions src/sentry/autofix/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,9 @@ def get_autofix_state_from_pr_id(provider: str, pr_id: int) -> AutofixState | No
return None

return AutofixState.validate(result.get("state", None))


class SeerAutomationSource(enum.Enum):
ISSUE_DETAILS = "issue_details"
ALERT = "alert"
POST_PROCESS = "post_process"
5 changes: 4 additions & 1 deletion src/sentry/integrations/utils/issue_summary_for_alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import sentry_sdk

from sentry import features, options
from sentry.autofix.utils import SeerAutomationSource
from sentry.issues.grouptype import GroupCategory
from sentry.models.group import Group
from sentry.seer.issue_summary import get_issue_summary
Expand Down Expand Up @@ -32,7 +33,9 @@ def fetch_issue_summary(group: Group) -> dict[str, Any] | None:
try:
with sentry_sdk.start_span(op="ai_summary.fetch_issue_summary_for_alert"):
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(get_issue_summary, group, source="alert")
future = executor.submit(
get_issue_summary, group, source=SeerAutomationSource.ALERT
)
summary_result, status_code = future.result(timeout=timeout)

if status_code == 200:
Expand Down
17 changes: 15 additions & 2 deletions src/sentry/seer/autofix.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
from django.utils import timezone
from rest_framework.response import Response

from sentry import eventstore, features
from sentry import eventstore, features, quotas
from sentry.api.serializers import EventSerializer, serialize
from sentry.autofix.utils import get_autofix_repos_from_project_code_mappings
from sentry.constants import ObjectStatus
from sentry.constants import DataCategory, ObjectStatus
from sentry.eventstore.models import Event, GroupEvent
from sentry.models.group import Group
from sentry.models.project import Project
Expand Down Expand Up @@ -747,6 +747,14 @@ def trigger_autofix(
403,
)

# check billing quota for autofix
has_budget: bool = quotas.backend.has_available_reserved_budget(
org_id=group.organization.id,
data_category=DataCategory.SEER_AUTOFIX,
)
if not has_budget:
return _respond_with_error("No budget for Seer Autofix.", 402)

if event_id is None:
event: Event | GroupEvent | None = group.get_recommended_event_for_environments()
if not event:
Expand Down Expand Up @@ -808,6 +816,11 @@ def trigger_autofix(

group.update(seer_autofix_last_triggered=timezone.now())

# log billing event for seer autofix
quotas.backend.record_seer_run(
Copy link
Member

Choose a reason for hiding this comment

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

So, if we set it up like this does it mean that we count a seer run for each time you press start on autofix, including:

  1. Hit start on a fresh issue without an autofix
  2. Hit start over then start on an issue with an autofix
  3. Automation that calls this method
    but not if say, you do a rethink from the very beginning of an Autofix?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep that's the intention

group.organization.id, group.project.id, DataCategory.SEER_AUTOFIX
)

return Response(
{
"run_id": run_id,
Expand Down
40 changes: 32 additions & 8 deletions src/sentry/seer/issue_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
from django.conf import settings
from django.contrib.auth.models import AnonymousUser

from sentry import eventstore, features
from sentry import eventstore, features, quotas
from sentry.api.serializers import EventSerializer, serialize
from sentry.api.serializers.rest_framework.base import convert_dict_key_case, snake_to_camel_case
from sentry.autofix.utils import get_autofix_state
from sentry.constants import ObjectStatus
from sentry.autofix.utils import SeerAutomationSource, get_autofix_state
from sentry.constants import DataCategory, ObjectStatus
from sentry.eventstore.models import Event, GroupEvent
from sentry.locks import locks
from sentry.models.group import Group
Expand Down Expand Up @@ -254,7 +254,7 @@ def _run_automation(
group: Group,
user: User | RpcUser | AnonymousUser,
event: GroupEvent,
source: str,
source: SeerAutomationSource,
):
if features.has(
"organizations:trigger-autofix-on-issue-summary", group.organization, actor=user
Expand Down Expand Up @@ -282,8 +282,9 @@ def _run_automation(
not autofix_state
): # Only trigger autofix if we don't have an autofix on this issue already.
auto_run_source_map = {
"issue_details": "issue_summary_fixability",
"alert": "issue_summary_on_alert_fixability",
SeerAutomationSource.ISSUE_DETAILS: "issue_summary_fixability",
SeerAutomationSource.ALERT: "issue_summary_on_alert_fixability",
SeerAutomationSource.POST_PROCESS: "issue_summary_on_post_process_fixability",
}
_trigger_autofix_task.delay(
group_id=group.id,
Expand All @@ -297,7 +298,7 @@ def _generate_summary(
group: Group,
user: User | RpcUser | AnonymousUser,
force_event_id: str | None,
source: str,
source: SeerAutomationSource,
cache_key: str,
) -> tuple[dict[str, Any], int]:
"""Core logic to generate and cache the issue summary."""
Expand Down Expand Up @@ -335,11 +336,29 @@ def _generate_summary(
return summary_dict, 200


def _log_seer_scanner_billing_event(group: Group, source: SeerAutomationSource):
if source == SeerAutomationSource.ISSUE_DETAILS:
return

quotas.backend.record_seer_run(
group.organization.id, group.project.id, DataCategory.SEER_SCANNER
)


def _has_seer_scanner_budget(group: Group, source: SeerAutomationSource) -> bool:
if source == SeerAutomationSource.ISSUE_DETAILS:
return True

return quotas.backend.has_available_reserved_budget(
org_id=group.organization.id, data_category=DataCategory.SEER_SCANNER
)


def get_issue_summary(
group: Group,
user: User | RpcUser | AnonymousUser | None = None,
force_event_id: str | None = None,
source: str = "issue_details",
source: SeerAutomationSource = SeerAutomationSource.ISSUE_DETAILS,
) -> tuple[dict[str, Any], int]:
"""
Generate an AI summary for an issue.
Expand All @@ -361,6 +380,9 @@ def get_issue_summary(
if not get_seer_org_acknowledgement(group.organization.id):
return {"detail": "AI Autofix has not been acknowledged by the organization."}, 403

if not _has_seer_scanner_budget(group, source):
return {"detail": "No budget for Seer Scanner."}, 402

cache_key = f"ai-group-summary-v2:{group.id}"
lock_key = f"ai-group-summary-v2-lock:{group.id}"
lock_duration = 10 # How long the lock is held if acquired (seconds)
Expand All @@ -371,6 +393,7 @@ def get_issue_summary(
summary_dict, status_code = _generate_summary(
group, user, force_event_id, source, cache_key
)
_log_seer_scanner_billing_event(group, source)
return convert_dict_key_case(summary_dict, snake_to_camel_case), status_code

# 1. Check cache first
Expand All @@ -392,6 +415,7 @@ def get_issue_summary(
summary_dict, status_code = _generate_summary(
group, user, force_event_id, source, cache_key
)
_log_seer_scanner_billing_event(group, source)
return convert_dict_key_case(summary_dict, snake_to_camel_case), status_code

except UnableToAcquireLock:
Expand Down
6 changes: 4 additions & 2 deletions src/sentry/tasks/autofix.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging
from datetime import datetime, timedelta

from sentry.autofix.utils import AutofixStatus, get_autofix_state
from sentry.autofix.utils import AutofixStatus, SeerAutomationSource, get_autofix_state
from sentry.models.group import Group
from sentry.tasks.base import instrumented_task
from sentry.taskworker.config import TaskworkerConfig
Expand Down Expand Up @@ -38,6 +38,8 @@ def check_autofix_status(run_id: int):
@instrumented_task(
name="sentry.tasks.autofix.start_seer_automation",
max_retries=1,
time_limit=30,
soft_time_limit=25,
taskworker_config=TaskworkerConfig(
namespace=ingest_errors_tasks,
retry=Retry(
Expand All @@ -49,4 +51,4 @@ def start_seer_automation(group_id: int):
from sentry.seer.issue_summary import get_issue_summary

group = Group.objects.get(id=group_id)
get_issue_summary(group=group, source="post_process")
get_issue_summary(group=group, source=SeerAutomationSource.POST_PROCESS)
16 changes: 13 additions & 3 deletions tests/sentry/api/endpoints/test_group_ai_summary.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from unittest.mock import ANY, patch

from sentry.autofix.utils import SeerAutomationSource
from sentry.testutils.cases import APITestCase, SnubaTestCase
from sentry.testutils.helpers.features import apply_feature_flag_on_cls
from sentry.testutils.skips import requires_snuba
Expand Down Expand Up @@ -28,7 +29,10 @@ def test_endpoint_calls_get_issue_summary(self, mock_get_issue_summary):
assert response.status_code == 200
assert response.data == mock_summary_data
mock_get_issue_summary.assert_called_once_with(
group=self.group, user=ANY, force_event_id="test_event_id", source="issue_details"
group=self.group,
user=ANY,
force_event_id="test_event_id",
source=SeerAutomationSource.ISSUE_DETAILS,
)

@patch("sentry.api.endpoints.group_ai_summary.get_issue_summary")
Expand All @@ -41,7 +45,10 @@ def test_endpoint_without_event_id(self, mock_get_issue_summary):
assert response.status_code == 200
assert response.data == mock_summary_data
mock_get_issue_summary.assert_called_once_with(
group=self.group, user=ANY, force_event_id=None, source="issue_details"
group=self.group,
user=ANY,
force_event_id=None,
source=SeerAutomationSource.ISSUE_DETAILS,
)

@patch("sentry.api.endpoints.group_ai_summary.get_issue_summary")
Expand All @@ -54,5 +61,8 @@ def test_endpoint_with_error_response(self, mock_get_issue_summary):
assert response.status_code == 400
assert response.data == error_data
mock_get_issue_summary.assert_called_once_with(
group=self.group, user=ANY, force_event_id=None, source="issue_details"
group=self.group,
user=ANY,
force_event_id=None,
source=SeerAutomationSource.ISSUE_DETAILS,
)
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ def test_successful_setup(self):
"orgHasAcknowledged": False,
"userHasAcknowledged": False,
},
"billing": {
"hasAutofixQuota": True,
},
}

def test_current_user_acknowledged_setup(self):
Expand Down Expand Up @@ -154,6 +157,9 @@ def test_successful_with_write_access(self, mock_get_repos_and_access):
"orgHasAcknowledged": False,
"userHasAcknowledged": False,
},
"billing": {
"hasAutofixQuota": True,
},
}


Expand Down
3 changes: 2 additions & 1 deletion tests/sentry/integrations/slack/test_message_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.urls import reverse
from urllib3.response import HTTPResponse

from sentry.autofix.utils import SeerAutomationSource
from sentry.eventstore.models import Event
from sentry.grouping.grouptype import ErrorGroupType
from sentry.incidents.logic import CRITICAL_TRIGGER_LABEL
Expand Down Expand Up @@ -995,7 +996,7 @@ def test_build_group_block_with_ai_summary_with_feature_flag(

blocks = SlackIssuesMessageBuilder(group).build()

mock_get_summary.assert_called_once_with(group, source="alert")
mock_get_summary.assert_called_once_with(group, source=SeerAutomationSource.ALERT)

# Verify that the original title is \\ present
assert "IntegrationError" in blocks["blocks"][0]["text"]["text"]
Expand Down
9 changes: 7 additions & 2 deletions tests/sentry/seer/test_issue_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from unittest.mock import ANY, Mock, call, patch

from sentry.api.serializers.rest_framework.base import convert_dict_key_case, snake_to_camel_case
from sentry.autofix.utils import SeerAutomationSource
from sentry.locks import locks
from sentry.seer.issue_summary import (
_get_event,
Expand Down Expand Up @@ -548,7 +549,9 @@ def test_run_automation_saves_fixability_score(
self.group.refresh_from_db()
assert self.group.seer_fixability_score is None

_run_automation(self.group, mock_user, mock_event, source="issue_details")
_run_automation(
self.group, mock_user, mock_event, source=SeerAutomationSource.ISSUE_DETAILS
)

mock_generate_fixability_score.assert_called_once_with(self.group.id)

Expand Down Expand Up @@ -613,7 +616,9 @@ def test_is_issue_fixable_triggers_autofix(

with self.subTest(option=option_value, score=score, should_trigger=should_trigger):
self.group.project.update_option("sentry:autofix_automation_tuning", option_value)
_run_automation(self.group, mock_user, mock_event, source="issue_details")
_run_automation(
self.group, mock_user, mock_event, source=SeerAutomationSource.ISSUE_DETAILS
)

mock_generate_fixability_score.assert_called_once_with(self.group.id)
self.group.refresh_from_db()
Expand Down
Loading