feat(alerting): ROUTE_METRIC evaluator
P95_LATENCY_MS maps to avgDurationMs (ExecutionStats has no p95 bucket). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
|||||||
|
package com.cameleer.server.app.alerting.eval;
|
||||||
|
|
||||||
|
import com.cameleer.server.core.alerting.AlertRule;
|
||||||
|
import com.cameleer.server.core.alerting.ConditionKind;
|
||||||
|
import com.cameleer.server.core.alerting.RouteMetricCondition;
|
||||||
|
import com.cameleer.server.core.runtime.EnvironmentRepository;
|
||||||
|
import com.cameleer.server.core.search.ExecutionStats;
|
||||||
|
import com.cameleer.server.core.storage.StatsStore;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class RouteMetricEvaluator implements ConditionEvaluator<RouteMetricCondition> {
|
||||||
|
|
||||||
|
private final StatsStore statsStore;
|
||||||
|
private final EnvironmentRepository envRepo;
|
||||||
|
|
||||||
|
public RouteMetricEvaluator(StatsStore statsStore, EnvironmentRepository envRepo) {
|
||||||
|
this.statsStore = statsStore;
|
||||||
|
this.envRepo = envRepo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ConditionKind kind() { return ConditionKind.ROUTE_METRIC; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public EvalResult evaluate(RouteMetricCondition c, AlertRule rule, EvalContext ctx) {
|
||||||
|
Instant from = ctx.now().minusSeconds(c.windowSeconds());
|
||||||
|
Instant to = ctx.now();
|
||||||
|
|
||||||
|
String envSlug = envRepo.findById(rule.environmentId())
|
||||||
|
.map(e -> e.slug())
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
String appSlug = c.scope() != null ? c.scope().appSlug() : null;
|
||||||
|
String routeId = c.scope() != null ? c.scope().routeId() : null;
|
||||||
|
|
||||||
|
ExecutionStats stats;
|
||||||
|
if (routeId != null) {
|
||||||
|
stats = statsStore.statsForRoute(from, to, routeId, appSlug, envSlug);
|
||||||
|
} else if (appSlug != null) {
|
||||||
|
stats = statsStore.statsForApp(from, to, appSlug, envSlug);
|
||||||
|
} else {
|
||||||
|
stats = statsStore.stats(from, to, envSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
double actual = switch (c.metric()) {
|
||||||
|
case ERROR_RATE -> errorRate(stats);
|
||||||
|
// ExecutionStats has no p95 field; avgDurationMs is the closest available proxy
|
||||||
|
case P95_LATENCY_MS -> (double) stats.avgDurationMs();
|
||||||
|
case P99_LATENCY_MS -> (double) stats.p99LatencyMs();
|
||||||
|
case THROUGHPUT -> (double) stats.totalCount();
|
||||||
|
case ERROR_COUNT -> (double) stats.failedCount();
|
||||||
|
};
|
||||||
|
|
||||||
|
boolean fire = switch (c.comparator()) {
|
||||||
|
case GT -> actual > c.threshold();
|
||||||
|
case GTE -> actual >= c.threshold();
|
||||||
|
case LT -> actual < c.threshold();
|
||||||
|
case LTE -> actual <= c.threshold();
|
||||||
|
case EQ -> actual == c.threshold();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!fire) return EvalResult.Clear.INSTANCE;
|
||||||
|
|
||||||
|
return new EvalResult.Firing(actual, c.threshold(),
|
||||||
|
Map.of(
|
||||||
|
"route", Map.of("id", routeId == null ? "" : routeId),
|
||||||
|
"app", Map.of("slug", appSlug == null ? "" : appSlug)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private double errorRate(ExecutionStats s) {
|
||||||
|
long total = s.totalCount();
|
||||||
|
return total == 0 ? 0.0 : (double) s.failedCount() / total;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.cameleer.server.app.alerting.eval;
|
||||||
|
|
||||||
|
import com.cameleer.server.core.alerting.*;
|
||||||
|
import com.cameleer.server.core.runtime.Environment;
|
||||||
|
import com.cameleer.server.core.runtime.EnvironmentRepository;
|
||||||
|
import com.cameleer.server.core.search.ExecutionStats;
|
||||||
|
import com.cameleer.server.core.storage.StatsStore;
|
||||||
|
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.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class RouteMetricEvaluatorTest {
|
||||||
|
|
||||||
|
private StatsStore statsStore;
|
||||||
|
private EnvironmentRepository envRepo;
|
||||||
|
private RouteMetricEvaluator 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() {
|
||||||
|
statsStore = mock(StatsStore.class);
|
||||||
|
envRepo = mock(EnvironmentRepository.class);
|
||||||
|
eval = new RouteMetricEvaluator(statsStore, envRepo);
|
||||||
|
|
||||||
|
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, null);
|
||||||
|
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env));
|
||||||
|
}
|
||||||
|
|
||||||
|
private AlertRule ruleWith(AlertCondition condition) {
|
||||||
|
return new AlertRule(RULE_ID, ENV_ID, "test", null,
|
||||||
|
AlertSeverity.CRITICAL, true, condition.kind(), condition,
|
||||||
|
60, 0, 0, null, null, List.of(), List.of(),
|
||||||
|
null, null, null, Map.of(), null, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExecutionStats stats(long total, long failed, long p99) {
|
||||||
|
return new ExecutionStats(total, failed, 100L, p99, 0L, 0L, 0L, 0L, 0L, 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void firesWhenP99ExceedsThreshold() {
|
||||||
|
var condition = new RouteMetricCondition(
|
||||||
|
new AlertScope("orders", null, null),
|
||||||
|
RouteMetric.P99_LATENCY_MS, Comparator.GT, 2000.0, 300);
|
||||||
|
when(statsStore.statsForApp(any(), any(), eq("orders"), eq("prod")))
|
||||||
|
.thenReturn(stats(100, 5, 2500));
|
||||||
|
|
||||||
|
EvalResult r = eval.evaluate(condition, ruleWith(condition), new EvalContext("default", NOW, new TickCache()));
|
||||||
|
assertThat(r).isInstanceOf(EvalResult.Firing.class);
|
||||||
|
var f = (EvalResult.Firing) r;
|
||||||
|
assertThat(f.currentValue()).isEqualTo(2500.0);
|
||||||
|
assertThat(f.threshold()).isEqualTo(2000.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clearWhenP99BelowThreshold() {
|
||||||
|
var condition = new RouteMetricCondition(
|
||||||
|
new AlertScope("orders", null, null),
|
||||||
|
RouteMetric.P99_LATENCY_MS, Comparator.GT, 2000.0, 300);
|
||||||
|
when(statsStore.statsForApp(any(), any(), eq("orders"), eq("prod")))
|
||||||
|
.thenReturn(stats(100, 5, 1500));
|
||||||
|
|
||||||
|
EvalResult r = eval.evaluate(condition, ruleWith(condition), new EvalContext("default", NOW, new TickCache()));
|
||||||
|
assertThat(r).isEqualTo(EvalResult.Clear.INSTANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void firesOnErrorRate() {
|
||||||
|
// 50/100 = 50% error rate, threshold 0.3 GT
|
||||||
|
var condition = new RouteMetricCondition(
|
||||||
|
new AlertScope("orders", null, null),
|
||||||
|
RouteMetric.ERROR_RATE, Comparator.GT, 0.3, 300);
|
||||||
|
when(statsStore.statsForApp(any(), any(), eq("orders"), eq("prod")))
|
||||||
|
.thenReturn(stats(100, 50, 500));
|
||||||
|
|
||||||
|
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(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void errorRateZeroWhenNoExecutions() {
|
||||||
|
var condition = new RouteMetricCondition(
|
||||||
|
new AlertScope("orders", null, null),
|
||||||
|
RouteMetric.ERROR_RATE, Comparator.GT, 0.1, 300);
|
||||||
|
when(statsStore.statsForApp(any(), any(), eq("orders"), eq("prod")))
|
||||||
|
.thenReturn(stats(0, 0, 0));
|
||||||
|
|
||||||
|
EvalResult r = eval.evaluate(condition, ruleWith(condition), new EvalContext("default", NOW, new TickCache()));
|
||||||
|
assertThat(r).isEqualTo(EvalResult.Clear.INSTANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void routeScopedUsesStatsForRoute() {
|
||||||
|
var condition = new RouteMetricCondition(
|
||||||
|
new AlertScope("orders", "direct:process", null),
|
||||||
|
RouteMetric.THROUGHPUT, Comparator.LT, 10.0, 300);
|
||||||
|
when(statsStore.statsForRoute(any(), any(), eq("direct:process"), eq("orders"), eq("prod")))
|
||||||
|
.thenReturn(stats(5, 0, 100));
|
||||||
|
|
||||||
|
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(5.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void envWideScopeUsesGlobalStats() {
|
||||||
|
var condition = new RouteMetricCondition(
|
||||||
|
new AlertScope(null, null, null),
|
||||||
|
RouteMetric.ERROR_COUNT, Comparator.GTE, 5.0, 300);
|
||||||
|
when(statsStore.stats(any(), any(), eq("prod")))
|
||||||
|
.thenReturn(stats(100, 10, 200));
|
||||||
|
|
||||||
|
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(10.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void kindIsRouteMetric() {
|
||||||
|
assertThat(eval.kind()).isEqualTo(ConditionKind.ROUTE_METRIC);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user