feat(alerting): EXCHANGE_MATCH evaluator with per-exchange + count modes

PER_EXCHANGE returns EvalResult.Batch(List<Firing>); last Firing carries
_nextCursor (Instant) in its context map for the job to persist as
evalState.lastExchangeTs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 19:40:54 +02:00
parent 89db8bd1c5
commit f8cd3f3ee4
2 changed files with 353 additions and 0 deletions

View File

@@ -0,0 +1,149 @@
package com.cameleer.server.app.alerting.eval;
import com.cameleer.server.app.search.ClickHouseSearchIndex;
import com.cameleer.server.core.alerting.AlertMatchSpec;
import com.cameleer.server.core.alerting.AlertRule;
import com.cameleer.server.core.alerting.ConditionKind;
import com.cameleer.server.core.alerting.ExchangeMatchCondition;
import com.cameleer.server.core.alerting.FireMode;
import com.cameleer.server.core.runtime.EnvironmentRepository;
import com.cameleer.server.core.search.ExecutionSummary;
import com.cameleer.server.core.search.SearchRequest;
import com.cameleer.server.core.search.SearchResult;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Component
public class ExchangeMatchEvaluator implements ConditionEvaluator<ExchangeMatchCondition> {
private final ClickHouseSearchIndex searchIndex;
private final EnvironmentRepository envRepo;
public ExchangeMatchEvaluator(ClickHouseSearchIndex searchIndex, EnvironmentRepository envRepo) {
this.searchIndex = searchIndex;
this.envRepo = envRepo;
}
@Override
public ConditionKind kind() { return ConditionKind.EXCHANGE_MATCH; }
@Override
public EvalResult evaluate(ExchangeMatchCondition c, AlertRule rule, EvalContext ctx) {
String envSlug = envRepo.findById(rule.environmentId())
.map(e -> e.slug())
.orElse(null);
return switch (c.fireMode()) {
case COUNT_IN_WINDOW -> evaluateCount(c, rule, ctx, envSlug);
case PER_EXCHANGE -> evaluatePerExchange(c, rule, ctx, envSlug);
};
}
// ── COUNT_IN_WINDOW ───────────────────────────────────────────────────────
private EvalResult evaluateCount(ExchangeMatchCondition c, AlertRule rule,
EvalContext ctx, String envSlug) {
String appSlug = c.scope() != null ? c.scope().appSlug() : null;
String routeId = c.scope() != null ? c.scope().routeId() : null;
ExchangeMatchCondition.ExchangeFilter filter = c.filter();
var spec = new AlertMatchSpec(
ctx.tenantId(),
envSlug,
appSlug,
routeId,
filter != null ? filter.status() : null,
filter != null ? filter.attributes() : Map.of(),
ctx.now().minusSeconds(c.windowSeconds()),
ctx.now(),
null
);
long count = searchIndex.countExecutionsForAlerting(spec);
if (count <= c.threshold()) return EvalResult.Clear.INSTANCE;
return new EvalResult.Firing(
(double) count,
c.threshold().doubleValue(),
Map.of(
"app", Map.of("slug", appSlug == null ? "" : appSlug),
"route", Map.of("id", routeId == null ? "" : routeId)
)
);
}
// ── PER_EXCHANGE ──────────────────────────────────────────────────────────
private EvalResult evaluatePerExchange(ExchangeMatchCondition c, AlertRule rule,
EvalContext ctx, String envSlug) {
String appSlug = c.scope() != null ? c.scope().appSlug() : null;
String routeId = c.scope() != null ? c.scope().routeId() : null;
ExchangeMatchCondition.ExchangeFilter filter = c.filter();
// Resolve cursor from evalState
Instant cursor = null;
Object raw = rule.evalState().get("lastExchangeTs");
if (raw instanceof String s && !s.isBlank()) {
try { cursor = Instant.parse(s); } catch (Exception ignored) {}
} else if (raw instanceof Instant i) {
cursor = i;
}
// Build SearchRequest — use cursor as timeFrom so we only see exchanges after last run
var req = new SearchRequest(
filter != null ? filter.status() : null,
cursor, // timeFrom = cursor (or null for first run)
ctx.now(), // timeTo
null, null, null, // durationMin/Max, correlationId
null, null, null, null, // text variants
routeId,
null, // instanceId
null, // processorType
appSlug,
null, // instanceIds
0,
50,
"startTime",
"asc", // asc so we process oldest first
envSlug
);
SearchResult<ExecutionSummary> result = searchIndex.search(req);
List<ExecutionSummary> matches = result.data();
if (matches.isEmpty()) return new EvalResult.Batch(List.of());
// Find the latest startTime across all matches — becomes the next cursor
Instant latestTs = matches.stream()
.map(ExecutionSummary::startTime)
.max(Instant::compareTo)
.orElse(ctx.now());
List<EvalResult.Firing> firings = new ArrayList<>();
for (int i = 0; i < matches.size(); i++) {
ExecutionSummary ex = matches.get(i);
Map<String, Object> ctx2 = new HashMap<>();
ctx2.put("exchange", Map.of(
"id", ex.executionId(),
"routeId", ex.routeId() == null ? "" : ex.routeId(),
"status", ex.status() == null ? "" : ex.status(),
"startTime", ex.startTime() == null ? "" : ex.startTime().toString()
));
ctx2.put("app", Map.of("slug", ex.applicationId() == null ? "" : ex.applicationId()));
// Attach the next-cursor to the last firing so the job can extract it
if (i == matches.size() - 1) {
ctx2.put("_nextCursor", latestTs);
}
firings.add(new EvalResult.Firing(1.0, null, ctx2));
}
return new EvalResult.Batch(firings);
}
}