diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilder.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilder.java new file mode 100644 index 00000000..41f0ce31 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilder.java @@ -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. + *

+ * Always present: {@code env}, {@code rule}, {@code alert}. + * Conditionally present based on {@code rule.conditionKind()}: + *

+ * 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 build(AlertRule rule, AlertInstance instance, Environment env, String uiOrigin) { + Map 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 subtree(AlertInstance instance, String... dottedPaths) { + Map sub = new LinkedHashMap<>(); + Map 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 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) current).get(parts[i]); + } + return current; + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderTest.java new file mode 100644 index 00000000..a046922c --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderTest.java @@ -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 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) 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) 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) 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) 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) ctx.get("alert"); + assertThat(alert.get("link")).isEqualTo("/alerts/inbox/" + INST_ID); + } + + // ---- conditional subtrees by kind ---- + + @Test + void exchangeMatch_hasExchangeAppRoute_butNotLogOrMetric() { + var ctx = Map.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.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.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.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.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) result.get("route"); + assertThat(route.get("id")).isEqualTo(""); + assertThat(route.get("uri")).isEqualTo(""); + } +}