diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/RouteMetricEvaluator.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/RouteMetricEvaluator.java new file mode 100644 index 00000000..f04f333d --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/RouteMetricEvaluator.java @@ -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 { + + 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; + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/RouteMetricEvaluatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/RouteMetricEvaluatorTest.java new file mode 100644 index 00000000..3baf34fe --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/RouteMetricEvaluatorTest.java @@ -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); + } +}