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()