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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user