Skip to content

feat(aci): Write to IncidentGroupOpenPeriod #97621

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 13 commits into from
Aug 12, 2025
5 changes: 5 additions & 0 deletions src/sentry/incidents/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down
19 changes: 19 additions & 0 deletions src/sentry/issues/ingest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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

Expand Down
162 changes: 162 additions & 0 deletions src/sentry/workflow_engine/models/incident_groupopenperiod.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import logging

from django.db import models
from django.db.models import Q

Expand All @@ -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
Expand All @@ -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),
},
)
137 changes: 137 additions & 0 deletions tests/sentry/issues/test_ingest_incident_integration.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading