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,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("");
}
}