feat(alerting): AGENT_STATE evaluator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
package com.cameleer.server.app.alerting.eval;
|
||||||
|
|
||||||
|
import com.cameleer.server.core.agent.AgentInfo;
|
||||||
|
import com.cameleer.server.core.agent.AgentRegistryService;
|
||||||
|
import com.cameleer.server.core.agent.AgentState;
|
||||||
|
import com.cameleer.server.core.alerting.AgentStateCondition;
|
||||||
|
import com.cameleer.server.core.alerting.AlertRule;
|
||||||
|
import com.cameleer.server.core.alerting.AlertScope;
|
||||||
|
import com.cameleer.server.core.alerting.ConditionKind;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class AgentStateEvaluator implements ConditionEvaluator<AgentStateCondition> {
|
||||||
|
|
||||||
|
private final AgentRegistryService registry;
|
||||||
|
|
||||||
|
public AgentStateEvaluator(AgentRegistryService registry) {
|
||||||
|
this.registry = registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ConditionKind kind() { return ConditionKind.AGENT_STATE; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public EvalResult evaluate(AgentStateCondition c, AlertRule rule, EvalContext ctx) {
|
||||||
|
AgentState target = AgentState.valueOf(c.state());
|
||||||
|
Instant cutoff = ctx.now().minusSeconds(c.forSeconds());
|
||||||
|
|
||||||
|
List<AgentInfo> hits = registry.findAll().stream()
|
||||||
|
.filter(a -> matchesScope(a, c.scope()))
|
||||||
|
.filter(a -> a.state() == target)
|
||||||
|
.filter(a -> a.lastHeartbeat() != null && a.lastHeartbeat().isBefore(cutoff))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (hits.isEmpty()) return EvalResult.Clear.INSTANCE;
|
||||||
|
|
||||||
|
AgentInfo first = hits.get(0);
|
||||||
|
return new EvalResult.Firing(
|
||||||
|
(double) hits.size(), null,
|
||||||
|
Map.of(
|
||||||
|
"agent", Map.of(
|
||||||
|
"id", first.instanceId(),
|
||||||
|
"name", first.displayName(),
|
||||||
|
"state", first.state().name()
|
||||||
|
),
|
||||||
|
"app", Map.of("slug", first.applicationId())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean matchesScope(AgentInfo a, AlertScope s) {
|
||||||
|
if (s == null) return true;
|
||||||
|
if (s.appSlug() != null && !s.appSlug().equals(a.applicationId())) return false;
|
||||||
|
if (s.agentId() != null && !s.agentId().equals(a.instanceId())) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.cameleer.server.app.alerting.eval;
|
||||||
|
|
||||||
|
import com.cameleer.server.core.agent.AgentInfo;
|
||||||
|
import com.cameleer.server.core.agent.AgentRegistryService;
|
||||||
|
import com.cameleer.server.core.agent.AgentState;
|
||||||
|
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;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class AgentStateEvaluatorTest {
|
||||||
|
|
||||||
|
private AgentRegistryService registry;
|
||||||
|
private AgentStateEvaluator eval;
|
||||||
|
|
||||||
|
private static final UUID ENV_ID = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||||
|
private static final UUID RULE_ID = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||||
|
private static final Instant NOW = Instant.parse("2026-04-19T10:00:00Z");
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
registry = mock(AgentRegistryService.class);
|
||||||
|
eval = new AgentStateEvaluator(registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AlertRule ruleWith(AlertCondition condition) {
|
||||||
|
return new AlertRule(RULE_ID, ENV_ID, "test", null,
|
||||||
|
AlertSeverity.WARNING, true, condition.kind(), condition,
|
||||||
|
60, 0, 0, null, null, List.of(), List.of(),
|
||||||
|
null, null, null, Map.of(), null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void firesWhenAgentInTargetStateForScope() {
|
||||||
|
when(registry.findAll()).thenReturn(List.of(
|
||||||
|
new AgentInfo("a1", "Agent1", "orders", ENV_ID.toString(), "1.0",
|
||||||
|
List.of(), Map.of(), AgentState.DEAD,
|
||||||
|
NOW.minusSeconds(200), NOW.minusSeconds(120), null)
|
||||||
|
));
|
||||||
|
var condition = new AgentStateCondition(new AlertScope("orders", null, null), "DEAD", 60);
|
||||||
|
var rule = ruleWith(condition);
|
||||||
|
EvalResult r = eval.evaluate(condition, rule, new EvalContext("default", NOW, new TickCache()));
|
||||||
|
assertThat(r).isInstanceOf(EvalResult.Firing.class);
|
||||||
|
var firing = (EvalResult.Firing) r;
|
||||||
|
assertThat(firing.currentValue()).isEqualTo(1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clearWhenNoMatchingAgents() {
|
||||||
|
when(registry.findAll()).thenReturn(List.of(
|
||||||
|
new AgentInfo("a1", "Agent1", "orders", ENV_ID.toString(), "1.0",
|
||||||
|
List.of(), Map.of(), AgentState.LIVE,
|
||||||
|
NOW.minusSeconds(200), NOW.minusSeconds(10), null)
|
||||||
|
));
|
||||||
|
var condition = new AgentStateCondition(new AlertScope("orders", null, null), "DEAD", 60);
|
||||||
|
var rule = ruleWith(condition);
|
||||||
|
EvalResult r = eval.evaluate(condition, rule, new EvalContext("default", NOW, new TickCache()));
|
||||||
|
assertThat(r).isEqualTo(EvalResult.Clear.INSTANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clearWhenAgentInStateBelowForSecondsCutoff() {
|
||||||
|
// Agent is DEAD but only 30s ago — forSeconds=60 → not yet long enough
|
||||||
|
when(registry.findAll()).thenReturn(List.of(
|
||||||
|
new AgentInfo("a1", "Agent1", "orders", ENV_ID.toString(), "1.0",
|
||||||
|
List.of(), Map.of(), AgentState.DEAD,
|
||||||
|
NOW.minusSeconds(200), NOW.minusSeconds(30), null)
|
||||||
|
));
|
||||||
|
var condition = new AgentStateCondition(new AlertScope("orders", null, null), "DEAD", 60);
|
||||||
|
var rule = ruleWith(condition);
|
||||||
|
EvalResult r = eval.evaluate(condition, rule, new EvalContext("default", NOW, new TickCache()));
|
||||||
|
assertThat(r).isEqualTo(EvalResult.Clear.INSTANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void kindIsAgentState() {
|
||||||
|
assertThat(eval.kind()).isEqualTo(ConditionKind.AGENT_STATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scopeFilterByAgentId() {
|
||||||
|
when(registry.findAll()).thenReturn(List.of(
|
||||||
|
new AgentInfo("a1", "Agent1", "orders", ENV_ID.toString(), "1.0",
|
||||||
|
List.of(), Map.of(), AgentState.DEAD,
|
||||||
|
NOW.minusSeconds(200), NOW.minusSeconds(120), null),
|
||||||
|
new AgentInfo("a2", "Agent2", "orders", ENV_ID.toString(), "1.0",
|
||||||
|
List.of(), Map.of(), AgentState.DEAD,
|
||||||
|
NOW.minusSeconds(200), NOW.minusSeconds(120), null)
|
||||||
|
));
|
||||||
|
// filter to only a1
|
||||||
|
var condition = new AgentStateCondition(new AlertScope("orders", null, "a1"), "DEAD", 60);
|
||||||
|
EvalResult r = eval.evaluate(condition, ruleWith(condition), new EvalContext("default", NOW, new TickCache()));
|
||||||
|
assertThat(r).isInstanceOf(EvalResult.Firing.class);
|
||||||
|
assertThat(((EvalResult.Firing) r).currentValue()).isEqualTo(1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user