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;
}
}

View File

@@ -0,0 +1,217 @@
package com.cameleer.server.app.alerting.notify;
import com.cameleer.server.core.alerting.*;
import com.cameleer.server.core.runtime.Environment;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class NotificationContextBuilderTest {
private NotificationContextBuilder builder;
private static final UUID ENV_ID = UUID.fromString("11111111-1111-1111-1111-111111111111");
private static final UUID RULE_ID = UUID.fromString("22222222-2222-2222-2222-222222222222");
private static final UUID INST_ID = UUID.fromString("33333333-3333-3333-3333-333333333333");
@BeforeEach
void setUp() {
builder = new NotificationContextBuilder();
}
// ---- helpers ----
private Environment env() {
return new Environment(ENV_ID, "prod", "Production", true, true, Map.of(), 5, Instant.EPOCH);
}
private AlertRule rule(ConditionKind kind) {
AlertCondition condition = switch (kind) {
case ROUTE_METRIC -> new RouteMetricCondition(
new AlertScope("my-app", "route-1", null),
RouteMetric.ERROR_RATE, Comparator.GT, 0.1, 60);
case EXCHANGE_MATCH -> new ExchangeMatchCondition(
new AlertScope("my-app", "route-1", null),
new ExchangeMatchCondition.ExchangeFilter("FAILED", Map.of()),
FireMode.PER_EXCHANGE, null, null, 30);
case AGENT_STATE -> new AgentStateCondition(
new AlertScope(null, null, null),
"DEAD", 0);
case DEPLOYMENT_STATE -> new DeploymentStateCondition(
new AlertScope("my-app", null, null),
List.of("FAILED"));
case LOG_PATTERN -> new LogPatternCondition(
new AlertScope("my-app", null, null),
"ERROR", "OutOfMemory", 5, 60);
case JVM_METRIC -> new JvmMetricCondition(
new AlertScope(null, null, "agent-1"),
"heap.used", AggregationOp.MAX, Comparator.GT, 90.0, 300);
};
return new AlertRule(
RULE_ID, ENV_ID, "High error rate", "Alert description",
AlertSeverity.CRITICAL, true,
kind, condition,
60, 120, 30,
"{{rule.name}} fired", "Value: {{alert.currentValue}}",
List.of(), List.of(),
Instant.now(), null, Instant.now(),
Map.of(),
Instant.now(), "admin", Instant.now(), "admin"
);
}
private AlertInstance instance(Map<String, Object> ctx) {
return new AlertInstance(
INST_ID, RULE_ID, Map.of(), ENV_ID,
AlertState.FIRING, AlertSeverity.CRITICAL,
Instant.parse("2026-04-19T10:00:00Z"),
null, null, null, null,
false, 0.95, 0.1,
ctx, "Alert fired", "Some message",
List.of(), List.of(), List.of()
);
}
// ---- env / rule / alert subtrees always present ----
@Test
void envSubtree_alwaysPresent() {
var inst = instance(Map.of("route", Map.of("id", "route-1"), "app", Map.of("slug", "my-app")));
var ctx = builder.build(rule(ConditionKind.ROUTE_METRIC), inst, env(), null);
assertThat(ctx).containsKey("env");
@SuppressWarnings("unchecked") var env = (Map<String, Object>) ctx.get("env");
assertThat(env).containsEntry("slug", "prod")
.containsEntry("id", ENV_ID.toString());
}
@Test
void ruleSubtree_alwaysPresent() {
var inst = instance(Map.of());
var ctx = builder.build(rule(ConditionKind.AGENT_STATE), inst, env(), null);
@SuppressWarnings("unchecked") var ruleMap = (Map<String, Object>) ctx.get("rule");
assertThat(ruleMap).containsEntry("id", RULE_ID.toString())
.containsEntry("name", "High error rate")
.containsEntry("severity", "CRITICAL")
.containsEntry("description", "Alert description");
}
@Test
void alertSubtree_alwaysPresent() {
var inst = instance(Map.of());
var ctx = builder.build(rule(ConditionKind.AGENT_STATE), inst, env(), "https://ui.example.com");
@SuppressWarnings("unchecked") var alert = (Map<String, Object>) ctx.get("alert");
assertThat(alert).containsEntry("id", INST_ID.toString())
.containsEntry("state", "FIRING")
.containsEntry("firedAt", "2026-04-19T10:00:00Z")
.containsEntry("currentValue", "0.95")
.containsEntry("threshold", "0.1");
}
@Test
void alertLink_withUiOrigin() {
var inst = instance(Map.of());
var ctx = builder.build(rule(ConditionKind.AGENT_STATE), inst, env(), "https://ui.example.com");
@SuppressWarnings("unchecked") var alert = (Map<String, Object>) ctx.get("alert");
assertThat(alert.get("link")).isEqualTo("https://ui.example.com/alerts/inbox/" + INST_ID);
}
@Test
void alertLink_withoutUiOrigin_isRelative() {
var inst = instance(Map.of());
var ctx = builder.build(rule(ConditionKind.AGENT_STATE), inst, env(), null);
@SuppressWarnings("unchecked") var alert = (Map<String, Object>) ctx.get("alert");
assertThat(alert.get("link")).isEqualTo("/alerts/inbox/" + INST_ID);
}
// ---- conditional subtrees by kind ----
@Test
void exchangeMatch_hasExchangeAppRoute_butNotLogOrMetric() {
var ctx = Map.<String, Object>of(
"exchange", Map.of("id", "ex-99", "status", "FAILED"),
"app", Map.of("slug", "my-app", "id", "app-uuid"),
"route", Map.of("id", "route-1", "uri", "direct:start"));
var result = builder.build(rule(ConditionKind.EXCHANGE_MATCH), instance(ctx), env(), null);
assertThat(result).containsKeys("exchange", "app", "route")
.doesNotContainKey("log")
.doesNotContainKey("metric")
.doesNotContainKey("agent");
}
@Test
void agentState_hasAgentAndApp_butNotRoute() {
var ctx = Map.<String, Object>of(
"agent", Map.of("id", "a-42", "name", "my-agent", "state", "DEAD"),
"app", Map.of("slug", "my-app", "id", "app-uuid"));
var result = builder.build(rule(ConditionKind.AGENT_STATE), instance(ctx), env(), null);
assertThat(result).containsKeys("agent", "app")
.doesNotContainKey("route")
.doesNotContainKey("exchange")
.doesNotContainKey("log")
.doesNotContainKey("metric");
}
@Test
void routeMetric_hasRouteAndApp_butNotAgentOrExchange() {
var ctx = Map.<String, Object>of(
"route", Map.of("id", "route-1", "uri", "timer://tick"),
"app", Map.of("slug", "my-app", "id", "app-uuid"));
var result = builder.build(rule(ConditionKind.ROUTE_METRIC), instance(ctx), env(), null);
assertThat(result).containsKeys("route", "app")
.doesNotContainKey("agent")
.doesNotContainKey("exchange")
.doesNotContainKey("log");
}
@Test
void logPattern_hasLogAndApp_butNotRouteOrAgent() {
var ctx = Map.<String, Object>of(
"log", Map.of("pattern", "ERROR", "matchCount", "7"),
"app", Map.of("slug", "my-app", "id", "app-uuid"));
var result = builder.build(rule(ConditionKind.LOG_PATTERN), instance(ctx), env(), null);
assertThat(result).containsKeys("log", "app")
.doesNotContainKey("route")
.doesNotContainKey("agent")
.doesNotContainKey("metric");
}
@Test
void jvmMetric_hasMetricAgentAndApp() {
var ctx = Map.<String, Object>of(
"metric", Map.of("name", "heap.used", "value", "88.5"),
"agent", Map.of("id", "a-42", "name", "my-agent"),
"app", Map.of("slug", "my-app", "id", "app-uuid"));
var result = builder.build(rule(ConditionKind.JVM_METRIC), instance(ctx), env(), null);
assertThat(result).containsKeys("metric", "agent", "app")
.doesNotContainKey("route")
.doesNotContainKey("exchange")
.doesNotContainKey("log");
}
@Test
void missingContextValues_emitEmptyString() {
// Empty context — subtree values should all be empty string, not null.
var inst = instance(Map.of());
var result = builder.build(rule(ConditionKind.ROUTE_METRIC), inst, env(), null);
@SuppressWarnings("unchecked") var route = (Map<String, Object>) result.get("route");
assertThat(route.get("id")).isEqualTo("");
assertThat(route.get("uri")).isEqualTo("");
}
}