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