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