From 89db8bd1c5398bfc63a757627f6d6af8ba9f822a Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:38:48 +0200 Subject: [PATCH] feat(alerting): JVM_METRIC evaluator Co-Authored-By: Claude Sonnet 4.6 --- .../app/alerting/eval/JvmMetricEvaluator.java | 77 +++++++++ .../alerting/eval/JvmMetricEvaluatorTest.java | 157 ++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/JvmMetricEvaluator.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/JvmMetricEvaluatorTest.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/JvmMetricEvaluator.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/JvmMetricEvaluator.java new file mode 100644 index 00000000..575e7a9c --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/JvmMetricEvaluator.java @@ -0,0 +1,77 @@ +package com.cameleer.server.app.alerting.eval; + +import com.cameleer.server.core.alerting.AggregationOp; +import com.cameleer.server.core.alerting.AlertRule; +import com.cameleer.server.core.alerting.ConditionKind; +import com.cameleer.server.core.alerting.JvmMetricCondition; +import com.cameleer.server.core.storage.MetricsQueryStore; +import com.cameleer.server.core.storage.model.MetricTimeSeries; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.OptionalDouble; + +@Component +public class JvmMetricEvaluator implements ConditionEvaluator { + + private final MetricsQueryStore metricsStore; + + public JvmMetricEvaluator(MetricsQueryStore metricsStore) { + this.metricsStore = metricsStore; + } + + @Override + public ConditionKind kind() { return ConditionKind.JVM_METRIC; } + + @Override + public EvalResult evaluate(JvmMetricCondition c, AlertRule rule, EvalContext ctx) { + String agentId = c.scope() != null ? c.scope().agentId() : null; + if (agentId == null) return EvalResult.Clear.INSTANCE; + + Map> series = metricsStore.queryTimeSeries( + agentId, + List.of(c.metric()), + ctx.now().minusSeconds(c.windowSeconds()), + ctx.now(), + 1 + ); + + List buckets = series.get(c.metric()); + if (buckets == null || buckets.isEmpty()) return EvalResult.Clear.INSTANCE; + + OptionalDouble aggregated = aggregate(buckets, c.aggregation()); + if (aggregated.isEmpty()) return EvalResult.Clear.INSTANCE; + + double actual = aggregated.getAsDouble(); + + 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( + "metric", c.metric(), + "agent", Map.of("id", agentId) + ) + ); + } + + private OptionalDouble aggregate(List buckets, AggregationOp op) { + return switch (op) { + case MAX -> buckets.stream().mapToDouble(MetricTimeSeries.Bucket::value).max(); + case MIN -> buckets.stream().mapToDouble(MetricTimeSeries.Bucket::value).min(); + case AVG -> buckets.stream().mapToDouble(MetricTimeSeries.Bucket::value).average(); + case LATEST -> buckets.stream() + .max(java.util.Comparator.comparing(MetricTimeSeries.Bucket::time)) + .map(b -> OptionalDouble.of(b.value())) + .orElse(OptionalDouble.empty()); + }; + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/JvmMetricEvaluatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/JvmMetricEvaluatorTest.java new file mode 100644 index 00000000..0dd872de --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/JvmMetricEvaluatorTest.java @@ -0,0 +1,157 @@ +package com.cameleer.server.app.alerting.eval; + +import com.cameleer.server.core.alerting.*; +import com.cameleer.server.core.storage.MetricsQueryStore; +import com.cameleer.server.core.storage.model.MetricTimeSeries; +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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class JvmMetricEvaluatorTest { + + private MetricsQueryStore metricsStore; + private JvmMetricEvaluator 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() { + metricsStore = mock(MetricsQueryStore.class); + eval = new JvmMetricEvaluator(metricsStore); + } + + 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 MetricTimeSeries.Bucket bucket(double value) { + return new MetricTimeSeries.Bucket(NOW.minusSeconds(10), value); + } + + @Test + void firesWhenMaxExceedsThreshold() { + var condition = new JvmMetricCondition( + new AlertScope(null, null, "agent-1"), + "heap_used_percent", AggregationOp.MAX, Comparator.GT, 90.0, 300); + + when(metricsStore.queryTimeSeries(eq("agent-1"), eq(List.of("heap_used_percent")), any(), any(), eq(1))) + .thenReturn(Map.of("heap_used_percent", List.of(bucket(95.0)))); + + 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(95.0); + assertThat(f.threshold()).isEqualTo(90.0); + } + + @Test + void clearWhenMaxBelowThreshold() { + var condition = new JvmMetricCondition( + new AlertScope(null, null, "agent-1"), + "heap_used_percent", AggregationOp.MAX, Comparator.GT, 90.0, 300); + + when(metricsStore.queryTimeSeries(eq("agent-1"), eq(List.of("heap_used_percent")), any(), any(), eq(1))) + .thenReturn(Map.of("heap_used_percent", List.of(bucket(80.0)))); + + EvalResult r = eval.evaluate(condition, ruleWith(condition), new EvalContext("default", NOW, new TickCache())); + assertThat(r).isEqualTo(EvalResult.Clear.INSTANCE); + } + + @Test + void aggregatesMultipleBucketsWithMax() { + var condition = new JvmMetricCondition( + new AlertScope(null, null, "agent-1"), + "heap_used_percent", AggregationOp.MAX, Comparator.GT, 90.0, 300); + + when(metricsStore.queryTimeSeries(eq("agent-1"), eq(List.of("heap_used_percent")), any(), any(), eq(1))) + .thenReturn(Map.of("heap_used_percent", + List.of(bucket(70.0), bucket(95.0), bucket(85.0)))); + + 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(95.0); + } + + @Test + void aggregatesWithMin() { + var condition = new JvmMetricCondition( + new AlertScope(null, null, "agent-1"), + "heap_free_percent", AggregationOp.MIN, Comparator.LT, 10.0, 300); + + when(metricsStore.queryTimeSeries(eq("agent-1"), eq(List.of("heap_free_percent")), any(), any(), eq(1))) + .thenReturn(Map.of("heap_free_percent", + List.of(bucket(20.0), bucket(8.0), bucket(15.0)))); + + 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(8.0); + } + + @Test + void aggregatesWithAvg() { + var condition = new JvmMetricCondition( + new AlertScope(null, null, "agent-1"), + "cpu_usage", AggregationOp.AVG, Comparator.GT, 50.0, 300); + + when(metricsStore.queryTimeSeries(eq("agent-1"), eq(List.of("cpu_usage")), any(), any(), eq(1))) + .thenReturn(Map.of("cpu_usage", + List.of(bucket(40.0), bucket(60.0), bucket(80.0)))); + + EvalResult r = eval.evaluate(condition, ruleWith(condition), new EvalContext("default", NOW, new TickCache())); + // avg = 60.0 > 50 → fires + assertThat(r).isInstanceOf(EvalResult.Firing.class); + assertThat(((EvalResult.Firing) r).currentValue()).isEqualTo(60.0); + } + + @Test + void aggregatesWithLatest() { + var condition = new JvmMetricCondition( + new AlertScope(null, null, "agent-1"), + "thread_count", AggregationOp.LATEST, Comparator.GT, 200.0, 300); + + Instant t1 = NOW.minusSeconds(30); + Instant t2 = NOW.minusSeconds(10); + when(metricsStore.queryTimeSeries(eq("agent-1"), eq(List.of("thread_count")), any(), any(), eq(1))) + .thenReturn(Map.of("thread_count", List.of( + new MetricTimeSeries.Bucket(t1, 180.0), + new MetricTimeSeries.Bucket(t2, 250.0) + ))); + + 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(250.0); + } + + @Test + void clearWhenNoBucketsReturned() { + var condition = new JvmMetricCondition( + new AlertScope(null, null, "agent-1"), + "heap_used_percent", AggregationOp.MAX, Comparator.GT, 90.0, 300); + + when(metricsStore.queryTimeSeries(eq("agent-1"), eq(List.of("heap_used_percent")), any(), any(), eq(1))) + .thenReturn(Map.of()); + + EvalResult r = eval.evaluate(condition, ruleWith(condition), new EvalContext("default", NOW, new TickCache())); + assertThat(r).isEqualTo(EvalResult.Clear.INSTANCE); + } + + @Test + void kindIsJvmMetric() { + assertThat(eval.kind()).isEqualTo(ConditionKind.JVM_METRIC); + } +}