diff --git a/keep-ui/entities/workflows/model/schema.ts b/keep-ui/entities/workflows/model/schema.ts
index b092339da0..6e9a3e3c3c 100644
--- a/keep-ui/entities/workflows/model/schema.ts
+++ b/keep-ui/entities/workflows/model/schema.ts
@@ -48,7 +48,7 @@ export const V2StepAlertTriggerSchema = TriggerSchemaBase.extend({
.optional(),
});
-export const IncidentEventEnum = z.enum(["created", "updated", "deleted"]);
+export const IncidentEventEnum = z.enum(["created", "updated", "deleted", "alert_association_changed"]);
const IncidentTriggerValueSchema = z.object({
events: z.array(IncidentEventEnum),
diff --git a/keep-ui/features/workflows/builder/ui/Editor/TriggerEditor.tsx b/keep-ui/features/workflows/builder/ui/Editor/TriggerEditor.tsx
index 2b990c23ca..060ea73e4e 100644
--- a/keep-ui/features/workflows/builder/ui/Editor/TriggerEditor.tsx
+++ b/keep-ui/features/workflows/builder/ui/Editor/TriggerEditor.tsx
@@ -199,7 +199,7 @@ export function TriggerEditor() {
return (
<>
Incident events
- {Array("created", "updated", "deleted").map((event) => (
+ {Array("created", "updated", "deleted", "alert_association_changed").map((event) => (
None:
@@ -343,6 +345,7 @@ def __postprocess_alerts_change(self, incident, alert_fingerprints):
"alert_fingerprints": alert_fingerprints,
},
)
+ self.send_workflow_event(incident_dto, "alert_association_changed")
def update_severity(
self,
diff --git a/keep/rulesengine/rulesengine.py b/keep/rulesengine/rulesengine.py
index 5953c492e9..7a3c9c67a9 100644
--- a/keep/rulesengine/rulesengine.py
+++ b/keep/rulesengine/rulesengine.py
@@ -208,6 +208,10 @@ def _run_cel_rules(
self.tenant_id, session, incident_dto, "updated"
)
+ RulesEngine.send_workflow_event(
+ self.tenant_id, session, incident_dto, "alert_association_changed"
+ )
+
incidents_dto[incident.id] = incident_dto
else:
diff --git a/keep/topologies/topology_processor.py b/keep/topologies/topology_processor.py
index fd06e86139..7234b80484 100644
--- a/keep/topologies/topology_processor.py
+++ b/keep/topologies/topology_processor.py
@@ -377,3 +377,7 @@ def _create_application_based_incident(
# Trigger the workflow event
RulesEngine.send_workflow_event(tenant_id, session, incident_dto, "created")
self.logger.info(f"Created new incident for application {application.name}")
+
+ RulesEngine.send_workflow_event(
+ tenant_id, session, incident_dto, "alert_association_changed"
+ )
diff --git a/keep/workflowmanager/workflowmanager.py b/keep/workflowmanager/workflowmanager.py
index 627b033d92..8e511b088b 100644
--- a/keep/workflowmanager/workflowmanager.py
+++ b/keep/workflowmanager/workflowmanager.py
@@ -162,6 +162,34 @@ def insert_incident(self, tenant_id: str, incident: IncidentDto, trigger: str):
for k, v in incident_enrichment.enrichments.items():
setattr(incident, k, v)
+ if trigger == "alert_association_changed":
+ try:
+ alerts = incident.alerts
+
+ processed_alerts = []
+ # Iterate over the alerts and process them as needed
+ for alert in alerts:
+ # Handle multiline description
+ alert_description = alert.description.split("\n")[0]
+
+ processed_alert = f"{alert.status.capitalize()} {alert.lastReceived} [{alert.severity}] {alert_description}"
+ processed_alerts.append(processed_alert)
+
+ # Add the linked alerts to the incident object for use in the workflow
+ setattr(incident, 'linked_alerts', processed_alerts)
+
+ except Exception as e:
+ self.logger.error(
+ f"Failed to fetch alerts linked to incident {incident.id}",
+ extra={
+ "incident_id": incident.id,
+ "tenant_id": tenant_id,
+ "exception": str(e)
+ }
+ )
+ # Set empty list if fetch fails
+ setattr(incident, 'linked_alerts', [])
+
self.logger.info("Adding workflow to run")
with self.scheduler.lock:
self.scheduler.workflows_to_run.append(
diff --git a/tests/test_incidents.py b/tests/test_incidents.py
index 1db8874e0d..7fbcd9d548 100644
--- a/tests/test_incidents.py
+++ b/tests/test_incidents.py
@@ -1396,11 +1396,11 @@ async def test_incident_bl_add_alert_to_incident(db_session, create_alert):
assert data["incident_id"] == str(incident_dto.id)
# Check workflow manager
- assert len(workflow_manager.events) == 2 # Created, update
+ assert len(workflow_manager.events) == 3 # Created, update, alert_association_changed
wf_tenant_id, wf_incident_dto, wf_action = workflow_manager.events[-1]
assert wf_tenant_id == SINGLE_TENANT_UUID
assert wf_incident_dto.id == incident_dto.id
- assert wf_action == "updated"
+ assert wf_action == "alert_association_changed"
# Check elastic
assert len(elastic_client.alerts) == 1
@@ -1490,12 +1490,12 @@ async def test_incident_bl_delete_alerts_from_incident(db_session, create_alert)
assert data["incident_id"] == str(incident_dto.id)
# Check workflow manager
- # Created, updated (added event), updated(deleted event)
- assert len(workflow_manager.events) == 3
+ # Created, updated (added event), alert_association_change(added event), updated(deleted event), alert_association_changed(deleted event)
+ assert len(workflow_manager.events) == 5
wf_tenant_id, wf_incident_dto, wf_action = workflow_manager.events[-1]
assert wf_tenant_id == SINGLE_TENANT_UUID
assert wf_incident_dto.id == incident_dto.id
- assert wf_action == "updated"
+ assert wf_action == "alert_association_changed"
# Check elastic
assert len(elastic_client.alerts) == 2
diff --git a/tests/test_workflow_execution.py b/tests/test_workflow_execution.py
index e831ff6871..0501b2b0a7 100644
--- a/tests/test_workflow_execution.py
+++ b/tests/test_workflow_execution.py
@@ -766,6 +766,26 @@ def test_workflow_execution_with_disabled_workflow(
"deleted incident: {{ incident.name }}"
"""
+workflow_definition_6 = """workflow:
+id: incident-triggers-test-alert-association-changed
+description: test incident alert association change
+triggers:
+- type: incident
+ events:
+ - alert_association_changed
+name: alert_association_changed
+owners: []
+services: []
+steps: []
+actions:
+- name: mock-action
+ provider:
+ type: console
+ with:
+ message: |
+ "linked_alerts: keep.len({{ incident.linked_alerts }})"
+"""
+
@pytest.mark.timeout(15)
@pytest.mark.parametrize(
@@ -792,6 +812,15 @@ def test_workflow_incident_triggers(
db_session.add(workflow_created)
db_session.commit()
+ alert_1 = AlertDto(
+ id="alert-1",
+ name="Test Alert 1",
+ status=AlertStatus.FIRING,
+ severity=AlertSeverity.HIGH,
+ lastReceived="2025-01-30T10:00:00Z",
+ description="Test alert 1 description\nThis is a multiline description\nWith multiple lines of content\nTo test the split behavior"
+ )
+
# Create the current alert
incident = IncidentDto(
id="ba9ddbb9-3a83-40fc-9ace-1e026e08ca2b",
@@ -802,6 +831,7 @@ def test_workflow_incident_triggers(
severity="critical",
is_predicted=False,
is_candidate=False,
+ alerts=[alert_1]
)
# Insert the current alert into the workflow manager
@@ -861,6 +891,31 @@ def test_workflow_incident_triggers(
'"deleted incident: incident"\n'
]
+ workflow_deleted = Workflow(
+ id="incident-triggers-test-alert-association-changed",
+ name="incident-triggers-test-alert-association-changed",
+ tenant_id=SINGLE_TENANT_UUID,
+ description="Check that incident triggers works",
+ created_by="test@keephq.dev",
+ interval=0,
+ workflow_raw=workflow_definition_6,
+ )
+ db_session.add(workflow_deleted)
+ db_session.commit()
+
+ workflow_manager.insert_incident(SINGLE_TENANT_UUID, incident, "alert_association_changed")
+ assert len(workflow_manager.scheduler.workflows_to_run) == 1
+
+ workflow_execution_alert_association_changed = wait_for_workflow_execution(
+ SINGLE_TENANT_UUID, "incident-triggers-test-alert-association-changed"
+ )
+ assert workflow_execution_alert_association_changed is not None
+ assert workflow_execution_alert_association_changed.status == "success"
+ assert workflow_execution_alert_association_changed.results["mock-action"] == [
+ '"linked_alerts: 1"\n'
+ ]
+ assert len(workflow_manager.scheduler.workflows_to_run) == 0
+
# @pytest.mark.parametrize(
# "test_app, test_case, alert_statuses, expected_tier, db_session",
diff --git a/tests/test_workflowmanager.py b/tests/test_workflowmanager.py
index 0c1d44ae1b..be171eacf4 100644
--- a/tests/test_workflowmanager.py
+++ b/tests/test_workflowmanager.py
@@ -1,4 +1,6 @@
from pathlib import Path
+import uuid
+from datetime import datetime
from unittest.mock import Mock, patch
import pytest
@@ -6,6 +8,9 @@
from keep.api.routes.workflows import get_event_from_body
from keep.parser.parser import Parser
+from keep.api.models.alert import AlertDto, AlertSeverity, AlertStatus
+from keep.api.models.incident import IncidentDto
+from keep.api.models.db.incident import IncidentSeverity, IncidentStatus
# Assuming WorkflowParser is the class containing the get_workflow_from_dict method
from keep.workflowmanager.workflow import Workflow
@@ -182,3 +187,102 @@ def test_handle_manual_event_workflow_test_run():
)
assert workflow_scheduler.workflows_to_run[0]["test_run"] == True
assert workflow_scheduler.workflows_to_run[0]["workflow"] == mock_workflow
+
+def test_insert_incident_alert_association_changed_adds_linked_alerts():
+ """Test that linked_alerts key is present when workflow trigger is alert_association_changed."""
+
+ # Create mock alerts that would be associated with the incident
+ mock_alert_1 = AlertDto(
+ id="alert-1",
+ name="Test Alert 1",
+ status=AlertStatus.FIRING,
+ severity=AlertSeverity.HIGH,
+ lastReceived="2025-01-30T10:00:00Z",
+ description="Test alert 1 description\nThis is a multiline description\nWith multiple lines of content\nTo test the split behavior"
+ )
+
+ mock_alert_2 = AlertDto(
+ id="alert-2",
+ name="Test Alert 2",
+ status=AlertStatus.RESOLVED,
+ severity=AlertSeverity.CRITICAL,
+ lastReceived="2025-01-30T11:00:00Z",
+ description="Test alert 2 description"
+ )
+
+ # Create incident DTO with mock alerts
+ incident_dto = IncidentDto(
+ id=uuid.uuid4(),
+ user_generated_name="Test Incident",
+ alerts_count=2,
+ alert_sources=["prometheus", "grafana"],
+ services=["web-service"],
+ severity=IncidentSeverity.HIGH,
+ status=IncidentStatus.FIRING,
+ is_predicted=False,
+ is_candidate=False,
+ creation_time=datetime.utcnow()
+ )
+
+ # Mock the alerts property to return our test alerts
+ incident_dto._alerts = [mock_alert_1, mock_alert_2]
+
+ # Create a mock workflow with alert_association_changed trigger
+ mock_workflow = Mock(spec=Workflow)
+ mock_workflow.workflow_triggers = [
+ {
+ "type": "incident",
+ "events": ["alert_association_changed"]
+ }
+ ]
+
+ # Create mock workflow model
+ mock_workflow_model = Mock()
+ mock_workflow_model.id = "test-workflow-id"
+ mock_workflow_model.name = "test-workflow"
+ mock_workflow_model.tenant_id = "test-tenant"
+ mock_workflow_model.is_disabled = False
+
+ # Create WorkflowManager and mock dependencies
+ workflow_manager = WorkflowManager()
+
+ with patch.object(workflow_manager.workflow_store, 'get_all_workflows') as mock_get_workflows, \
+ patch.object(workflow_manager, '_get_workflow_from_store') as mock_get_workflow, \
+ patch('keep.workflowmanager.workflowmanager.get_enrichment') as mock_get_enrichment:
+
+ # Set up mocks
+ mock_get_workflows.return_value = [mock_workflow_model]
+ mock_get_workflow.return_value = mock_workflow
+ mock_get_enrichment.return_value = None
+
+ # Mock the scheduler
+ workflow_manager.scheduler = Mock()
+ workflow_manager.scheduler.lock = Mock()
+ workflow_manager.scheduler.lock.__enter__ = Mock(return_value=None)
+ workflow_manager.scheduler.lock.__exit__ = Mock(return_value=None)
+ workflow_manager.scheduler.workflows_to_run = []
+
+ # Call insert_incident with alert_association_changed trigger
+ workflow_manager.insert_incident("test-tenant", incident_dto, "alert_association_changed")
+
+ # Verify workflow was added to run
+ assert len(workflow_manager.scheduler.workflows_to_run) == 1
+
+ # Get the workflow execution event
+ workflow_execution = workflow_manager.scheduler.workflows_to_run[0]
+ executed_incident = workflow_execution["event"]
+
+ # Verify that linked_alerts attribute was added to the incident
+ assert hasattr(executed_incident, 'linked_alerts'), "linked_alerts attribute should be present"
+
+ # Verify the content of linked_alerts
+ linked_alerts = executed_incident.linked_alerts
+ assert isinstance(linked_alerts, list), "linked_alerts should be a list"
+ assert len(linked_alerts) == 2, "Should have 2 linked alerts"
+
+ # Verify the format of linked alerts entries
+ expected_alert_1 = "Firing 2025-01-30T10:00:00.000Z [high] Test alert 1 description"
+ expected_alert_2 = "Resolved 2025-01-30T11:00:00.000Z [critical] Test alert 2 description"
+
+ assert expected_alert_1 in linked_alerts, f"Expected '{expected_alert_1}' in linked_alerts"
+ assert expected_alert_2 in linked_alerts, f"Expected '{expected_alert_2}' in linked_alerts"