This is an automated email from the ASF dual-hosted git repository.

Duansg pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git


The following commit(s) were added to refs/heads/master by this push:
     new 12463f6ab7 maintenance: derive rule-scoped notice groups (#4291)
12463f6ab7 is described below

commit 12463f6ab72d211de74384515a52d16f3f4989a3
Author: Logic <[email protected]>
AuthorDate: Mon Aug 3 14:05:39 2026 +0800

    maintenance: derive rule-scoped notice groups (#4291)
---
 .../alert/notice/AlertNoticeDispatch.java          | 107 +++++++++++++-
 .../service/impl/NoticeConfigServiceImpl.java      |   6 +-
 .../alert/notice/AlertNoticeDispatchTest.java      | 162 ++++++++++++++++++++-
 3 files changed, 267 insertions(+), 8 deletions(-)

diff --git 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/notice/AlertNoticeDispatch.java
 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/notice/AlertNoticeDispatch.java
index b294e817cd..c6cfa4084a 100644
--- 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/notice/AlertNoticeDispatch.java
+++ 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/notice/AlertNoticeDispatch.java
@@ -18,10 +18,16 @@
 package org.apache.hertzbeat.alert.notice;
 
 import com.google.common.collect.Maps;
+import java.time.LocalDateTime;
+import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.concurrent.RejectedExecutionException;
+import java.util.function.Function;
+import java.util.stream.Collectors;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.hertzbeat.alert.AlerterWorkerPool;
 import org.apache.hertzbeat.alert.config.AlertSseManager;
@@ -29,7 +35,9 @@ import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
 import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
 import org.apache.hertzbeat.common.entity.alerter.NoticeRule;
 import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
+import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
 import org.apache.hertzbeat.alert.service.NoticeConfigService;
+import org.apache.hertzbeat.common.constants.CommonConstants;
 import org.apache.hertzbeat.common.util.JsonUtil;
 import org.apache.hertzbeat.plugin.PostAlertPlugin;
 import org.apache.hertzbeat.plugin.Plugin;
@@ -124,6 +132,7 @@ public class AlertNoticeDispatch {
     private void sendNotify(GroupAlert alert) {
         matchNoticeRulesByAlert(alert).ifPresent(noticeRules -> 
noticeRules.forEach(rule -> {
             NoticeTemplate noticeTemplate = 
getOneTemplateById(rule.getTemplateId());
+            GroupAlert noticeAlert = scopeAlertToRule(alert, rule);
             rule.getReceiverId().forEach(receiverId -> {
                 NoticeReceiver receiver = getOneReceiverById(receiverId);
                 if (receiver == null || receiver.getType() == null) {
@@ -133,7 +142,7 @@ public class AlertNoticeDispatch {
                 try {
                     workerPool.executeNotify(receiver.getType(), () -> {
                         try {
-                            sendNoticeMsg(receiver, noticeTemplate, alert);
+                            sendNoticeMsg(receiver, noticeTemplate, 
noticeAlert);
                         } catch (AlertNoticeException e) {
                             log.warn("DispatchTask sendNoticeMsg error, 
message: {}", e.getMessage());
                         }
@@ -145,4 +154,100 @@ public class AlertNoticeDispatch {
             });
         }));
     }
+
+    private GroupAlert scopeAlertToRule(GroupAlert alert, NoticeRule rule) {
+        if (rule.isFilterAll() || rule.getLabels() == null || 
rule.getLabels().isEmpty()
+                || alert.getAlerts() == null) {
+            return alert;
+        }
+        List<SingleAlert> matchingAlerts = alert.getAlerts().stream()
+                .filter(singleAlert -> singleAlert.getLabels() != null
+                        && rule.getLabels().entrySet().stream().allMatch(label 
->
+                                Objects.equals(label.getValue(), 
singleAlert.getLabels().get(label.getKey()))))
+                .toList();
+        Map<String, String> commonLabels = 
extractCommonAttributes(matchingAlerts, SingleAlert::getLabels);
+        Map<String, String> commonAnnotations =
+                extractCommonAttributes(matchingAlerts, 
SingleAlert::getAnnotations);
+        Map<String, String> groupLabels = 
extractGroupLabels(alert.getGroupLabels(), commonLabels);
+        String groupKey = Objects.equals(groupLabels, alert.getGroupLabels())
+                ? alert.getGroupKey() : generateGroupKey(groupLabels);
+        return GroupAlert.builder()
+                .id(alert.getId())
+                .groupKey(groupKey)
+                .status(determineGroupStatus(matchingAlerts))
+                .groupLabels(groupLabels)
+                .commonLabels(commonLabels)
+                .commonAnnotations(commonAnnotations)
+                .alertFingerprints(matchingAlerts.stream()
+                        .map(SingleAlert::getFingerprint)
+                        .filter(Objects::nonNull)
+                        .toList())
+                .creator(alert.getCreator())
+                .modifier(alert.getModifier())
+                .gmtCreate(firstTime(matchingAlerts, 
SingleAlert::getGmtCreate, alert.getGmtCreate()))
+                .gmtUpdate(lastTime(matchingAlerts, SingleAlert::getGmtUpdate, 
alert.getGmtUpdate()))
+                .alerts(matchingAlerts)
+                .build();
+    }
+
+    private Map<String, String> extractCommonAttributes(
+            Collection<SingleAlert> alerts,
+            Function<SingleAlert, Map<String, String>> attributes) {
+        if (alerts.isEmpty()) {
+            return new HashMap<>(0);
+        }
+        Map<String, String> firstAttributes = 
attributes.apply(alerts.iterator().next());
+        Map<String, String> common =
+                firstAttributes == null ? new HashMap<>(0) : new 
HashMap<>(firstAttributes);
+        for (SingleAlert alert : alerts) {
+            Map<String, String> current = attributes.apply(alert);
+            common.keySet().removeIf(key ->
+                    current == null || !current.containsKey(key)
+                            || !Objects.equals(common.get(key), 
current.get(key)));
+        }
+        return common;
+    }
+
+    private Map<String, String> extractGroupLabels(
+            Map<String, String> originalGroupLabels,
+            Map<String, String> commonLabels) {
+        Map<String, String> groupLabels = new HashMap<>();
+        if (originalGroupLabels != null) {
+            originalGroupLabels.keySet().forEach(key -> {
+                if (commonLabels.containsKey(key)) {
+                    groupLabels.put(key, commonLabels.get(key));
+                }
+            });
+        }
+        return groupLabels;
+    }
+
+    private String generateGroupKey(Map<String, String> groupLabels) {
+        return groupLabels.entrySet().stream()
+                .sorted(Map.Entry.comparingByKey())
+                .map(entry -> entry.getKey() + ":" + entry.getValue())
+                .collect(Collectors.joining(","));
+    }
+
+    private String determineGroupStatus(List<SingleAlert> alerts) {
+        return alerts.stream().anyMatch(alert ->
+                CommonConstants.ALERT_STATUS_FIRING.equals(alert.getStatus()))
+                ? CommonConstants.ALERT_STATUS_FIRING : 
CommonConstants.ALERT_STATUS_RESOLVED;
+    }
+
+    private LocalDateTime firstTime(
+            List<SingleAlert> alerts,
+            Function<SingleAlert, LocalDateTime> time,
+            LocalDateTime fallback) {
+        return alerts.stream().map(time).filter(Objects::nonNull)
+                .min(LocalDateTime::compareTo).orElse(fallback);
+    }
+
+    private LocalDateTime lastTime(
+            List<SingleAlert> alerts,
+            Function<SingleAlert, LocalDateTime> time,
+            LocalDateTime fallback) {
+        return alerts.stream().map(time).filter(Objects::nonNull)
+                .max(LocalDateTime::compareTo).orElse(fallback);
+    }
 }
diff --git 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/NoticeConfigServiceImpl.java
 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/NoticeConfigServiceImpl.java
index eab8ad014e..8469315c5d 100644
--- 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/NoticeConfigServiceImpl.java
+++ 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/service/impl/NoticeConfigServiceImpl.java
@@ -246,11 +246,7 @@ public class NoticeConfigServiceImpl implements 
NoticeConfigService, CommandLine
             CacheFactory.setNoticeCache(rules);
         }
 
-        // The temporary rule is to forward all, and then implement more 
matching rules: alarm status selection, monitoring type selection, etc.
-        // TODO: This matches an already-grouped alert against notice rules 
(group-then-route). It cannot fully
-        //  separate alerts that were grouped together but should reach 
different receivers, so a rule matched by
-        //  one alert still notifies the whole group. The ideal design is 
route-then-group (like Alertmanager):
-        //  route each single alert by its labels first, then group per 
receiver. Tracked as a follow-up to #3852.
+        // Match grouped alerts here; dispatch scopes each notification to the 
single alerts matching its rule.
         return rules.stream()
             .filter(rule -> {
                 if (!rule.isFilterAll()) {
diff --git 
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/notice/AlertNoticeDispatchTest.java
 
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/notice/AlertNoticeDispatchTest.java
index ff4c92ef4c..1dd407589f 100644
--- 
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/notice/AlertNoticeDispatchTest.java
+++ 
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/notice/AlertNoticeDispatchTest.java
@@ -17,31 +17,38 @@
 
 package org.apache.hertzbeat.alert.notice;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertAll;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyByte;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import java.time.LocalDateTime;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import org.apache.hertzbeat.alert.AlerterWorkerPool;
 import org.apache.hertzbeat.alert.config.AlertSseManager;
 import org.apache.hertzbeat.alert.service.NoticeConfigService;
 import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
 import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
+import org.apache.hertzbeat.common.entity.alerter.NoticeRule;
+import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
+import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
 import org.apache.hertzbeat.plugin.runner.PluginRunner;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
-import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
-import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
 
 /**
  * Test case for Alert Notice Dispatch
@@ -180,4 +187,155 @@ class AlertNoticeDispatchTest {
         verify(alertNotifyHandler).send(eq(receiver), eq(template), eq(alert));
         verify(emitterManager).broadcast(any(String.class));
     }
+
+    @Test
+    void testDispatchAlarmRecomputesNoticeFromAlertsMatchingRuleLabels() {
+        LocalDateTime matchingCreated = LocalDateTime.of(2026, 7, 30, 10, 0);
+        LocalDateTime matchingUpdated = LocalDateTime.of(2026, 7, 30, 10, 5);
+        SingleAlert matchingAlert = SingleAlert.builder()
+                .fingerprint("matching")
+                .labels(Map.of("department", "algorithm", "service", 
"checkout", "severity", "warning"))
+                .annotations(Map.of("summary", "algorithm summary", "runbook", 
"shared runbook"))
+                .content("matching-content")
+                .status("resolved")
+                .gmtCreate(matchingCreated)
+                .gmtUpdate(matchingUpdated)
+                .build();
+        SingleAlert unrelatedAlert = SingleAlert.builder()
+                .fingerprint("unrelated")
+                .labels(Map.of("department", "infra", "service", "checkout", 
"severity", "critical"))
+                .annotations(Map.of("summary", "infra summary", "runbook", 
"shared runbook"))
+                .content("unrelated-content")
+                .status("firing")
+                .gmtCreate(matchingCreated.minusHours(1))
+                .gmtUpdate(matchingUpdated.plusHours(1))
+                .build();
+        GroupAlert groupedAlert = GroupAlert.builder()
+                .id(2L)
+                .groupKey("department:infra,service:checkout")
+                .status("firing")
+                .groupLabels(Map.of("department", "infra", "service", 
"checkout"))
+                .commonLabels(Map.of("service", "checkout"))
+                .commonAnnotations(Map.of("runbook", "shared runbook"))
+                .alertFingerprints(List.of("matching", "unrelated"))
+                .gmtCreate(matchingCreated.minusHours(1))
+                .gmtUpdate(matchingUpdated.plusHours(1))
+                .alerts(List.of(matchingAlert, unrelatedAlert))
+                .build();
+        NoticeTemplate template = NoticeTemplate.builder().id(1L).build();
+        NoticeRule rule = NoticeRule.builder()
+                .filterAll(false)
+                .labels(Map.of("department", "algorithm"))
+                .receiverId(List.of(1L))
+                .templateId(1L)
+                .build();
+
+        when(alertStoreHandler.store(groupedAlert)).thenReturn(groupedAlert);
+        
when(noticeConfigService.getReceiverFilterRule(groupedAlert)).thenReturn(List.of(rule));
+        when(noticeConfigService.getReceiverById(1L)).thenReturn(receiver);
+        when(noticeConfigService.getOneTemplateById(1L)).thenReturn(template);
+        doAnswer(invocation -> {
+            Runnable task = invocation.getArgument(1);
+            task.run();
+            return null;
+        }).when(workerPool).executeNotify(anyByte(), any(Runnable.class));
+
+        alertNoticeDispatch.dispatchAlarm(groupedAlert);
+
+        ArgumentCaptor<GroupAlert> noticeAlert = 
ArgumentCaptor.forClass(GroupAlert.class);
+        verify(alertNotifyHandler).send(eq(receiver), eq(template), 
noticeAlert.capture());
+        GroupAlert scopedAlert = noticeAlert.getValue();
+        assertAll(
+                () -> assertEquals(List.of(matchingAlert), 
scopedAlert.getAlerts()),
+                () -> assertEquals(List.of("matching"), 
scopedAlert.getAlertFingerprints()),
+                () -> assertEquals("resolved", scopedAlert.getStatus()),
+                () -> assertEquals(
+                        Map.of("department", "algorithm", "service", 
"checkout"),
+                        scopedAlert.getGroupLabels()),
+                () -> assertEquals(
+                        Map.of("department", "algorithm", "service", 
"checkout", "severity", "warning"),
+                        scopedAlert.getCommonLabels()),
+                () -> assertEquals(
+                        Map.of("summary", "algorithm summary", "runbook", 
"shared runbook"),
+                        scopedAlert.getCommonAnnotations()),
+                () -> assertEquals("department:algorithm,service:checkout", 
scopedAlert.getGroupKey()),
+                () -> assertEquals(matchingCreated, 
scopedAlert.getGmtCreate()),
+                () -> assertEquals(matchingUpdated, 
scopedAlert.getGmtUpdate()),
+                () -> assertEquals(2, groupedAlert.getAlerts().size()),
+                () -> assertEquals("firing", groupedAlert.getStatus()),
+                () -> assertEquals(Map.of("service", "checkout"), 
groupedAlert.getCommonLabels()));
+    }
+
+    @Test
+    void testDispatchAlarmScopesMultipleRulesForTheSameReceiverIndependently() 
{
+        SingleAlert algorithmAlert = SingleAlert.builder()
+                .fingerprint("algorithm")
+                .labels(Map.of("department", "algorithm", "service", 
"checkout"))
+                .annotations(Map.of("summary", "algorithm firing", "runbook", 
"algorithm runbook"))
+                .status("firing")
+                .build();
+        SingleAlert algorithmResolvedAlert = SingleAlert.builder()
+                .fingerprint("algorithm-resolved")
+                .labels(Map.of("department", "algorithm", "service", 
"checkout"))
+                .annotations(Map.of("summary", "algorithm resolved", 
"runbook", "algorithm runbook"))
+                .status("resolved")
+                .build();
+        SingleAlert infrastructureAlert = SingleAlert.builder()
+                .fingerprint("infra")
+                .labels(Map.of("department", "infra", "service", "checkout"))
+                .annotations(Map.of("summary", "infra summary"))
+                .status("resolved")
+                .build();
+        GroupAlert groupedAlert = GroupAlert.builder()
+                .status("firing")
+                .groupLabels(Map.of("service", "checkout"))
+                .alerts(List.of(algorithmAlert, algorithmResolvedAlert, 
infrastructureAlert))
+                .build();
+        NoticeTemplate algorithmTemplate = 
NoticeTemplate.builder().id(1L).build();
+        NoticeTemplate infrastructureTemplate = 
NoticeTemplate.builder().id(2L).build();
+        NoticeRule algorithmRule = NoticeRule.builder()
+                .filterAll(false)
+                .labels(Map.of("department", "algorithm"))
+                .receiverId(List.of(1L))
+                .templateId(1L)
+                .build();
+        NoticeRule infrastructureRule = NoticeRule.builder()
+                .filterAll(false)
+                .labels(Map.of("department", "infra"))
+                .receiverId(List.of(1L))
+                .templateId(2L)
+                .build();
+
+        when(alertStoreHandler.store(groupedAlert)).thenReturn(groupedAlert);
+        when(noticeConfigService.getReceiverFilterRule(groupedAlert))
+                .thenReturn(List.of(algorithmRule, infrastructureRule));
+        when(noticeConfigService.getReceiverById(1L)).thenReturn(receiver);
+        
when(noticeConfigService.getOneTemplateById(1L)).thenReturn(algorithmTemplate);
+        
when(noticeConfigService.getOneTemplateById(2L)).thenReturn(infrastructureTemplate);
+        doAnswer(invocation -> {
+            Runnable task = invocation.getArgument(1);
+            task.run();
+            return null;
+        }).when(workerPool).executeNotify(anyByte(), any(Runnable.class));
+
+        alertNoticeDispatch.dispatchAlarm(groupedAlert);
+
+        ArgumentCaptor<NoticeTemplate> templates = 
ArgumentCaptor.forClass(NoticeTemplate.class);
+        ArgumentCaptor<GroupAlert> alerts = 
ArgumentCaptor.forClass(GroupAlert.class);
+        verify(alertNotifyHandler, times(2)).send(eq(receiver), 
templates.capture(), alerts.capture());
+        assertAll(
+                () -> assertEquals(List.of(algorithmTemplate, 
infrastructureTemplate), templates.getAllValues()),
+                () -> assertEquals(
+                        List.of("algorithm", "algorithm-resolved"),
+                        alerts.getAllValues().get(0).getAlertFingerprints()),
+                () -> assertEquals(List.of("infra"), 
alerts.getAllValues().get(1).getAlertFingerprints()),
+                () -> assertEquals("firing", 
alerts.getAllValues().get(0).getStatus()),
+                () -> assertEquals("resolved", 
alerts.getAllValues().get(1).getStatus()),
+                () -> assertEquals(
+                        Map.of("runbook", "algorithm runbook"),
+                        alerts.getAllValues().get(0).getCommonAnnotations()),
+                () -> assertEquals(
+                        Map.of("summary", "infra summary"),
+                        alerts.getAllValues().get(1).getCommonAnnotations()));
+    }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to