feat(alerting): silence matcher for notification-time dispatch

SilenceMatcherService.matches() evaluates AND semantics across ruleId,
severity, appSlug, routeId, agentId constraints. Null fields are wildcards.
Scope-based constraints (appSlug/routeId/agentId) return false when rule is
null (deleted rule — scope cannot be verified). 17 unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 19:27:18 +02:00
parent 1c74ab8541
commit 891c7f87e3
2 changed files with 246 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
package com.cameleer.server.app.alerting.notify;
import com.cameleer.server.core.alerting.AlertInstance;
import com.cameleer.server.core.alerting.AlertRule;
import com.cameleer.server.core.alerting.SilenceMatcher;
import org.springframework.stereotype.Component;
/**
* Evaluates whether an active silence matches an alert instance at notification-dispatch time.
* <p>
* Each non-null field on the matcher is an additional AND constraint. A null field is a wildcard.
* Matching is purely in-process — no I/O.
*/
@Component
public class SilenceMatcherService {
/**
* Returns {@code true} if the silence covers this alert instance.
*
* @param matcher the silence's matching spec (never null)
* @param instance the alert instance to test (never null)
* @param rule the alert rule; may be null when the rule was deleted after instance creation.
* Scope-based matchers (appSlug, routeId, agentId) return false when rule is null
* because the scope cannot be verified.
*/
public boolean matches(SilenceMatcher matcher, AlertInstance instance, AlertRule rule) {
// ruleId constraint
if (matcher.ruleId() != null && !matcher.ruleId().equals(instance.ruleId())) {
return false;
}
// severity constraint
if (matcher.severity() != null && matcher.severity() != instance.severity()) {
return false;
}
// scope-based constraints require the rule to derive scope from
boolean needsScope = matcher.appSlug() != null || matcher.routeId() != null || matcher.agentId() != null;
if (needsScope && rule == null) {
return false;
}
if (rule != null && rule.condition() != null) {
var scope = rule.condition().scope();
if (matcher.appSlug() != null && !matcher.appSlug().equals(scope.appSlug())) {
return false;
}
if (matcher.routeId() != null && !matcher.routeId().equals(scope.routeId())) {
return false;
}
if (matcher.agentId() != null && !matcher.agentId().equals(scope.agentId())) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,188 @@
package com.cameleer.server.app.alerting.notify;
import com.cameleer.server.core.alerting.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class SilenceMatcherServiceTest {
private SilenceMatcherService service;
private static final UUID RULE_ID = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
private static final UUID ENV_ID = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
private static final UUID INST_ID = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
@BeforeEach
void setUp() {
service = new SilenceMatcherService();
}
// ---- helpers ----
private AlertInstance instance() {
return new AlertInstance(
INST_ID, RULE_ID, Map.of(), ENV_ID,
AlertState.FIRING, AlertSeverity.WARNING,
Instant.now(), null, null, null, null,
false, 1.5, 1.0,
Map.of(), "title", "msg",
List.of(), List.of(), List.of()
);
}
private AlertRule ruleWithScope(String appSlug, String routeId, String agentId) {
var scope = new AlertScope(appSlug, routeId, agentId);
var condition = new RouteMetricCondition(scope, RouteMetric.ERROR_RATE, Comparator.GT, 0.1, 60);
return new AlertRule(
RULE_ID, ENV_ID, "Test rule", null,
AlertSeverity.WARNING, true,
ConditionKind.ROUTE_METRIC, condition,
60, 0, 0, "t", "m",
List.of(), List.of(),
Instant.now(), null, Instant.now(),
Map.of(), Instant.now(), "admin", Instant.now(), "admin"
);
}
// ---- wildcard matcher ----
@Test
void wildcardMatcher_matchesAnyInstance() {
var matcher = new SilenceMatcher(null, null, null, null, null);
assertThat(service.matches(matcher, instance(), ruleWithScope("my-app", "r1", null))).isTrue();
}
@Test
void wildcardMatcher_matchesWithNullRule() {
var matcher = new SilenceMatcher(null, null, null, null, null);
assertThat(service.matches(matcher, instance(), null)).isTrue();
}
// ---- ruleId constraint ----
@Test
void ruleIdMatcher_matchesWhenEqual() {
var matcher = new SilenceMatcher(RULE_ID, null, null, null, null);
assertThat(service.matches(matcher, instance(), ruleWithScope(null, null, null))).isTrue();
}
@Test
void ruleIdMatcher_rejectsWhenDifferent() {
var matcher = new SilenceMatcher(UUID.randomUUID(), null, null, null, null);
assertThat(service.matches(matcher, instance(), ruleWithScope(null, null, null))).isFalse();
}
@Test
void ruleIdMatcher_withNullInstanceRuleId_rejects() {
// Instance where rule was deleted (ruleId = null)
var inst = new AlertInstance(
INST_ID, null, Map.of(), ENV_ID,
AlertState.FIRING, AlertSeverity.WARNING,
Instant.now(), null, null, null, null,
false, null, null,
Map.of(), "t", "m",
List.of(), List.of(), List.of()
);
var matcher = new SilenceMatcher(RULE_ID, null, null, null, null);
assertThat(service.matches(matcher, inst, null)).isFalse();
}
@Test
void ruleIdNull_withNullInstanceRuleId_wildcardStillMatches() {
var inst = new AlertInstance(
INST_ID, null, Map.of(), ENV_ID,
AlertState.FIRING, AlertSeverity.WARNING,
Instant.now(), null, null, null, null,
false, null, null,
Map.of(), "t", "m",
List.of(), List.of(), List.of()
);
var matcher = new SilenceMatcher(null, null, null, null, null);
// Wildcard ruleId + null rule — scope constraints not needed — should match.
assertThat(service.matches(matcher, inst, null)).isTrue();
}
// ---- severity constraint ----
@Test
void severityMatcher_matchesWhenEqual() {
var matcher = new SilenceMatcher(null, null, null, null, AlertSeverity.WARNING);
assertThat(service.matches(matcher, instance(), ruleWithScope(null, null, null))).isTrue();
}
@Test
void severityMatcher_rejectsWhenDifferent() {
var matcher = new SilenceMatcher(null, null, null, null, AlertSeverity.CRITICAL);
assertThat(service.matches(matcher, instance(), ruleWithScope(null, null, null))).isFalse();
}
// ---- appSlug constraint ----
@Test
void appSlugMatcher_matchesWhenEqual() {
var matcher = new SilenceMatcher(null, "my-app", null, null, null);
assertThat(service.matches(matcher, instance(), ruleWithScope("my-app", null, null))).isTrue();
}
@Test
void appSlugMatcher_rejectsWhenDifferent() {
var matcher = new SilenceMatcher(null, "other-app", null, null, null);
assertThat(service.matches(matcher, instance(), ruleWithScope("my-app", null, null))).isFalse();
}
@Test
void appSlugMatcher_rejectsWhenRuleIsNull() {
var matcher = new SilenceMatcher(null, "my-app", null, null, null);
assertThat(service.matches(matcher, instance(), null)).isFalse();
}
// ---- routeId constraint ----
@Test
void routeIdMatcher_matchesWhenEqual() {
var matcher = new SilenceMatcher(null, null, "route-1", null, null);
assertThat(service.matches(matcher, instance(), ruleWithScope(null, "route-1", null))).isTrue();
}
@Test
void routeIdMatcher_rejectsWhenDifferent() {
var matcher = new SilenceMatcher(null, null, "route-99", null, null);
assertThat(service.matches(matcher, instance(), ruleWithScope(null, "route-1", null))).isFalse();
}
// ---- agentId constraint ----
@Test
void agentIdMatcher_matchesWhenEqual() {
var matcher = new SilenceMatcher(null, null, null, "agent-7", null);
assertThat(service.matches(matcher, instance(), ruleWithScope(null, null, "agent-7"))).isTrue();
}
@Test
void agentIdMatcher_rejectsWhenDifferent() {
var matcher = new SilenceMatcher(null, null, null, "agent-99", null);
assertThat(service.matches(matcher, instance(), ruleWithScope(null, null, "agent-7"))).isFalse();
}
// ---- AND semantics: multiple fields ----
@Test
void multipleFields_allMustMatch() {
var matcher = new SilenceMatcher(RULE_ID, "my-app", "route-1", null, AlertSeverity.WARNING);
assertThat(service.matches(matcher, instance(), ruleWithScope("my-app", "route-1", null))).isTrue();
}
@Test
void multipleFields_failsWhenOneDoesNotMatch() {
// severity mismatch while everything else matches
var matcher = new SilenceMatcher(RULE_ID, "my-app", "route-1", null, AlertSeverity.CRITICAL);
assertThat(service.matches(matcher, instance(), ruleWithScope("my-app", "route-1", null))).isFalse();
}
}