diff --git a/src/sentry/tasks/web_vitals_issue_detection.py b/src/sentry/tasks/web_vitals_issue_detection.py index 33a2b5c9293f54..5fdfb126a9b9d5 100644 --- a/src/sentry/tasks/web_vitals_issue_detection.py +++ b/src/sentry/tasks/web_vitals_issue_detection.py @@ -3,11 +3,14 @@ import logging from datetime import UTC, datetime, timedelta -from sentry import options +from sentry import features, options from sentry.models.project import Project from sentry.search.eap.types import SearchResolverConfig from sentry.search.events.types import SnubaParams +from sentry.seer.autofix.utils import get_autofix_repos_from_project_code_mappings +from sentry.seer.constants import SEER_SUPPORTED_SCM_PROVIDERS from sentry.seer.explorer.utils import normalize_description +from sentry.seer.seer_setup import get_seer_org_acknowledgement from sentry.snuba.referrer import Referrer from sentry.snuba.spans_rpc import Spans from sentry.tasks.base import instrumented_task @@ -53,8 +56,13 @@ def run_web_vitals_issue_detection() -> None: return # Spawn a sub-task for each project - for project_id in enabled_project_ids: - detect_web_vitals_issues_for_project.delay(project_id) + projects = Project.objects.filter(id__in=enabled_project_ids).select_related("organization") + + for project in projects: + if not check_seer_setup_for_project(project): + continue + + detect_web_vitals_issues_for_project.delay(project.id) @instrumented_task( @@ -183,3 +191,25 @@ def get_highest_opportunity_page_vitals_for_project( ) return web_vital_issue_groups + + +def check_seer_setup_for_project(project: Project) -> bool: + """ + Checks if a project and it's organization have the necessary Seer setup to detect web vitals issues. + The project must have seer feature flags, seer acknowledgement, and a github code mapping. + """ + if not features.has("organizations:gen-ai-features", project.organization): + return False + + if project.organization.get_option("sentry:hide_ai_features"): + return False + + if not get_seer_org_acknowledgement(project.organization): + return False + + repos = get_autofix_repos_from_project_code_mappings(project) + github_repos = [repo for repo in repos if repo.get("provider") in SEER_SUPPORTED_SCM_PROVIDERS] + if not github_repos: + return False + + return True diff --git a/tests/sentry/tasks/test_web_vitals_issue_detection.py b/tests/sentry/tasks/test_web_vitals_issue_detection.py index b5de51d3293a57..77293333993e24 100644 --- a/tests/sentry/tasks/test_web_vitals_issue_detection.py +++ b/tests/sentry/tasks/test_web_vitals_issue_detection.py @@ -1,3 +1,4 @@ +from contextlib import contextmanager from unittest.mock import patch import pytest @@ -16,21 +17,105 @@ def setUp(self): super().setUp() self.ten_mins_ago = before_now(minutes=10) + @contextmanager + def mock_seer_ack(self): + with ( + patch( + "sentry.tasks.web_vitals_issue_detection.get_seer_org_acknowledgement" + ) as mock_ack, + ): + mock_ack.return_value = True + yield {"mock_ack": mock_ack} + + @contextmanager + def mock_code_mapping(self): + with ( + patch( + "sentry.tasks.web_vitals_issue_detection.get_autofix_repos_from_project_code_mappings" + ) as mock_repos, + ): + mock_repos.return_value = [ + { + "provider": "integrations:github", + "owner": "test-owner", + "name": "test-repo", + } + ] + yield {"mock_repos": mock_repos} + @patch("sentry.tasks.web_vitals_issue_detection.detect_web_vitals_issues_for_project.delay") - def test_run_detection_dispatches_sub_tasks(self, mock_delay): + def test_run_detection_dispatches_sub_tasks_when_enabled(self, mock_delay): project = self.create_project() - with self.options( - { - "issue-detection.web-vitals-detection.enabled": True, - "issue-detection.web-vitals-detection.projects-allowlist": [project.id], - } + with ( + self.mock_seer_ack(), + self.mock_code_mapping(), + self.options( + { + "issue-detection.web-vitals-detection.enabled": True, + "issue-detection.web-vitals-detection.projects-allowlist": [project.id], + } + ), + self.feature("organizations:gen-ai-features"), ): run_web_vitals_issue_detection() assert mock_delay.called assert mock_delay.call_args[0][0] == project.id + @patch("sentry.tasks.web_vitals_issue_detection.detect_web_vitals_issues_for_project.delay") + def test_run_detection_skips_when_seer_not_acknowledged(self, mock_delay): + project = self.create_project() + + with ( + self.mock_code_mapping(), + self.options( + { + "issue-detection.web-vitals-detection.enabled": True, + "issue-detection.web-vitals-detection.projects-allowlist": [project.id], + } + ), + self.feature("organizations:gen-ai-features"), + ): + run_web_vitals_issue_detection() + + assert not mock_delay.called + + @patch("sentry.tasks.web_vitals_issue_detection.detect_web_vitals_issues_for_project.delay") + def test_run_detection_skips_when_no_github_code_mappings(self, mock_delay): + project = self.create_project() + + with ( + self.mock_seer_ack(), + self.options( + { + "issue-detection.web-vitals-detection.enabled": True, + "issue-detection.web-vitals-detection.projects-allowlist": [project.id], + } + ), + self.feature("organizations:gen-ai-features"), + ): + run_web_vitals_issue_detection() + + assert not mock_delay.called + + @patch("sentry.tasks.web_vitals_issue_detection.detect_web_vitals_issues_for_project.delay") + def test_run_detection_skips_when_not_allowlisted(self, mock_delay): + with ( + self.mock_seer_ack(), + self.mock_code_mapping(), + self.options( + { + "issue-detection.web-vitals-detection.enabled": True, + "issue-detection.web-vitals-detection.projects-allowlist": [], + } + ), + self.feature("organizations:gen-ai-features"), + ): + run_web_vitals_issue_detection() + + assert not mock_delay.called + @pytest.mark.snuba @patch("sentry.web_vitals.issue_platform_adapter.produce_occurrence_to_kafka") def test_run_detection_produces_occurrences(self, mock_produce_occurrence_to_kafka): @@ -110,12 +195,15 @@ def test_run_detection_produces_occurrences(self, mock_produce_occurrence_to_kaf self.store_spans(spans, is_eap=True) with ( + self.mock_seer_ack(), + self.mock_code_mapping(), self.options( { "issue-detection.web-vitals-detection.enabled": True, "issue-detection.web-vitals-detection.projects-allowlist": [project.id], } ), + self.feature("organizations:gen-ai-features"), TaskRunner(), ): run_web_vitals_issue_detection() @@ -209,12 +297,15 @@ def test_run_detection_does_not_produce_occurrences_for_existing_issues( ) with ( + self.mock_seer_ack(), + self.mock_code_mapping(), self.options( { "issue-detection.web-vitals-detection.enabled": True, "issue-detection.web-vitals-detection.projects-allowlist": [project.id], } ), + self.feature("organizations:gen-ai-features"), TaskRunner(), ): run_web_vitals_issue_detection() @@ -251,12 +342,15 @@ def test_run_detection_does_not_create_issue_on_insufficient_samples( self.store_spans(spans, is_eap=True) with ( + self.mock_seer_ack(), + self.mock_code_mapping(), self.options( { "issue-detection.web-vitals-detection.enabled": True, "issue-detection.web-vitals-detection.projects-allowlist": [project.id], } ), + self.feature("organizations:gen-ai-features"), TaskRunner(), ): run_web_vitals_issue_detection() @@ -330,12 +424,15 @@ def test_run_detection_selects_trace_closest_to_p75_web_vital_value( self.store_spans(spans, is_eap=True) with ( + self.mock_seer_ack(), + self.mock_code_mapping(), self.options( { "issue-detection.web-vitals-detection.enabled": True, "issue-detection.web-vitals-detection.projects-allowlist": [project.id], } ), + self.feature("organizations:gen-ai-features"), TaskRunner(), ): run_web_vitals_issue_detection()