diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluator.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluator.java new file mode 100644 index 00000000..eac4e351 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluator.java @@ -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 { + + 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() + ) + ); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluatorTest.java new file mode 100644 index 00000000..ea9a586b --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluatorTest.java @@ -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 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); + } +}