diff --git a/src/sentry/incidents/logic.py b/src/sentry/incidents/logic.py index fcd1b9ce1f3d27..71c7c04fea1118 100644 --- a/src/sentry/incidents/logic.py +++ b/src/sentry/incidents/logic.py @@ -101,6 +101,7 @@ from sentry.utils.not_set import NOT_SET, NotSet from sentry.utils.snuba import is_measurement from sentry.workflow_engine.endpoints.validators.utils import toggle_detector +from sentry.workflow_engine.models import IncidentGroupOpenPeriod from sentry.workflow_engine.models.detector import Detector # We can return an incident as "windowed" which returns a range of points around the start of the incident @@ -181,6 +182,10 @@ def create_incident( incident_type=incident_type.value, ) + # If this is a metric alert incident, check for pending group relationships + if alert_rule and incident_type == IncidentType.ALERT_TRIGGERED: + IncidentGroupOpenPeriod.create_pending_relationships_for_incident(incident, alert_rule) + return incident diff --git a/src/sentry/issues/ingest.py b/src/sentry/issues/ingest.py index a97e928befe6be..babd768c951af2 100644 --- a/src/sentry/issues/ingest.py +++ b/src/sentry/issues/ingest.py @@ -22,6 +22,7 @@ save_grouphash_and_group, ) from sentry.eventstore.models import Event, GroupEvent, augment_message_with_occurrence +from sentry.incidents.grouptype import MetricIssue from sentry.issues.grouptype import FeedbackGroup, should_create_group from sentry.issues.issue_occurrence import IssueOccurrence, IssueOccurrenceData from sentry.issues.priority import PriorityChangeReason, update_priority @@ -34,6 +35,7 @@ from sentry.utils import json, metrics, redis from sentry.utils.strings import truncatechars from sentry.utils.tag_normalization import normalized_sdk_tag_from_event +from sentry.workflow_engine.models import IncidentGroupOpenPeriod from sentry.workflow_engine.models.detector_group import DetectorGroup issue_rate_limiter = RedisSlidingWindowRateLimiter( @@ -70,6 +72,23 @@ def save_issue_occurrence( group_info.group.project, environment, release, [group_info] ) _get_or_create_group_release(environment, release, event, [group_info]) + + # Create IncidentGroupOpenPeriod relationship for metric issues + if occurrence.type == MetricIssue: + open_period = get_latest_open_period(group_info.group) + if open_period: + IncidentGroupOpenPeriod.create_from_occurrence( + occurrence, group_info.group, open_period + ) + else: + logger.error( + "save_issue_occurrence.no_open_period", + extra={ + "group_id": group_info.group.id, + "occurrence_id": occurrence.id, + }, + ) + send_issue_occurrence_to_eventstream(event, occurrence, group_info) return occurrence, group_info diff --git a/src/sentry/workflow_engine/models/incident_groupopenperiod.py b/src/sentry/workflow_engine/models/incident_groupopenperiod.py index 5a0717ee79cac8..04392ae89ae0da 100644 --- a/src/sentry/workflow_engine/models/incident_groupopenperiod.py +++ b/src/sentry/workflow_engine/models/incident_groupopenperiod.py @@ -1,3 +1,5 @@ +import logging + from django.db import models from django.db.models import Q @@ -8,6 +10,12 @@ FlexibleForeignKey, region_silo_model, ) +from sentry.incidents.models.alert_rule import AlertRule +from sentry.incidents.models.incident import Incident +from sentry.models.groupopenperiod import GroupOpenPeriod +from sentry.workflow_engine.models.alertrule_detector import AlertRuleDetector + +logger = logging.getLogger(__name__) @region_silo_model @@ -32,3 +40,157 @@ class Meta: name="inc_id_inc_identifier_together", ) ] + + @classmethod + def create_from_occurrence(self, occurrence, group, open_period): + """ + Creates an IncidentGroupOpenPeriod relationship from an issue occurrence. + This method handles the case where the incident might not exist yet. + + Args: + occurrence: The IssueOccurrence that triggered the group creation + group: The Group that was created + open_period: The GroupOpenPeriod for the group + """ + try: + # Extract alert_id from evidence_data using the detector_id + detector_id = occurrence.evidence_data.get("detector_id") + if detector_id: + alert_id = AlertRuleDetector.objects.get(detector_id=detector_id).alert_rule_id + else: + raise Exception("No detector_id found in evidence_data for metric issue") + + # Try to find the active incident for this alert rule and project + try: + alert_rule = AlertRule.objects.get(id=alert_id) + incident = Incident.objects.get_active_incident( + alert_rule=alert_rule, + project=group.project, + ) + except AlertRule.DoesNotExist: + logger.warning( + "AlertRule not found for alert_id", + extra={ + "alert_id": alert_id, + "group_id": group.id, + }, + ) + incident = None + + if incident: + # Incident exists, create the relationship immediately + return self.create_relationship(incident, open_period) + else: + # Incident doesn't exist yet, create a placeholder relationship + # that will be updated when the incident is created + return self.create_placeholder_relationship(detector_id, open_period, group.project) + + except Exception as e: + logger.exception( + "Failed to create IncidentGroupOpenPeriod relationship", + extra={ + "group_id": group.id, + "occurrence_id": occurrence.id, + "error": str(e), + }, + ) + return None + + @classmethod + def create_relationship(self, incident, open_period): + """ + Creates IncidentGroupOpenPeriod relationship. + + Args: + incident: The Incident to link + open_period: The GroupOpenPeriod to link + """ + try: + incident_group_open_period, _ = self.objects.get_or_create( + group_open_period=open_period, + defaults={ + "incident_id": incident.id, + "incident_identifier": incident.identifier, + }, + ) + + return incident_group_open_period + + except Exception as e: + logger.exception( + "Failed to create/update IncidentGroupOpenPeriod relationship", + extra={ + "incident_id": incident.id, + "open_period_id": open_period.id, + "error": str(e), + }, + ) + return None + + @classmethod + def create_placeholder_relationship(self, detector_id, open_period, project): + """ + Creates a placeholder relationship when the incident doesn't exist yet. + This will be updated when the incident is created. + + Args: + detector_id: The detector ID + open_period: The GroupOpenPeriod to link + project: The project for the group + """ + try: + # Store the alert_id in the open_period data for later lookup + data = open_period.data or {} + data["pending_incident_detector_id"] = detector_id + open_period.update(data=data) + + return None + + except Exception as e: + logger.exception( + "Failed to create placeholder IncidentGroupOpenPeriod relationship", + extra={ + "detector_id": detector_id, + "open_period_id": open_period.id, + "error": str(e), + }, + ) + return None + + @classmethod + def create_pending_relationships_for_incident(self, incident, alert_rule): + """ + Creates IncidentGroupOpenPeriod relationships for any groups that were created + before the incident. This handles the timing issue where groups might be created + before incidents. + + Args: + incident: The Incident that was just created + alert_rule: The AlertRule that triggered the incident + """ + try: + # Find all open periods that have a pending incident detector_id for this alert rule + detector_id = AlertRuleDetector.objects.get(alert_rule_id=alert_rule.id).detector_id + pending_open_periods = GroupOpenPeriod.objects.filter( + data__pending_incident_detector_id=detector_id, + group__project__in=incident.projects.all(), + ) + + for open_period in pending_open_periods: + # Create the relationship + relationship = self.create_relationship(incident, open_period) + if relationship: + # Remove the pending flag from the open_period data + data = open_period.data or {} + data.pop("pending_incident_detector_id", None) + open_period.update(data=data) + + except Exception as e: + logger.exception( + "Failed to create pending IncidentGroupOpenPeriod relationships", + extra={ + "incident_id": incident.id, + "alert_rule_id": alert_rule.id, + "error": str(e), + }, + ) diff --git a/tests/sentry/issues/test_ingest_incident_integration.py b/tests/sentry/issues/test_ingest_incident_integration.py new file mode 100644 index 00000000000000..9e8c97ce5206ab --- /dev/null +++ b/tests/sentry/issues/test_ingest_incident_integration.py @@ -0,0 +1,137 @@ +from unittest.mock import patch + +from django.utils import timezone + +from sentry.event_manager import GroupInfo +from sentry.incidents.grouptype import MetricIssue +from sentry.issues.grouptype import FeedbackGroup +from sentry.issues.ingest import save_issue_occurrence +from sentry.issues.issue_occurrence import IssueOccurrence, IssueOccurrenceData +from sentry.models.group import GroupStatus +from sentry.models.groupopenperiod import GroupOpenPeriod +from sentry.testutils.cases import TestCase +from sentry.testutils.helpers import with_feature +from sentry.workflow_engine.models import IncidentGroupOpenPeriod + + +class IncidentGroupOpenPeriodIntegrationTest(TestCase): + def setUp(self) -> None: + super().setUp() + self.organization = self.create_organization() + self.project = self.create_project(organization=self.organization) + self.alert_rule = self.create_alert_rule( + organization=self.organization, + projects=[self.project], + name="Test Alert Rule", + ) + self.detector = self.create_detector( + project=self.project, + name="Test Detector", + ) + self.alert_rule_detector = self.create_alert_rule_detector( + alert_rule_id=self.alert_rule.id, + detector=self.detector, + ) + + def save_issue_occurrence( + self, group_type: int = MetricIssue.type_id + ) -> tuple[IssueOccurrence, GroupInfo]: + event = self.store_event( + data={"timestamp": timezone.now().isoformat()}, project_id=self.project.id + ) + + occurrence_data: IssueOccurrenceData = { + "id": "1", + "project_id": self.project.id, + "event_id": event.event_id, + "fingerprint": ["test-fingerprint"], + "issue_title": "Test Issue", + "subtitle": "Test Subtitle", + "resource_id": None, + "evidence_data": {"detector_id": self.detector.id}, + "evidence_display": [ + {"name": "Test Evidence", "value": "Test Value", "important": True} + ], + "type": group_type, + "detection_time": timezone.now().timestamp(), + "level": "error", + "culprit": "test-culprit", + } + + with patch("sentry.issues.ingest.eventstream") as _: + occurrence, group_info = save_issue_occurrence(occurrence_data, event) + + assert group_info is not None + assert group_info.group.type == group_type + return occurrence, group_info + + @with_feature("organizations:issue-open-periods") + def test_save_issue_occurrence_creates_relationship_when_incident_exists(self) -> None: + """Test that save_issue_occurrence creates the relationship when incident exists""" + incident = self.create_incident( + organization=self.organization, + title="Test Incident", + date_started=timezone.now(), + alert_rule=self.alert_rule, + ) + + _, group_info = self.save_issue_occurrence() + group = group_info.group + assert group is not None + + open_period = GroupOpenPeriod.objects.get(group=group) + item = IncidentGroupOpenPeriod.objects.get(group_open_period=open_period) + assert item.incident_id == incident.id + assert item.incident_identifier == incident.identifier + + @with_feature("organizations:issue-open-periods") + def test_save_issue_occurrence_creates_placeholder_when_incident_doesnt_exist(self) -> None: + """Test that save_issue_occurrence creates placeholder when incident doesn't exist""" + _, group_info = self.save_issue_occurrence() + group = group_info.group + assert group is not None + + open_period = GroupOpenPeriod.objects.get(group=group) + assert open_period.data["pending_incident_detector_id"] == self.detector.id + + assert not IncidentGroupOpenPeriod.objects.filter(group_open_period=open_period).exists() + + @with_feature("organizations:issue-open-periods") + def test_save_issue_occurrence_creates_relationship_for_existing_group(self) -> None: + """Test that save_issue_occurrence creates relationship for existing groups""" + incident = self.create_incident( + organization=self.organization, + title="Test Incident", + date_started=timezone.now(), + alert_rule=self.alert_rule, + ) + + _, group_info = self.save_issue_occurrence() + group = group_info.group + assert group is not None + + assert GroupOpenPeriod.objects.filter(group=group, project=self.project).exists() + + group.update(status=GroupStatus.RESOLVED) + open_period = GroupOpenPeriod.objects.get(group=group, project=self.project) + open_period.update(date_ended=timezone.now()) + + _, group_info = self.save_issue_occurrence() + group = group_info.group + assert group is not None + + item = IncidentGroupOpenPeriod.objects.get(group_open_period=open_period) + assert item.incident_id == incident.id + assert item.incident_identifier == incident.identifier + + @with_feature("organizations:issue-open-periods") + def test_save_issue_occurrence_no_relationship_for_non_metric_issues(self) -> None: + # Test that save_issue_occurrence doesn't create relationships for non-metric issues + _, group_info = self.save_issue_occurrence(group_type=FeedbackGroup.type_id) + + group = group_info.group + assert group is not None + + open_period = GroupOpenPeriod.objects.get(group=group) + assert not IncidentGroupOpenPeriod.objects.filter(group_open_period=open_period).exists() + assert "pending_incident_alert_id" not in open_period.data diff --git a/tests/sentry/workflow_engine/models/test_incident_groupopenperiod.py b/tests/sentry/workflow_engine/models/test_incident_groupopenperiod.py new file mode 100644 index 00000000000000..e0fdc9829eece4 --- /dev/null +++ b/tests/sentry/workflow_engine/models/test_incident_groupopenperiod.py @@ -0,0 +1,237 @@ +import uuid +from typing import Any +from unittest.mock import patch + +from django.utils import timezone + +from sentry.incidents.grouptype import MetricIssue +from sentry.incidents.models.incident import IncidentStatus +from sentry.issues.ingest import save_issue_occurrence +from sentry.issues.issue_occurrence import IssueOccurrenceData +from sentry.models.group import GroupStatus +from sentry.models.groupopenperiod import GroupOpenPeriod, create_open_period +from sentry.testutils.cases import TestCase +from sentry.testutils.helpers.features import with_feature +from sentry.workflow_engine.models import IncidentGroupOpenPeriod + + +class IncidentGroupOpenPeriodTest(TestCase): + def setUp(self) -> None: + super().setUp() + self.organization = self.create_organization() + self.project = self.create_project(organization=self.organization) + self.alert_rule = self.create_alert_rule( + organization=self.organization, + projects=[self.project], + name="Test Alert Rule", + ) + self.detector = self.create_detector( + project=self.project, + name="Test Detector", + ) + self.alert_rule_detector = self.create_alert_rule_detector( + alert_rule_id=self.alert_rule.id, + detector=self.detector, + ) + self.group = self.create_group(project=self.project) + self.group.type = MetricIssue.type_id + self.group.save() + + def save_issue_occurrence(self, include_alert_id: bool = True) -> tuple[Any, GroupOpenPeriod]: + event = self.store_event( + data={"timestamp": timezone.now().isoformat()}, project_id=self.project.id + ) + + occurrence_data: IssueOccurrenceData = { + "id": str(uuid.uuid4()), + "project_id": self.project.id, + "event_id": event.event_id, + "fingerprint": ["test-fingerprint"], + "issue_title": "Test Issue", + "subtitle": "Test Subtitle", + "resource_id": None, + "evidence_data": {"detector_id": self.detector.id} if include_alert_id else {}, + "evidence_display": [ + {"name": "Test Evidence", "value": "Test Value", "important": True} + ], + "type": MetricIssue.type_id, + "detection_time": timezone.now().timestamp(), + "level": "error", + "culprit": "test-culprit", + } + + with patch("sentry.issues.ingest.eventstream") as _: + occurrence, group_info = save_issue_occurrence(occurrence_data, event) + + assert group_info is not None + assert group_info.group.type == MetricIssue.type_id + + open_period = ( + GroupOpenPeriod.objects.filter(group=group_info.group).order_by("-date_started").first() + ) + assert open_period is not None + assert open_period.date_ended is None + return occurrence, open_period + + @with_feature("organizations:issue-open-periods") + def test_create_from_occurrence_with_existing_incident(self) -> None: + """Test creating relationship when incident exists""" + occurrence, open_period = self.save_issue_occurrence() + + incident = self.create_incident( + organization=self.organization, + title="Test Incident", + date_started=timezone.now(), + alert_rule=self.alert_rule, + ) + + result = IncidentGroupOpenPeriod.create_from_occurrence(occurrence, self.group, open_period) + + assert result is not None + assert result.incident_id == incident.id + assert result.incident_identifier == incident.identifier + assert result.group_open_period == open_period + + @with_feature("organizations:issue-open-periods") + def test_create_from_occurrence_without_incident(self) -> None: + """Test creating placeholder when incident doesn't exist""" + occurrence, open_period = self.save_issue_occurrence() + + result = IncidentGroupOpenPeriod.create_from_occurrence(occurrence, self.group, open_period) + + assert result is None + open_period.refresh_from_db() + assert open_period.data["pending_incident_detector_id"] == self.detector.id + + @with_feature("organizations:issue-open-periods") + def test_create_from_occurrence_no_alert_id(self) -> None: + """Test handling when no alert_id in evidence_data""" + with patch("sentry.issues.ingest.eventstream") as _: + occurrence, group_info = self.save_issue_occurrence(include_alert_id=False) + + assert group_info is not None + open_period = GroupOpenPeriod.objects.get(group=group_info.group) + + result = IncidentGroupOpenPeriod.create_from_occurrence( + occurrence, group_info.group, open_period + ) + + assert result is None + + @with_feature("organizations:issue-open-periods") + def test_create_relationship_new(self) -> None: + """Test creating a new relationship""" + occurrence, open_period = self.save_issue_occurrence() + + incident = self.create_incident( + organization=self.organization, + title="Test Incident", + date_started=timezone.now(), + alert_rule=self.alert_rule, + ) + + result = IncidentGroupOpenPeriod.create_relationship(incident, open_period) + + assert result is not None + assert result.incident_id == incident.id + assert result.incident_identifier == incident.identifier + assert result.group_open_period == open_period + + @with_feature("organizations:issue-open-periods") + def test_create_second_relationship(self) -> None: + """Test creating a second relationship for a new incident""" + _, open_period = self.save_issue_occurrence() + + incident1 = self.create_incident( + organization=self.organization, + title="Test Incident 1", + date_started=timezone.now(), + alert_rule=self.alert_rule, + ) + + # Create initial relationship + relationship = IncidentGroupOpenPeriod.create_relationship(incident1, open_period) + assert relationship.incident_id == incident1.id + assert relationship.group_open_period == open_period + + incident1.status = IncidentStatus.CLOSED.value + incident1.save() + + open_period.group.update(status=GroupStatus.RESOLVED) + open_period.update(date_ended=timezone.now()) + create_open_period(open_period.group, timezone.now()) + open_period_2 = ( + GroupOpenPeriod.objects.filter(group=open_period.group) + .order_by("-date_started") + .first() + ) + + # Create new incident and new relationship + incident2 = self.create_incident( + organization=self.organization, + title="Test Incident 2", + date_started=timezone.now(), + alert_rule=self.alert_rule, + ) + + result = IncidentGroupOpenPeriod.create_relationship(incident2, open_period_2) + assert result is not None + assert result.incident_id == incident2.id + assert result.incident_identifier == incident2.identifier + assert result.group_open_period == open_period_2 + + @with_feature("organizations:issue-open-periods") + def test_create_placeholder_relationship(self) -> None: + """Test creating a placeholder relationship""" + occurrence, open_period = self.save_issue_occurrence() + + result = IncidentGroupOpenPeriod.create_placeholder_relationship( + self.detector.id, open_period, self.project + ) + + assert result is None + open_period.refresh_from_db() + assert open_period.data["pending_incident_detector_id"] == self.detector.id + + @with_feature("organizations:issue-open-periods") + def test_create_pending_relationships_for_incident(self) -> None: + """Test creating relationships for pending open periods""" + occurrence, open_period = self.save_issue_occurrence() + + # Create a placeholder relationship + open_period.data = {"pending_incident_detector_id": self.detector.id} + open_period.save() + + incident = self.create_incident( + organization=self.organization, + title="Test Incident", + date_started=timezone.now(), + alert_rule=self.alert_rule, + ) + + IncidentGroupOpenPeriod.create_pending_relationships_for_incident(incident, self.alert_rule) + + # Check that relationship was created + relationship = IncidentGroupOpenPeriod.objects.get(group_open_period=open_period) + assert relationship.incident_id == incident.id + assert relationship.incident_identifier == incident.identifier + + # Check that placeholder was cleaned up + open_period.refresh_from_db() + assert "pending_incident_detector_id" not in open_period.data + + @with_feature("organizations:issue-open-periods") + def test_create_pending_relationships_for_incident_no_pending(self) -> None: + """Test creating relationships when no pending relationships exist""" + incident = self.create_incident( + organization=self.organization, + title="Test Incident", + date_started=timezone.now(), + alert_rule=self.alert_rule, + ) + + # Should not raise any errors + IncidentGroupOpenPeriod.create_pending_relationships_for_incident(incident, self.alert_rule) + + # No relationships should be created + assert IncidentGroupOpenPeriod.objects.filter(incident_id=incident.id).count() == 0