From 56a7b6de7db36cf51e25a62720e6fae7883eb5b3 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:42:04 +0200 Subject: [PATCH] feat(alerting): sealed AlertCondition hierarchy with Jackson deduction --- .../core/alerting/AgentStateCondition.java | 9 +++ .../server/core/alerting/AlertCondition.java | 23 ++++++ .../server/core/alerting/AlertScope.java | 3 + .../alerting/DeploymentStateCondition.java | 12 +++ .../core/alerting/ExchangeMatchCondition.java | 30 ++++++++ .../core/alerting/JvmMetricCondition.java | 15 ++++ .../core/alerting/LogPatternCondition.java | 14 ++++ .../core/alerting/RouteMetricCondition.java | 14 ++++ .../core/alerting/AlertConditionJsonTest.java | 75 +++++++++++++++++++ 9 files changed, 195 insertions(+) create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AgentStateCondition.java create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertCondition.java create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/DeploymentStateCondition.java create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/ExchangeMatchCondition.java create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/JvmMetricCondition.java create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/LogPatternCondition.java create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/RouteMetricCondition.java create mode 100644 cameleer-server-core/src/test/java/com/cameleer/server/core/alerting/AlertConditionJsonTest.java diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AgentStateCondition.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AgentStateCondition.java new file mode 100644 index 00000000..9d5f3c92 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AgentStateCondition.java @@ -0,0 +1,9 @@ +package com.cameleer.server.core.alerting; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record AgentStateCondition(AlertScope scope, String state, int forSeconds) implements AlertCondition { + @Override + @JsonProperty(value = "kind", access = JsonProperty.Access.READ_ONLY) + public ConditionKind kind() { return ConditionKind.AGENT_STATE; } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertCondition.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertCondition.java new file mode 100644 index 00000000..008fd78a --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertCondition.java @@ -0,0 +1,23 @@ +package com.cameleer.server.core.alerting; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind", include = JsonTypeInfo.As.EXISTING_PROPERTY, visible = true) +@JsonSubTypes({ + @JsonSubTypes.Type(value = RouteMetricCondition.class, name = "ROUTE_METRIC"), + @JsonSubTypes.Type(value = ExchangeMatchCondition.class, name = "EXCHANGE_MATCH"), + @JsonSubTypes.Type(value = AgentStateCondition.class, name = "AGENT_STATE"), + @JsonSubTypes.Type(value = DeploymentStateCondition.class, name = "DEPLOYMENT_STATE"), + @JsonSubTypes.Type(value = LogPatternCondition.class, name = "LOG_PATTERN"), + @JsonSubTypes.Type(value = JvmMetricCondition.class, name = "JVM_METRIC") +}) +public sealed interface AlertCondition permits + RouteMetricCondition, ExchangeMatchCondition, AgentStateCondition, + DeploymentStateCondition, LogPatternCondition, JvmMetricCondition { + + @JsonProperty("kind") + ConditionKind kind(); + AlertScope scope(); +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertScope.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertScope.java index b69c9002..1ccc9b2a 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertScope.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertScope.java @@ -1,5 +1,8 @@ package com.cameleer.server.core.alerting; +import com.fasterxml.jackson.annotation.JsonIgnore; + public record AlertScope(String appSlug, String routeId, String agentId) { + @JsonIgnore public boolean isEnvWide() { return appSlug == null && routeId == null && agentId == null; } } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/DeploymentStateCondition.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/DeploymentStateCondition.java new file mode 100644 index 00000000..400c572d --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/DeploymentStateCondition.java @@ -0,0 +1,12 @@ +package com.cameleer.server.core.alerting; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record DeploymentStateCondition(AlertScope scope, List states) implements AlertCondition { + public DeploymentStateCondition { states = List.copyOf(states); } + @Override + @JsonProperty(value = "kind", access = JsonProperty.Access.READ_ONLY) + public ConditionKind kind() { return ConditionKind.DEPLOYMENT_STATE; } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/ExchangeMatchCondition.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/ExchangeMatchCondition.java new file mode 100644 index 00000000..0ea59eb0 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/ExchangeMatchCondition.java @@ -0,0 +1,30 @@ +package com.cameleer.server.core.alerting; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Map; + +public record ExchangeMatchCondition( + AlertScope scope, + ExchangeFilter filter, + FireMode fireMode, + Integer threshold, // required when COUNT_IN_WINDOW; null for PER_EXCHANGE + Integer windowSeconds, // required when COUNT_IN_WINDOW + Integer perExchangeLingerSeconds // required when PER_EXCHANGE +) implements AlertCondition { + + public ExchangeMatchCondition { + if (fireMode == FireMode.COUNT_IN_WINDOW && (threshold == null || windowSeconds == null)) + throw new IllegalArgumentException("COUNT_IN_WINDOW requires threshold + windowSeconds"); + if (fireMode == FireMode.PER_EXCHANGE && perExchangeLingerSeconds == null) + throw new IllegalArgumentException("PER_EXCHANGE requires perExchangeLingerSeconds"); + } + + @Override + @JsonProperty(value = "kind", access = JsonProperty.Access.READ_ONLY) + public ConditionKind kind() { return ConditionKind.EXCHANGE_MATCH; } + + public record ExchangeFilter(String status, Map attributes) { + public ExchangeFilter { attributes = attributes == null ? Map.of() : Map.copyOf(attributes); } + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/JvmMetricCondition.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/JvmMetricCondition.java new file mode 100644 index 00000000..3055b46c --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/JvmMetricCondition.java @@ -0,0 +1,15 @@ +package com.cameleer.server.core.alerting; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record JvmMetricCondition( + AlertScope scope, + String metric, + AggregationOp aggregation, + Comparator comparator, + double threshold, + int windowSeconds) implements AlertCondition { + @Override + @JsonProperty(value = "kind", access = JsonProperty.Access.READ_ONLY) + public ConditionKind kind() { return ConditionKind.JVM_METRIC; } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/LogPatternCondition.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/LogPatternCondition.java new file mode 100644 index 00000000..0b78be88 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/LogPatternCondition.java @@ -0,0 +1,14 @@ +package com.cameleer.server.core.alerting; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record LogPatternCondition( + AlertScope scope, + String level, + String pattern, + int threshold, + int windowSeconds) implements AlertCondition { + @Override + @JsonProperty(value = "kind", access = JsonProperty.Access.READ_ONLY) + public ConditionKind kind() { return ConditionKind.LOG_PATTERN; } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/RouteMetricCondition.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/RouteMetricCondition.java new file mode 100644 index 00000000..ad0cb650 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/RouteMetricCondition.java @@ -0,0 +1,14 @@ +package com.cameleer.server.core.alerting; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record RouteMetricCondition( + AlertScope scope, + RouteMetric metric, + Comparator comparator, + double threshold, + int windowSeconds) implements AlertCondition { + @Override + @JsonProperty(value = "kind", access = JsonProperty.Access.READ_ONLY) + public ConditionKind kind() { return ConditionKind.ROUTE_METRIC; } +} diff --git a/cameleer-server-core/src/test/java/com/cameleer/server/core/alerting/AlertConditionJsonTest.java b/cameleer-server-core/src/test/java/com/cameleer/server/core/alerting/AlertConditionJsonTest.java new file mode 100644 index 00000000..b17b056b --- /dev/null +++ b/cameleer-server-core/src/test/java/com/cameleer/server/core/alerting/AlertConditionJsonTest.java @@ -0,0 +1,75 @@ +package com.cameleer.server.core.alerting; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class AlertConditionJsonTest { + + private final ObjectMapper om = new ObjectMapper(); + + @Test + void roundtripRouteMetric() throws Exception { + var c = new RouteMetricCondition( + new AlertScope("orders", "route-1", null), + RouteMetric.P99_LATENCY_MS, Comparator.GT, 2000.0, 300); + String json = om.writeValueAsString((AlertCondition) c); + AlertCondition parsed = om.readValue(json, AlertCondition.class); + assertThat(parsed).isInstanceOf(RouteMetricCondition.class); + assertThat(parsed.kind()).isEqualTo(ConditionKind.ROUTE_METRIC); + } + + @Test + void roundtripExchangeMatchPerExchange() throws Exception { + var c = new ExchangeMatchCondition( + new AlertScope("orders", null, null), + new ExchangeMatchCondition.ExchangeFilter("FAILED", Map.of("type","payment")), + FireMode.PER_EXCHANGE, null, null, 300); + String json = om.writeValueAsString((AlertCondition) c); + AlertCondition parsed = om.readValue(json, AlertCondition.class); + assertThat(parsed).isInstanceOf(ExchangeMatchCondition.class); + } + + @Test + void roundtripExchangeMatchCountInWindow() throws Exception { + var c = new ExchangeMatchCondition( + new AlertScope("orders", null, null), + new ExchangeMatchCondition.ExchangeFilter("FAILED", Map.of()), + FireMode.COUNT_IN_WINDOW, 5, 900, null); + AlertCondition parsed = om.readValue(om.writeValueAsString((AlertCondition) c), AlertCondition.class); + assertThat(((ExchangeMatchCondition) parsed).threshold()).isEqualTo(5); + } + + @Test + void roundtripAgentState() throws Exception { + var c = new AgentStateCondition(new AlertScope("orders", null, null), "DEAD", 60); + AlertCondition parsed = om.readValue(om.writeValueAsString((AlertCondition) c), AlertCondition.class); + assertThat(parsed).isInstanceOf(AgentStateCondition.class); + } + + @Test + void roundtripDeploymentState() throws Exception { + var c = new DeploymentStateCondition(new AlertScope("orders", null, null), List.of("FAILED","DEGRADED")); + AlertCondition parsed = om.readValue(om.writeValueAsString((AlertCondition) c), AlertCondition.class); + assertThat(parsed).isInstanceOf(DeploymentStateCondition.class); + } + + @Test + void roundtripLogPattern() throws Exception { + var c = new LogPatternCondition(new AlertScope("orders", null, null), + "ERROR", "TimeoutException", 5, 900); + AlertCondition parsed = om.readValue(om.writeValueAsString((AlertCondition) c), AlertCondition.class); + assertThat(parsed).isInstanceOf(LogPatternCondition.class); + } + + @Test + void roundtripJvmMetric() throws Exception { + var c = new JvmMetricCondition(new AlertScope("orders", null, null), + "heap_used_percent", AggregationOp.MAX, Comparator.GT, 90.0, 300); + AlertCondition parsed = om.readValue(om.writeValueAsString((AlertCondition) c), AlertCondition.class); + assertThat(parsed).isInstanceOf(JvmMetricCondition.class); + } +}