feat(alerting): JVM_METRIC evaluator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<JvmMetricCondition> {
|
||||||
|
|
||||||
|
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<String, List<MetricTimeSeries.Bucket>> series = metricsStore.queryTimeSeries(
|
||||||
|
agentId,
|
||||||
|
List.of(c.metric()),
|
||||||
|
ctx.now().minusSeconds(c.windowSeconds()),
|
||||||
|
ctx.now(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
List<MetricTimeSeries.Bucket> 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<MetricTimeSeries.Bucket> 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());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user