diff --git a/src/sentry/api/endpoints/group_ai_summary.py b/src/sentry/api/endpoints/group_ai_summary.py index 9e4be89959e5e4..81993ebbabacc0 100644 --- a/src/sentry/api/endpoints/group_ai_summary.py +++ b/src/sentry/api/endpoints/group_ai_summary.py @@ -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 @@ -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, ) return Response(summary_data, status=status_code) diff --git a/src/sentry/api/endpoints/group_autofix_setup_check.py b/src/sentry/api/endpoints/group_autofix_setup_check.py index 07ae4119358175..14722fc7e2f8e2 100644 --- a/src/sentry/api/endpoints/group_autofix_setup_check.py +++ b/src/sentry/api/endpoints/group_autofix_setup_check.py @@ -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 @@ -126,6 +127,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": { @@ -137,5 +143,8 @@ def get(self, request: Request, group: Group) -> Response: "orgHasAcknowledged": org_acknowledgement, "userHasAcknowledged": user_acknowledgement, }, + "billing": { + "hasAutofixQuota": has_autofix_quota, + }, } ) diff --git a/src/sentry/autofix/utils.py b/src/sentry/autofix/utils.py index 8958c004757054..52795f5fbb679d 100644 --- a/src/sentry/autofix/utils.py +++ b/src/sentry/autofix/utils.py @@ -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" diff --git a/src/sentry/integrations/utils/issue_summary_for_alerts.py b/src/sentry/integrations/utils/issue_summary_for_alerts.py index ec15b11092b17a..4718cb7b407198 100644 --- a/src/sentry/integrations/utils/issue_summary_for_alerts.py +++ b/src/sentry/integrations/utils/issue_summary_for_alerts.py @@ -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 @@ -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: diff --git a/src/sentry/seer/autofix.py b/src/sentry/seer/autofix.py index 06501438a7a1d6..934b41bea0032e 100644 --- a/src/sentry/seer/autofix.py +++ b/src/sentry/seer/autofix.py @@ -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 @@ -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: @@ -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( + group.organization.id, group.project.id, DataCategory.SEER_AUTOFIX + ) + return Response( { "run_id": run_id, diff --git a/src/sentry/seer/issue_summary.py b/src/sentry/seer/issue_summary.py index ccc3e98cbee98d..0a6cd48bccaa47 100644 --- a/src/sentry/seer/issue_summary.py +++ b/src/sentry/seer/issue_summary.py @@ -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 @@ -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 @@ -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, @@ -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.""" @@ -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. @@ -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) @@ -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 @@ -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: diff --git a/src/sentry/tasks/autofix.py b/src/sentry/tasks/autofix.py index a572c6ce5c65a6..f788352c318d3e 100644 --- a/src/sentry/tasks/autofix.py +++ b/src/sentry/tasks/autofix.py @@ -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 @@ -51,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) diff --git a/tests/sentry/api/endpoints/test_group_ai_summary.py b/tests/sentry/api/endpoints/test_group_ai_summary.py index d1c6c73b75dd10..f2703bcdf39bbb 100644 --- a/tests/sentry/api/endpoints/test_group_ai_summary.py +++ b/tests/sentry/api/endpoints/test_group_ai_summary.py @@ -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 @@ -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") @@ -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") @@ -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, ) diff --git a/tests/sentry/api/endpoints/test_group_autofix_setup_check.py b/tests/sentry/api/endpoints/test_group_autofix_setup_check.py index 1aabc6aa610c6c..7ca14bbbd3bb6c 100644 --- a/tests/sentry/api/endpoints/test_group_autofix_setup_check.py +++ b/tests/sentry/api/endpoints/test_group_autofix_setup_check.py @@ -55,6 +55,9 @@ def test_successful_setup(self): "orgHasAcknowledged": False, "userHasAcknowledged": False, }, + "billing": { + "hasAutofixQuota": True, + }, } def test_current_user_acknowledged_setup(self): @@ -154,6 +157,9 @@ def test_successful_with_write_access(self, mock_get_repos_and_access): "orgHasAcknowledged": False, "userHasAcknowledged": False, }, + "billing": { + "hasAutofixQuota": True, + }, } diff --git a/tests/sentry/integrations/slack/test_message_builder.py b/tests/sentry/integrations/slack/test_message_builder.py index ca8f27d17b8e0b..2b22d8d46836ea 100644 --- a/tests/sentry/integrations/slack/test_message_builder.py +++ b/tests/sentry/integrations/slack/test_message_builder.py @@ -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 @@ -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"] diff --git a/tests/sentry/seer/test_issue_summary.py b/tests/sentry/seer/test_issue_summary.py index 3a1a9b13484d7e..c696780c4fbb3a 100644 --- a/tests/sentry/seer/test_issue_summary.py +++ b/tests/sentry/seer/test_issue_summary.py @@ -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, @@ -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) @@ -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()