feat(alerting): sealed AlertCondition hierarchy with Jackson deduction

This commit is contained in:
hsiegeln
2026-04-19 18:42:04 +02:00
parent 530bc32040
commit 56a7b6de7d
9 changed files with 195 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<String, String> attributes) {
public ExchangeFilter { attributes = attributes == null ? Map.of() : Map.copyOf(attributes); }
}
}

View File

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

View File

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

View File

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

View File

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