feat(alerting): NotificationContextBuilder for template context maps

Builds the Mustache context map from AlertRule + AlertInstance + Environment.
Always emits env/rule/alert subtrees; conditionally emits kind-specific
subtrees (agent, app, route, exchange, log, metric, deployment) based on
rule.conditionKind(). Missing instance.context() keys resolve to empty
string. alert.link prefixed with uiOrigin when non-null. 11 unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 19:27:12 +02:00
parent 92a74e7b8d
commit 1c74ab8541
2 changed files with 339 additions and 0 deletions

View File

@@ -0,0 +1,122 @@
package com.cameleer.server.app.alerting.notify;
import com.cameleer.server.core.alerting.AlertInstance;
import com.cameleer.server.core.alerting.AlertRule;
import com.cameleer.server.core.runtime.Environment;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Builds the Mustache template context map from an AlertRule + AlertInstance + Environment.
* <p>
* Always present: {@code env}, {@code rule}, {@code alert}.
* Conditionally present based on {@code rule.conditionKind()}:
* <ul>
* <li>AGENT_STATE → {@code agent}, {@code app}</li>
* <li>DEPLOYMENT_STATE → {@code deployment}, {@code app}</li>
* <li>ROUTE_METRIC → {@code route}, {@code app}</li>
* <li>EXCHANGE_MATCH → {@code exchange}, {@code app}, {@code route}</li>
* <li>LOG_PATTERN → {@code log}, {@code app}</li>
* <li>JVM_METRIC → {@code metric}, {@code agent}, {@code app}</li>
* </ul>
* Values absent from {@code instance.context()} render as empty string so Mustache templates
* remain valid even for env-wide rules that have no app/route scope.
*/
@Component
public class NotificationContextBuilder {
public Map<String, Object> build(AlertRule rule, AlertInstance instance, Environment env, String uiOrigin) {
Map<String, Object> ctx = new LinkedHashMap<>();
// --- env subtree ---
ctx.put("env", Map.of(
"slug", env.slug(),
"id", env.id().toString()
));
// --- rule subtree ---
ctx.put("rule", Map.of(
"id", rule.id().toString(),
"name", rule.name(),
"severity", rule.severity().name(),
"description", rule.description() == null ? "" : rule.description()
));
// --- alert subtree ---
String base = uiOrigin == null ? "" : uiOrigin;
ctx.put("alert", Map.of(
"id", instance.id().toString(),
"state", instance.state().name(),
"firedAt", instance.firedAt().toString(),
"resolvedAt", instance.resolvedAt() == null ? "" : instance.resolvedAt().toString(),
"ackedBy", instance.ackedBy() == null ? "" : instance.ackedBy(),
"link", base + "/alerts/inbox/" + instance.id(),
"currentValue", instance.currentValue() == null ? "" : instance.currentValue().toString(),
"threshold", instance.threshold() == null ? "" : instance.threshold().toString()
));
// --- per-kind conditional subtrees ---
if (rule.conditionKind() != null) {
switch (rule.conditionKind()) {
case AGENT_STATE -> {
ctx.put("agent", subtree(instance, "agent.id", "agent.name", "agent.state"));
ctx.put("app", subtree(instance, "app.slug", "app.id"));
}
case DEPLOYMENT_STATE -> {
ctx.put("deployment", subtree(instance, "deployment.id", "deployment.status"));
ctx.put("app", subtree(instance, "app.slug", "app.id"));
}
case ROUTE_METRIC -> {
ctx.put("route", subtree(instance, "route.id", "route.uri"));
ctx.put("app", subtree(instance, "app.slug", "app.id"));
}
case EXCHANGE_MATCH -> {
ctx.put("exchange", subtree(instance, "exchange.id", "exchange.status"));
ctx.put("app", subtree(instance, "app.slug", "app.id"));
ctx.put("route", subtree(instance, "route.id", "route.uri"));
}
case LOG_PATTERN -> {
ctx.put("log", subtree(instance, "log.pattern", "log.matchCount"));
ctx.put("app", subtree(instance, "app.slug", "app.id"));
}
case JVM_METRIC -> {
ctx.put("metric", subtree(instance, "metric.name", "metric.value"));
ctx.put("agent", subtree(instance, "agent.id", "agent.name"));
ctx.put("app", subtree(instance, "app.slug", "app.id"));
}
}
}
return ctx;
}
/**
* Extracts a flat subtree from {@code instance.context()} using dotted key paths.
* Each path like {@code "agent.id"} becomes the leaf key {@code "id"} in the returned map.
* Missing or null values are stored as empty string.
*/
private Map<String, Object> subtree(AlertInstance instance, String... dottedPaths) {
Map<String, Object> sub = new LinkedHashMap<>();
Map<String, Object> ic = instance.context();
for (String path : dottedPaths) {
String leafKey = path.contains(".") ? path.substring(path.lastIndexOf('.') + 1) : path;
Object val = resolveContext(ic, path);
sub.put(leafKey, val == null ? "" : val.toString());
}
return sub;
}
@SuppressWarnings("unchecked")
private Object resolveContext(Map<String, Object> ctx, String path) {
if (ctx == null) return null;
String[] parts = path.split("\\.");
Object current = ctx.get(parts[0]);
for (int i = 1; i < parts.length; i++) {
if (!(current instanceof Map)) return null;
current = ((Map<String, Object>) current).get(parts[i]);
}
return current;
}
}