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


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new f5519e873 fix(alert): stop silently retargeting native metrics in 
Prometheus export (#3987)
f5519e873 is described below

commit f5519e8736490a512e3d3994fdfe3c90e43b2ff9
Author: Zhao Jianing <[email protected]>
AuthorDate: Mon Sep 7 19:41:04 2026 +0800

    fix(alert): stop silently retargeting native metrics in Prometheus export 
(#3987)
    
    exportPrometheusRulesYaml translated every metric that failed the
    Prometheus name pattern to rocketmq_consumer_lag_messages. All twelve
    native Studio metric names defined by NativeAlertRulePolicy contain
    dots, so each of them failed the pattern: exporting a business rule
    like 'dlq.message.count > 100' or 'topic.backlog.total > 50000'
    produced an alert expression on rocketmq_consumer_lag_messages
    instead. Users who loaded the exported file into Prometheus were
    silently monitoring consumer lag where they had configured DLQ size or
    topic backlog alerts.
    
    Translate consumer.lag.total to its exact exporter equivalent
    (rocketmq_consumer_lag_messages) via an explicit mapping, and skip
    rules whose native metric has no rocketmq-exporter equivalent,
    recording each skipped rule as a comment so the omission is visible in
    the exported file. When every enabled business rule is skipped the file
    still stays loadable (groups: []). The existing fallback for malformed
    metric strings (injection hardening) is unchanged.
    
    Signed-off-by: zjncs <[email protected]>
---
 .../cluster/metrics/MetricProfileService.java      |  15 +++
 .../rocketmq/studio/ops/alert/AlertService.java    | 106 +++++++++++++++------
 .../studio/ops/alert/NativeAlertRulePolicy.java    |   4 +
 .../ops/alert/AlertServiceDefaultRulesTest.java    |   8 +-
 .../studio/ops/alert/AlertServiceTest.java         |  73 +++++++++++++-
 5 files changed, 175 insertions(+), 31 deletions(-)

diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricProfileService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricProfileService.java
index 24990ae07..bfd2b8dbb 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricProfileService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/MetricProfileService.java
@@ -22,6 +22,7 @@ import org.springframework.stereotype.Service;
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 
 @Service
 @RequiredArgsConstructor
@@ -57,6 +58,20 @@ public class MetricProfileService {
                         + "' for profile '" + profileId + "'"));
     }
 
