diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AlertEvaluatorJob.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AlertEvaluatorJob.java index 7002ad80..0beace9d 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AlertEvaluatorJob.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AlertEvaluatorJob.java @@ -161,7 +161,8 @@ public class AlertEvaluatorJob implements SchedulingConfigurer { && current.state() == AlertState.PENDING && next.state() == AlertState.FIRING; - AlertInstance enriched = enrichTitleMessage(rule, next); + AlertInstance withSnapshot = next.withRuleSnapshot(snapshotRule(rule)); + AlertInstance enriched = enrichTitleMessage(rule, withSnapshot); AlertInstance persisted = instanceRepo.save(enriched); if (isFirstFire || promotedFromPending) { @@ -176,7 +177,8 @@ public class AlertEvaluatorJob implements SchedulingConfigurer { */ private void applyBatchFiring(AlertRule rule, EvalResult.Firing f) { Instant now = Instant.now(clock); - AlertInstance instance = AlertStateTransitions.newInstance(rule, f, AlertState.FIRING, now); + AlertInstance instance = AlertStateTransitions.newInstance(rule, f, AlertState.FIRING, now) + .withRuleSnapshot(snapshotRule(rule)); AlertInstance enriched = enrichTitleMessage(rule, instance); AlertInstance persisted = instanceRepo.save(enriched); enqueueNotifications(rule, persisted, now); @@ -236,7 +238,12 @@ public class AlertEvaluatorJob implements SchedulingConfigurer { @SuppressWarnings("unchecked") Map snapshotRule(AlertRule rule) { try { - return objectMapper.convertValue(rule, Map.class); + Map raw = objectMapper.convertValue(rule, Map.class); + // Map.copyOf (used in AlertInstance compact ctor) rejects null values — + // strip them so the snapshot is safe to store. + Map safe = new java.util.LinkedHashMap<>(); + raw.forEach((k, v) -> { if (v != null) safe.put(k, v); }); + return safe; } catch (Exception e) { log.warn("Failed to snapshot rule {}: {}", rule.id(), e.getMessage()); return Map.of("id", rule.id().toString(), "name", rule.name()); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AlertEvaluatorJobIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AlertEvaluatorJobIT.java index 9c3e5659..46b49531 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AlertEvaluatorJobIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/AlertEvaluatorJobIT.java @@ -196,4 +196,53 @@ class AlertEvaluatorJobIT extends AbstractPostgresIT { jdbcTemplate.update("DELETE FROM alert_instances WHERE rule_id = ?", ruleId2); jdbcTemplate.update("DELETE FROM alert_rules WHERE id = ?", ruleId2); } + + @Test + void ruleSnapshotIsPersistedOnInstanceCreation() { + // Dead agent → FIRING instance created + when(agentRegistryService.findAll()) + .thenReturn(List.of(deadAgent(Instant.now().minusSeconds(120)))); + + job.tick(); + + // Read rule_snapshot directly from the DB — must contain name, severity, conditionKind + String snapshot = jdbcTemplate.queryForObject( + "SELECT rule_snapshot::text FROM alert_instances WHERE rule_id = ?", + String.class, ruleId); + + assertThat(snapshot).isNotNull(); + assertThat(snapshot).contains("\"name\": \"dead-agent-rule\""); + assertThat(snapshot).contains("\"severity\": \"WARNING\""); + assertThat(snapshot).contains("\"conditionKind\": \"AGENT_STATE\""); + } + + @Test + void historySurvivesRuleDelete() { + // Seed: dead agent → FIRING instance created + when(agentRegistryService.findAll()) + .thenReturn(List.of(deadAgent(Instant.now().minusSeconds(120)))); + job.tick(); + + // Verify instance exists with a populated snapshot + String snapshotBefore = jdbcTemplate.queryForObject( + "SELECT rule_snapshot::text FROM alert_instances WHERE rule_id = ?", + String.class, ruleId); + assertThat(snapshotBefore).contains("\"name\": \"dead-agent-rule\""); + + // Delete the rule — ON DELETE SET NULL clears rule_id on the instance + ruleRepo.delete(ruleId); + + // rule_id must be NULL on the instance row + Long nullRuleIdCount = jdbcTemplate.queryForObject( + "SELECT count(*) FROM alert_instances WHERE rule_id IS NULL AND rule_snapshot::text LIKE '%dead-agent-rule%'", + Long.class); + assertThat(nullRuleIdCount).isEqualTo(1L); + + // snapshot still contains the rule name — history survives deletion + String snapshotAfter = jdbcTemplate.queryForObject( + "SELECT rule_snapshot::text FROM alert_instances WHERE rule_id IS NULL AND rule_snapshot::text LIKE '%dead-agent-rule%'", + String.class); + assertThat(snapshotAfter).contains("\"name\": \"dead-agent-rule\""); + assertThat(snapshotAfter).contains("\"severity\": \"WARNING\""); + } } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertInstance.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertInstance.java index cf319124..cdc1822b 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertInstance.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertInstance.java @@ -92,4 +92,11 @@ public record AlertInstance( currentValue, threshold, context, title, message, targetUserIds, targetGroupIds, targetRoleNames); } + + public AlertInstance withRuleSnapshot(Map snapshot) { + return new AlertInstance(id, ruleId, snapshot, environmentId, + state, severity, firedAt, ackedAt, ackedBy, resolvedAt, lastNotifiedAt, silenced, + currentValue, threshold, context, title, message, + targetUserIds, targetGroupIds, targetRoleNames); + } }