This is an automated email from the ASF dual-hosted git repository. lizhimins pushed a commit to branch rocketmq-studio in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
commit 1aa3c23dc715340b61b2d475d9652026a84a61ac Author: aias00 <[email protected]> AuthorDate: Thu Jul 23 22:51:35 2026 -0700 feat: add alert rules YAML endpoint (#507) Add alert rules Prometheus YAML export endpoint with default RocketMQ alert template fallback. --- .../studio/ops/alert/AlertRulesYamlVO.java | 28 ++++ .../rocketmq/studio/ops/alert/AlertService.java | 155 +++++++++++++++++++++ .../studio/ops/alert/LegacyAlertController.java | 36 +++++ .../studio/ops/alert/AlertServiceTest.java | 37 +++++ .../ops/alert/LegacyAlertControllerTest.java | 55 ++++++++ 5 files changed, 311 insertions(+) diff --git a/server/src/main/java/com/rocketmq/studio/ops/alert/AlertRulesYamlVO.java b/server/src/main/java/com/rocketmq/studio/ops/alert/AlertRulesYamlVO.java new file mode 100644 index 00000000..472b6a57 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/alert/AlertRulesYamlVO.java @@ -0,0 +1,28 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.rocketmq.studio.ops.alert; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AlertRulesYamlVO { + private String rules; +} diff --git a/server/src/main/java/com/rocketmq/studio/ops/alert/AlertService.java b/server/src/main/java/com/rocketmq/studio/ops/alert/AlertService.java index 7336481e..9de4f2c5 100644 --- a/server/src/main/java/com/rocketmq/studio/ops/alert/AlertService.java +++ b/server/src/main/java/com/rocketmq/studio/ops/alert/AlertService.java @@ -20,6 +20,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -36,6 +37,32 @@ public class AlertService { return alertRepository.findAllRules(); } + public String exportPrometheusRulesYaml() { + List<AlertRuleVO> rules = alertRepository.findAllRules(); + List<PrometheusAlertRule> prometheusRules = rules.isEmpty() + ? defaultPrometheusRules() + : rules.stream().map(this::toPrometheusRule).toList(); + + StringBuilder yaml = new StringBuilder(); + yaml.append("groups:\n"); + int index = 1; + for (PrometheusAlertRule rule : prometheusRules) { + yaml.append(" - name: ").append(rule.group()).append('\n'); + yaml.append(" rules:\n"); + yaml.append(" # Rule ").append(index++).append(": ").append(rule.alert()).append('\n'); + yaml.append(" - alert: ").append(rule.alert()).append('\n'); + yaml.append(" expr: ").append(rule.expr()).append('\n'); + yaml.append(" for: ").append(rule.duration()).append('\n'); + yaml.append(" labels:\n"); + yaml.append(" severity: ").append(rule.severity()).append('\n'); + yaml.append(" team: ").append(rule.team()).append('\n'); + yaml.append(" annotations:\n"); + yaml.append(" summary: \"").append(escapeYaml(rule.summary())).append("\"\n"); + yaml.append(" description: \"").append(escapeYaml(rule.description())).append("\"\n"); + } + return yaml.toString(); + } + public AlertRuleVO createRule(AlertRuleVO rule) { log.info("Creating alert rule: {}", rule.getName()); @@ -90,4 +117,132 @@ public class AlertService { log.info("Clearing acknowledged system alerts"); return alertRepository.deleteAcknowledgedAlerts(); } + + private List<PrometheusAlertRule> defaultPrometheusRules() { + List<PrometheusAlertRule> rules = new ArrayList<>(); + rules.add(rule("rocketmq-broker.rules", "RocketMQBrokerDown", "up{job=~\".*rocketmq.*\"} == 0", "1m", + "critical", "broker", "RocketMQ broker is down", + "A RocketMQ broker scrape target has been unavailable for more than 1 minute.")); + rules.add(rule("rocketmq-consumer.rules", "RocketMQConsumerLagHigh", + "rocketmq_consumer_lag_messages > 100000", "5m", + "warning", "consumer", "Consumer lag is high", + "A consumer group has accumulated more than 100000 messages.")); + rules.add(rule("rocketmq-consumer.rules", "RocketMQConsumerLagCritical", + "rocketmq_consumer_lag_messages > 1000000", "5m", + "critical", "consumer", "Consumer lag is critical", + "A consumer group has accumulated more than 1000000 messages.")); + rules.add(rule("rocketmq-client.rules", "RocketMQProducerSendLatencyHigh", + "rocketmq_producer_send_to_back_rt > 1000", "5m", + "warning", "client", "Producer send latency is high", + "Producer send-to-broker latency has stayed above 1000 ms.")); + rules.add(rule("rocketmq-broker.rules", "RocketMQProcessorWatermarkHigh", + "rocketmq_processor_watermark > 80", "5m", + "warning", "broker", "Processor watermark is high", + "Broker processor watermark is above 80 percent.")); + rules.add(rule("rocketmq-topic.rules", "RocketMQMessageInDrop", + "rate(rocketmq_messages_in_total[5m]) == 0", "10m", + "info", "topic", "No incoming messages", + "No incoming messages have been observed for 10 minutes.")); + rules.add(rule("rocketmq-consumer.rules", "RocketMQMessageOutDrop", + "rate(rocketmq_messages_out_total[5m]) == 0", "10m", + "info", "consumer", "No outgoing messages", + "No outgoing messages have been observed for 10 minutes.")); + return rules; + } + + private PrometheusAlertRule rule(String group, String alert, String expr, String duration, + String severity, String team, String summary, String description) { + return new PrometheusAlertRule(group, alert, expr, duration, severity, team, summary, description); + } + + private PrometheusAlertRule toPrometheusRule(AlertRuleVO rule) { + String team = inferTeam(rule.getMetric()); + return new PrometheusAlertRule( + groupName(team), + alertName(rule), + expression(rule), + duration(rule), + "warning", + team, + summary(rule), + description(rule)); + } + + private String groupName(String team) { + if ("client".equals(team)) { + return "rocketmq-client.rules"; + } + if ("consumer".equals(team)) { + return "rocketmq-consumer.rules"; + } + if ("topic".equals(team)) { + return "rocketmq-topic.rules"; + } + return "rocketmq-broker.rules"; + } + + private String alertName(AlertRuleVO rule) { + return hasText(rule.getName()) ? rule.getName().replaceAll("[^A-Za-z0-9_]", "") : "RocketMQAlert"; + } + + private String expression(AlertRuleVO rule) { + String metric = hasText(rule.getMetric()) ? rule.getMetric() : "rocketmq_consumer_lag_messages"; + String operator = hasText(rule.getOperator()) ? rule.getOperator() : ">"; + return metric + " " + operator + " " + formatThreshold(rule.getThreshold()); + } + + private String formatThreshold(double threshold) { + if (threshold == Math.rint(threshold)) { + return Long.toString((long) threshold); + } + return Double.toString(threshold); + } + + private String duration(AlertRuleVO rule) { + return hasText(rule.getDuration()) ? rule.getDuration() : "5m"; + } + + private String inferTeam(String metric) { + if (!hasText(metric)) { + return "broker"; + } + if (metric.contains("consumer") || metric.contains("lag")) { + return "consumer"; + } + if (metric.contains("producer") || metric.contains("client")) { + return "client"; + } + if (metric.contains("topic") || metric.contains("messages_in") || metric.contains("messages_out")) { + return "topic"; + } + return "broker"; + } + + private String summary(AlertRuleVO rule) { + String description = rule.getDescription(); + if (hasText(description) && description.contains(" - ")) { + return description.substring(0, description.indexOf(" - ")); + } + return hasText(rule.getName()) ? rule.getName() : "RocketMQ alert"; + } + + private String description(AlertRuleVO rule) { + String description = rule.getDescription(); + if (hasText(description) && description.contains(" - ")) { + return description.substring(description.indexOf(" - ") + 3); + } + return hasText(description) ? description : "RocketMQ alert condition matched."; + } + + private String escapeYaml(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + private record PrometheusAlertRule(String group, String alert, String expr, String duration, + String severity, String team, String summary, String description) { + } } diff --git a/server/src/main/java/com/rocketmq/studio/ops/alert/LegacyAlertController.java b/server/src/main/java/com/rocketmq/studio/ops/alert/LegacyAlertController.java new file mode 100644 index 00000000..88d24d80 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/alert/LegacyAlertController.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.rocketmq.studio.ops.alert; + +import com.rocketmq.studio.common.domain.Result; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/alert") +@RequiredArgsConstructor +public class LegacyAlertController { + + private final AlertService alertService; + + @GetMapping("/rules") + public Result<AlertRulesYamlVO> getRules() { + return Result.ok(new AlertRulesYamlVO(alertService.exportPrometheusRulesYaml())); + } +} diff --git a/server/src/test/java/com/rocketmq/studio/ops/alert/AlertServiceTest.java b/server/src/test/java/com/rocketmq/studio/ops/alert/AlertServiceTest.java index 2fd408c2..c32d4448 100644 --- a/server/src/test/java/com/rocketmq/studio/ops/alert/AlertServiceTest.java +++ b/server/src/test/java/com/rocketmq/studio/ops/alert/AlertServiceTest.java @@ -70,6 +70,43 @@ class AlertServiceTest { assertThat(result).isEmpty(); } + @Test + void exportPrometheusRulesYamlShouldReturnDefaultRulesWhenRepositoryIsEmpty() { + when(alertRepository.findAllRules()).thenReturn(Collections.emptyList()); + + String result = alertService.exportPrometheusRulesYaml(); + + assertThat(result) + .contains("groups:") + .contains("# Rule 1: RocketMQBrokerDown") + .contains("up{job=~\".*rocketmq.*\"} == 0") + .contains("rocketmq_consumer_lag_messages > 100000") + .contains("rocketmq_producer_send_to_back_rt > 1000") + .contains("severity: critical"); + } + + @Test + void exportPrometheusRulesYamlShouldConvertConfiguredRules() { + AlertRuleVO rule = AlertRuleVO.builder() + .name("High Lag Alert") + .metric("rocketmq_consumer_lag_messages") + .operator(">") + .threshold(5000) + .duration("3m") + .description("Lag too high") + .build(); + when(alertRepository.findAllRules()).thenReturn(List.of(rule)); + + String result = alertService.exportPrometheusRulesYaml(); + + assertThat(result) + .contains("rocketmq-consumer.rules") + .contains("# Rule 1: HighLagAlert") + .contains("expr: rocketmq_consumer_lag_messages > 5000") + .contains("for: 3m") + .contains("description: \"Lag too high\""); + } + @Test void createRuleShouldAssignId() { AlertRuleVO input = AlertRuleVO.builder().name("New Rule").metric("tps") diff --git a/server/src/test/java/com/rocketmq/studio/ops/alert/LegacyAlertControllerTest.java b/server/src/test/java/com/rocketmq/studio/ops/alert/LegacyAlertControllerTest.java new file mode 100644 index 00000000..6c50ce2a --- /dev/null +++ b/server/src/test/java/com/rocketmq/studio/ops/alert/LegacyAlertControllerTest.java @@ -0,0 +1,55 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.rocketmq.studio.ops.alert; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(LegacyAlertController.class) +@AutoConfigureMockMvc(addFilters = false) +class LegacyAlertControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AlertService alertService; + + @Test + void getRulesShouldReturnYamlPayloadForLegacyStudioPage() throws Exception { + String yaml = "groups:\n - name: rocketmq-broker.rules\n rules:\n" + + " # Rule 1: RocketMQBrokerDown\n - alert: RocketMQBrokerDown\n"; + when(alertService.exportPrometheusRulesYaml()).thenReturn(yaml); + + mockMvc.perform(get("/api/alert/rules")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.rules").value(yaml)); + + verify(alertService).exportPrometheusRulesYaml(); + } +}
