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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user