+    /**
+     * Resolves the prometheus metric name for a semantic metric under the 
currently configured
+     * profile (the one {@link #listProfiles()} orders first). Returns empty 
when the active profile
+     * has no mapping for the semantic metric, so callers can treat it as 
unexportable rather than
+     * falling back to a hardcoded name that would be wrong for the deployed 
profile (e.g. consumer
+     * lag is {@code rocketmq_message_accumulation} on the 4.x exporter 
profile, not the 5.x name).
+     */
+    public Optional<String> resolveCurrentPrometheusMetric(String 
semanticMetric) {
+        return listProfiles().get(0).getMetrics().stream()
+                .filter(metric -> 
metric.getSemanticMetric().equals(semanticMetric))
+                .map(MetricProfileVO.MetricMappingVO::getPrometheusMetric)
+                .findFirst();
+    }
+
     private MetricProfileVO profile(MetricProfile profile,
                                     List<MetricProfileVO.MetricMappingVO> 
metrics) {
         return MetricProfileVO.builder()
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
index ab9f1e2e8..9a38cae45 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
@@ -16,6 +16,8 @@
  */
 package org.apache.rocketmq.studio.ops.alert;
 
+import org.apache.rocketmq.studio.cluster.metrics.MetricProfileService;
+import org.apache.rocketmq.studio.cluster.metrics.SemanticMetric;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.audit.OperationAuditService;
@@ -46,6 +48,11 @@ public class AlertService {
 
     private static final Set<String> VALID_OPERATORS = Set.of(">", ">=", "<", 
"<=", "==", "!=", "UNAVAILABLE");
     private static final Pattern METRIC_NAME_PATTERN = 
Pattern.compile("^[a-zA-Z_:][a-zA-Z0-9_:]*$");
+    // Native Studio metric names that have a rocketmq-exporter equivalent, 
mapped to the semantic
+    // metric whose profile-specific prometheus name MetricProfileService 
resolves at export time
+    // (so a 4.x deployment exports rocketmq_message_accumulation, not a 
hardcoded 5.x name).
+    private static final Map<String, String> NATIVE_METRIC_SEMANTIC = Map.of(
+            "consumer.lag.total", 
SemanticMetric.CONSUMER_LAG_MESSAGES.getKey());
     private static final Pattern DURATION_PATTERN = Pattern.compile(
             "^" + AlertRuleRequestDTO.PROMETHEUS_DURATION_REGEXP + "$");
 
@@ -53,6 +60,7 @@ public class AlertService {
     private final AlertStateRepository alertStateRepository;
     private final AlertRuleAssetService alertRuleAssetService;
     private final OperationAuditService operationAuditService;
+    private final MetricProfileService metricProfileService;
 
 
     public List<AlertRuleVO> listRules() {
@@ -94,38 +102,57 @@ public class AlertService {
                 .filter(AlertRuleVO::isEnabled)
                 .filter(rule -> resolveDomain(rule) == AlertDomain.BUSINESS)
                 .toList();
-        List<PrometheusAlertRule> prometheusRules = rules.isEmpty()
-                ? defaultPrometheusRules()
-                : rules.stream().map(this::toPrometheusRule).toList();
+        List<PrometheusAlertRule> prometheusRules = new ArrayList<>();
+        List<String> skippedRuleNotes = new ArrayList<>();
+        if (rules.isEmpty()) {
+            prometheusRules.addAll(defaultPrometheusRules());
+        } else {
+            for (AlertRuleVO rule : rules) {
+                if (isUnexportableNativeMetric(rule.getMetric())) {
+                    skippedRuleNotes.add("Skipped \"" + 
sanitizeCommentText(rule.getName()) + "\": native metric '"
+                            + rule.getMetric().trim() + "' has no equivalent 
in the rocketmq-exporter metric set");
+                    continue;
+                }
+                prometheusRules.add(toPrometheusRule(rule));
+            }
+        }
 
         StringBuilder yaml = new StringBuilder();
-        yaml.append("groups:\n");
-        int index = 1;
-        // Prometheus requires each group name to be unique, so rules sharing 
a group must be
-        // emitted under a single "  - name:" block instead of one block per 
rule.
-        Map<String, List<PrometheusAlertRule>> rulesByGroup = new 
LinkedHashMap<>();
-        for (PrometheusAlertRule rule : prometheusRules) {
-            rulesByGroup.computeIfAbsent(rule.group(), key -> new 
ArrayList<>()).add(rule);
-        }
-        for (Map.Entry<String, List<PrometheusAlertRule>> group : 
rulesByGroup.entrySet()) {
-            yaml.append("  - name: ").append(group.getKey()).append('\n');
-            yaml.append("    rules:\n");
-            Set<String> usedAlertNames = new HashSet<>();
-            for (PrometheusAlertRule rule : group.getValue()) {
-                String uniqueAlertName = ensureUniqueAlertName(rule.alert(), 
usedAlertNames);
-                yaml.append("      # Rule ").append(index++).append(": 
").append(uniqueAlertName).append('\n');
-                yaml.append("      - alert: 
").append(uniqueAlertName).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(escapeDoubleQuotedValue(rule.summary())).append("\"\n");
-                yaml.append("          description: 
\"").append(escapeDoubleQuotedValue(rule.description()))
-                        .append("\"\n");
+        if (prometheusRules.isEmpty()) {
+            // keep the file loadable when every enabled business rule uses a 
native-only metric
+            yaml.append("groups: []\n");
+        } else {
+            yaml.append("groups:\n");
+            int index = 1;
+            // Prometheus requires each group name to be unique, so rules 
sharing a group must be
+            // emitted under a single "  - name:" block instead of one block 
per rule.
+            Map<String, List<PrometheusAlertRule>> rulesByGroup = new 
LinkedHashMap<>();
+            for (PrometheusAlertRule rule : prometheusRules) {
+                rulesByGroup.computeIfAbsent(rule.group(), key -> new 
ArrayList<>()).add(rule);
+            }
+            for (Map.Entry<String, List<PrometheusAlertRule>> group : 
rulesByGroup.entrySet()) {
+                yaml.append("  - name: ").append(group.getKey()).append('\n');
+                yaml.append("    rules:\n");
+                Set<String> usedAlertNames = new HashSet<>();
+                for (PrometheusAlertRule rule : group.getValue()) {
+                    String uniqueAlertName = 
ensureUniqueAlertName(rule.alert(), usedAlertNames);
+                    yaml.append("      # Rule ").append(index++).append(": 
").append(uniqueAlertName).append('\n');
+                    yaml.append("      - alert: 
").append(uniqueAlertName).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(escapeDoubleQuotedValue(rule.summary())).append("\"\n");
+                    yaml.append("          description: 
\"").append(escapeDoubleQuotedValue(rule.description()))
+                            .append("\"\n");
+                }
             }
         }
+        for (String note : skippedRuleNotes) {
+            yaml.append("# ").append(note).append('\n');
+        }
         return yaml.toString();
     }
 
@@ -636,9 +663,32 @@ public class AlertService {
 
     private String validateMetric(String metric) {
         String normalized = hasText(metric) ? metric.trim() : 
"rocketmq_consumer_lag_messages";
+        String semantic = NATIVE_METRIC_SEMANTIC.get(normalized);
+        if (semantic != null) {
+            // Resolve the exporter name from the active metric profile 
instead of a hardcoded 5.x
+            // name, so a 4.x deployment exports rocketmq_message_accumulation 
for consumer lag.
+            return 
metricProfileService.resolveCurrentPrometheusMetric(semantic)
+                    .orElse("rocketmq_consumer_lag_messages");
+        }
         return METRIC_NAME_PATTERN.matcher(normalized).matches() ? normalized 
: "rocketmq_consumer_lag_messages";
     }
 
+    private boolean isUnexportableNativeMetric(String metric) {
+        if (!hasText(metric)) {
+            return false;
+        }
+        String normalized = metric.trim();
+        if (!NativeAlertRulePolicy.isNativeMetric(normalized)) {
+            return false;
+        }
+        String semantic = NATIVE_METRIC_SEMANTIC.get(normalized);
+        return semantic == null || 
metricProfileService.resolveCurrentPrometheusMetric(semantic).isEmpty();
+    }
+
+    private String sanitizeCommentText(String value) {
+        return value == null ? "" : value.replaceAll("[\\r\\n]", " ");
+    }
+
     private String validateOperator(String operator) {
         String normalized = hasText(operator) ? operator.trim() : ">";
         return VALID_OPERATORS.contains(normalized) ? normalized : ">";
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertRulePolicy.java
 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertRulePolicy.java
index 84aee5fe0..36654aff2 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertRulePolicy.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/NativeAlertRulePolicy.java
@@ -49,6 +49,10 @@ final class NativeAlertRulePolicy {
     private NativeAlertRulePolicy() {
     }
 
+    static boolean isNativeMetric(String metric) {
+        return StringUtils.hasText(metric) && 
NATIVE_METRICS.containsKey(metric.trim());
+    }
+
     static void validate(AlertRuleVO rule) {
         validateChannels(rule);
         if (!StringUtils.hasText(rule.getMetric())) {
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceDefaultRulesTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceDefaultRulesTest.java
index cce9201d3..a31d3bce5 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceDefaultRulesTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceDefaultRulesTest.java
@@ -17,6 +17,8 @@
 package org.apache.rocketmq.studio.ops.alert;
 
 import org.apache.rocketmq.studio.audit.OperationAuditService;
+import org.apache.rocketmq.studio.cluster.metrics.MetricProfileService;
+import org.apache.rocketmq.studio.cluster.metrics.PrometheusProperties;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
 
@@ -39,7 +41,8 @@ class AlertServiceDefaultRulesTest {
         when(repository.findAllRules()).thenReturn(Collections.emptyList());
 
         AlertService service = new AlertService(repository, 
Mockito.mock(AlertStateRepository.class),
-                new AlertRuleAssetService(), 
Mockito.mock(OperationAuditService.class));
+                new AlertRuleAssetService(), 
Mockito.mock(OperationAuditService.class),
+                new MetricProfileService(new PrometheusProperties()));
         String yaml = service.exportPrometheusRulesYaml();
 
         int ruleCount = countRules(yaml);
@@ -52,7 +55,8 @@ class AlertServiceDefaultRulesTest {
         when(repository.findAllRules()).thenReturn(List.of());
 
         AlertService service = new AlertService(repository, 
Mockito.mock(AlertStateRepository.class),
-                new AlertRuleAssetService(), 
Mockito.mock(OperationAuditService.class));
+                new AlertRuleAssetService(), 
Mockito.mock(OperationAuditService.class),
+                new MetricProfileService(new PrometheusProperties()));
         String yaml = service.exportPrometheusRulesYaml();
 
         assertTrue(yaml.contains("rocketmq-broker.rules"));
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
index ef0e5a270..fa0d31c60 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
@@ -23,6 +23,8 @@ import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.domain.enums.AlertLevel;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.audit.OperationAuditService;
+import org.apache.rocketmq.studio.cluster.metrics.MetricProfileService;
+import org.apache.rocketmq.studio.cluster.metrics.PrometheusProperties;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
@@ -64,7 +66,7 @@ class AlertServiceTest {
     @org.junit.jupiter.api.BeforeEach
     void setUpTest() {
         alertService = new AlertService(alertRepository, alertStateRepository, 
new AlertRuleAssetService(),
-                operationAuditService);
+                operationAuditService, new MetricProfileService(new 
PrometheusProperties()));
     }
 
     @Test
@@ -426,6 +428,75 @@ class AlertServiceTest {
                 .doesNotContain("vector(1", "> 0 or", "5xyz");
     }
 
+    @Test
+    void 
exportPrometheusRulesYamlShouldSkipNativeMetricsWithoutExporterEquivalentTest() 
{
+        AlertRuleVO dlqRule = AlertRuleVO.builder()
+                .name("DLQ Flood")
+                .metric("dlq.message.count")
+                .operator(">")
+                .threshold(100)
+                .duration("5m")
+                .enabled(true)
+                .build();
+        AlertRuleVO lagRule = AlertRuleVO.builder()
+                .name("High Lag")
+                .metric("rocketmq_consumer_lag_messages")
+                .operator(">")
+                .threshold(2000)
+                .duration("5m")
+                .enabled(true)
+                .build();
+        when(alertRepository.findAllRules()).thenReturn(List.of(dlqRule, 
lagRule));
+
+        String result = alertService.exportPrometheusRulesYaml();
+
+        assertThat(result)
+                .contains("expr: rocketmq_consumer_lag_messages > 2000")
+                .doesNotContain("expr: rocketmq_consumer_lag_messages > 100")
+                .doesNotContain("- alert: DLQFlood")
+                .contains("# Skipped \"DLQ Flood\": native metric 
'dlq.message.count'");
+    }
+
+    @Test
+    void exportPrometheusRulesYamlShouldTranslateNativeConsumerLagMetricTest() 
{
+        AlertRuleVO rule = AlertRuleVO.builder()
+                .name("Native Lag Total")
+                .metric("consumer.lag.total")
+                .operator(">")
+                .threshold(5000)
+                .duration("3m")
+                .enabled(true)
+                .build();
+        when(alertRepository.findAllRules()).thenReturn(List.of(rule));
+
+        String result = alertService.exportPrometheusRulesYaml();
+
+        assertThat(result)
+                .contains("expr: rocketmq_consumer_lag_messages > 5000")
+                .contains("- alert: NativeLagTotal")
+                .contains("- name: rocketmq-consumer.rules");
+    }
+
+    @Test
+    void 
exportPrometheusRulesYamlShouldEmitEmptyGroupsWhenAllRulesUseUnexportableNativeMetricsTest()
 {
+        AlertRuleVO rule = AlertRuleVO.builder()
+                .name("Topic Backlog")
+                .metric("topic.backlog.total")
+                .operator(">")
+                .threshold(50000)
+                .duration("5m")
+                .enabled(true)
+                .build();
+        when(alertRepository.findAllRules()).thenReturn(List.of(rule));
+
+        String result = alertService.exportPrometheusRulesYaml();
+
+        assertThat(result)
+                .startsWith("groups: []\n")
+                .doesNotContain("rocketmq_consumer_lag_messages")
+                .contains("# Skipped \"Topic Backlog\": native metric 
'topic.backlog.total'");
+    }
+
     @Test
     void 
exportPrometheusRulesYamlShouldNormalizeSeverityIndependentlyOfDefaultLocaleTest()
 {
         AlertRuleVO rule = AlertRuleVO.builder()

Reply via email to