feat(alerting): LOG_PATTERN evaluator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 19:37:33 +02:00
parent 07d0386bf2
commit 17d2be5638
2 changed files with 205 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
package com.cameleer.server.app.alerting.eval;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.core.alerting.AlertRule;
import com.cameleer.server.core.alerting.ConditionKind;
import com.cameleer.server.core.alerting.LogPatternCondition;
import com.cameleer.server.core.runtime.EnvironmentRepository;
import com.cameleer.server.core.search.LogSearchRequest;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@Component
public class LogPatternEvaluator implements ConditionEvaluator<LogPatternCondition> {
private final ClickHouseLogStore logStore;
private final EnvironmentRepository envRepo;
public LogPatternEvaluator(ClickHouseLogStore logStore, EnvironmentRepository envRepo) {
this.logStore = logStore;
this.envRepo = envRepo;
}
@Override
public ConditionKind kind() { return ConditionKind.LOG_PATTERN; }
@Override
public EvalResult evaluate(LogPatternCondition c, AlertRule rule, EvalContext ctx) {
String envSlug = envRepo.findById(rule.environmentId())
.map(e -> e.slug())
.orElse(null);
String appSlug = c.scope() != null ? c.scope().appSlug() : null;
Instant from = ctx.now().minusSeconds(c.windowSeconds());
Instant to = ctx.now();
// Build a stable cache key so identical queries within the same tick are coalesced.
String cacheKey = String.join("|",
envSlug == null ? "" : envSlug,
appSlug == null ? "" : appSlug,
c.level() == null ? "" : c.level(),
c.pattern() == null ? "" : c.pattern(),
from.toString(),
to.toString()
);
long count = ctx.tickCache().getOrCompute(cacheKey, () -> {
var req = new LogSearchRequest(
c.pattern(),
c.level() != null ? List.of(c.level()) : List.of(),
appSlug,
null, // instanceId
null, // exchangeId
null, // logger
envSlug,
null, // sources
from,
to,
null, // cursor
1, // limit (count query; value irrelevant)
"desc" // sort
);
return logStore.countLogs(req);
});
if (count <= c.threshold()) return EvalResult.Clear.INSTANCE;
return new EvalResult.Firing(
(double) count,
(double) c.threshold(),
Map.of(
"app", Map.of("slug", appSlug == null ? "" : appSlug),
"pattern", c.pattern() == null ? "" : c.pattern(),
"level", c.level() == null ? "" : c.level()
)
);
}
}

View File

@@ -0,0 +1,124 @@
package com.cameleer.server.app.alerting.eval;
import com.cameleer.server.app.search.ClickHouseLogStore;
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.LogSearchRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
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.Mockito.*;
class LogPatternEvaluatorTest {
private ClickHouseLogStore logStore;
private EnvironmentRepository envRepo;
private LogPatternEvaluator 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() {
logStore = mock(ClickHouseLogStore.class);
envRepo = mock(EnvironmentRepository.class);
eval = new LogPatternEvaluator(logStore, 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.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 firesWhenCountExceedsThreshold() {
var condition = new LogPatternCondition(
new AlertScope("orders", null, null), "ERROR", "OutOfMemory", 5, 300);
when(logStore.countLogs(any())).thenReturn(7L);
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(7.0);
assertThat(f.threshold()).isEqualTo(5.0);
}
@Test
void clearWhenCountBelowThreshold() {
var condition = new LogPatternCondition(
new AlertScope("orders", null, null), "ERROR", "OutOfMemory", 5, 300);
when(logStore.countLogs(any())).thenReturn(3L);
EvalResult r = eval.evaluate(condition, ruleWith(condition), new EvalContext("default", NOW, new TickCache()));
assertThat(r).isEqualTo(EvalResult.Clear.INSTANCE);
}
@Test
void clearWhenCountEqualsThreshold() {
// threshold is GT (strictly greater), so equal should be Clear
var condition = new LogPatternCondition(
new AlertScope("orders", null, null), "ERROR", "OutOfMemory", 5, 300);
when(logStore.countLogs(any())).thenReturn(5L);
EvalResult r = eval.evaluate(condition, ruleWith(condition), new EvalContext("default", NOW, new TickCache()));
assertThat(r).isEqualTo(EvalResult.Clear.INSTANCE);
}
@Test
void passesCorrectFieldsToLogStore() {
var condition = new LogPatternCondition(
new AlertScope("orders", null, null), "WARN", "timeout", 1, 120);
when(logStore.countLogs(any())).thenReturn(2L);
eval.evaluate(condition, ruleWith(condition), new EvalContext("default", NOW, new TickCache()));
ArgumentCaptor<LogSearchRequest> captor = ArgumentCaptor.forClass(LogSearchRequest.class);
verify(logStore).countLogs(captor.capture());
LogSearchRequest req = captor.getValue();
assertThat(req.application()).isEqualTo("orders");
assertThat(req.levels()).contains("WARN");
assertThat(req.q()).isEqualTo("timeout");
assertThat(req.environment()).isEqualTo("prod");
assertThat(req.from()).isEqualTo(NOW.minusSeconds(120));
assertThat(req.to()).isEqualTo(NOW);
}
@Test
void tickCacheCoalescesDuplicateQueries() {
var condition = new LogPatternCondition(
new AlertScope("orders", null, null), "ERROR", "NPE", 1, 300);
when(logStore.countLogs(any())).thenReturn(2L);
var cache = new TickCache();
var ctx = new EvalContext("default", NOW, cache);
var rule = ruleWith(condition);
eval.evaluate(condition, rule, ctx);
eval.evaluate(condition, rule, ctx); // same tick, same key
// countLogs should only be called once due to TickCache coalescing
verify(logStore, times(1)).countLogs(any());
}
@Test
void kindIsLogPattern() {
assertThat(eval.kind()).isEqualTo(ConditionKind.LOG_PATTERN);
}
}