Repository: moira-alert/moira
Pull request #1161: Feat/system tags notifier
Author: login: HeavyPunk
Created: 2025-03-17T14:30:02Z | Merged: 2025-03-21T06:23:34Z

# PR Summary

Notify users subscribed on system tags when moira in disability state

This PR depends on #1152 



Diff:
diff --git a/cmd/notifier/config.go b/cmd/notifier/config.go
index a095aebbc..7503616e6 100644
--- a/cmd/notifier/config.go
+++ b/cmd/notifier/config.go
@@ -79,6 +79,28 @@ type selfStateConfig struct {
 	NoticeInterval string `yaml:"notice_interval"`
 	// Self state monitor check interval
 	CheckInterval string `yaml:"check_interval"`
+	// Checks contains the configuration for selfstate checks
+	Checks cmd.ChecksConfig `yaml:"selfstate_checks"`
+}
+
+func toCheckConfig(checksConfig cmd.ChecksConfig) selfstate.ChecksConfig {
+	return selfstate.ChecksConfig{
+		Database: selfstate.HeartbeatConfig{
+			SystemTags: checksConfig.Database.SystemTags,
+		},
+		Filter: selfstate.HeartbeatConfig{
+			SystemTags: checksConfig.Filter.SystemTags,
+		},
+		LocalChecker: selfstate.HeartbeatConfig{
+			SystemTags: checksConfig.LocalChecker.SystemTags,
+		},
+		RemoteChecker: selfstate.HeartbeatConfig{
+			SystemTags: checksConfig.RemoteChecker.SystemTags,
+		},
+		Notifier: selfstate.HeartbeatConfig{
+			SystemTags: checksConfig.Notifier.SystemTags,
+		},
+	}
 }
 
 func getDefault() config {
@@ -230,5 +252,6 @@ func (config *selfStateConfig) getSettings() selfstate.Config {
 		CheckInterval:                  checkInterval,
 		Contacts:                       config.Contacts,
 		NoticeIntervalSeconds:          int64(to.Duration(config.NoticeInterval).Seconds()),
+		Checks:                         toCheckConfig(config.Checks),
 	}
 }
diff --git a/helpers.go b/helpers.go
index a6110d4af..e8e50faad 100644
--- a/helpers.go
+++ b/helpers.go
@@ -17,6 +17,15 @@ type BytesScanner struct {
 	emitEmptySlice bool
 }
 
+// Map applies a transformation function to each element in the input slice and returns a new slice with the transformed elements.
+func Map[T any, R any](input []T, transform func(T) R) []R {
+	result := make([]R, len(input))
+	for i, v := range input {
+		result[i] = transform(v)
+	}
+	return result
+}
+
 // HasNext checks if next subslice available or not.
 func (it *BytesScanner) HasNext() bool {
 	return it.index < len(it.source) || it.emitEmptySlice
diff --git a/mock/heartbeat/heartbeat.go b/mock/heartbeat/heartbeat.go
index f0a6f6acf..002abbaf0 100644
--- a/mock/heartbeat/heartbeat.go
+++ b/mock/heartbeat/heartbeat.go
@@ -12,6 +12,7 @@ package mock_heartbeat
 import (
 	reflect "reflect"
 
+	heartbeat "github.com/moira-alert/moira/notifier/selfstate/heartbeat"
 	gomock "go.uber.org/mock/gomock"
 )
 
@@ -54,6 +55,20 @@ func (mr *MockHeartbeaterMockRecorder) Check(arg0 any) *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Check", reflect.TypeOf((*MockHeartbeater)(nil).Check), arg0)
 }
 
+// GetCheckTags mocks base method.
+func (m *MockHeartbeater) GetCheckTags() heartbeat.CheckTags {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetCheckTags")
+	ret0, _ := ret[0].(heartbeat.CheckTags)
+	return ret0
+}
+
+// GetCheckTags indicates an expected call of GetCheckTags.
+func (mr *MockHeartbeaterMockRecorder) GetCheckTags() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCheckTags", reflect.TypeOf((*MockHeartbeater)(nil).GetCheckTags))
+}
+
 // GetErrorMessage mocks base method.
 func (m *MockHeartbeater) GetErrorMessage() string {
 	m.ctrl.T.Helper()
diff --git a/notifier/selfstate/check.go b/notifier/selfstate/check.go
index 9b2ad2fe5..b2de75740 100644
--- a/notifier/selfstate/check.go
+++ b/notifier/selfstate/check.go
@@ -7,8 +7,14 @@ import (
 
 	"github.com/moira-alert/moira"
 	"github.com/moira-alert/moira/notifier"
+	"github.com/moira-alert/moira/notifier/selfstate/heartbeat"
 )
 
+type heartbeatNotificationEvent struct {
+	moira.NotificationEvent
+	heartbeat.CheckTags
+}
+
 func (selfCheck *SelfCheckWorker) selfStateChecker(stop <-chan struct{}) error {
 	selfCheck.Logger.Info().Msg("Moira Notifier Self State Monitor started")
 
@@ -32,8 +38,8 @@ func (selfCheck *SelfCheckWorker) selfStateChecker(stop <-chan struct{}) error {
 	}
 }
 
-func (selfCheck *SelfCheckWorker) handleCheckServices(nowTS int64) []moira.NotificationEvent {
-	var events []moira.NotificationEvent
+func (selfCheck *SelfCheckWorker) handleCheckServices(nowTS int64) []heartbeatNotificationEvent {
+	var events []heartbeatNotificationEvent
 
 	for _, heartbeat := range selfCheck.heartbeats {
 		currentValue, hasErrors, err := heartbeat.Check(nowTS)
@@ -44,7 +50,10 @@ func (selfCheck *SelfCheckWorker) handleCheckServices(nowTS int64) []moira.Notif
 		}
 
 		if hasErrors {
-			events = append(events, generateNotificationEvent(heartbeat.GetErrorMessage(), currentValue))
+			events = append(events, heartbeatNotificationEvent{
+				NotificationEvent: generateNotificationEvent(heartbeat.GetErrorMessage(), currentValue, nowTS),
+				CheckTags:         heartbeat.GetCheckTags(),
+			})
 			if heartbeat.NeedTurnOffNotifier() {
 				selfCheck.setNotifierState(moira.SelfStateERROR)
 			}
@@ -58,7 +67,7 @@ func (selfCheck *SelfCheckWorker) handleCheckServices(nowTS int64) []moira.Notif
 	return events
 }
 
-func (selfCheck *SelfCheckWorker) sendNotification(events []moira.NotificationEvent, nowTS int64) int64 {
+func (selfCheck *SelfCheckWorker) sendNotification(events []heartbeatNotificationEvent, nowTS int64) int64 {
 	eventsJSON, _ := json.Marshal(events)
 	selfCheck.Logger.Error().
 		Int("number_of_events", len(events)).
@@ -77,9 +86,77 @@ func (selfCheck *SelfCheckWorker) check(nowTS int64, nextSendErrorMessage int64)
 	return nextSendErrorMessage
 }
 
-func (selfCheck *SelfCheckWorker) sendErrorMessages(events []moira.NotificationEvent) {
+func (selfCheck *SelfCheckWorker) constructUserNotification(events []heartbeatNotificationEvent) ([]*notifier.NotificationPackage, error) {
+	contactToEvents := make(map[*moira.ContactData][]moira.NotificationEvent)
+	for _, event := range events {
+		if len(event.CheckTags) == 0 {
+			continue
+		}
+
+		subscriptions, err := selfCheck.Database.GetTagsSubscriptions(event.CheckTags)
+		if err != nil {
+			return nil, err
+		}
+		for _, subscription := range subscriptions {
+			contacts, err := selfCheck.Database.GetContacts(subscription.Contacts)
+			if err != nil {
+				return nil, err
+			}
+			for _, contact := range contacts {
+				contactToEvents[contact] = append(contactToEvents[contact], event.NotificationEvent)
+			}
+		}
+	}
+
+	notificationPkgs := make([]*notifier.NotificationPackage, 0, len(contactToEvents))
+	for contact, events := range contactToEvents {
+		notificationPkgs = append(notificationPkgs, &notifier.NotificationPackage{
+			Contact: *contact,
+			Trigger: moira.TriggerData{
+				Name:       "Moira health check",
+				ErrorValue: float64(0),
+			},
+			Events:     events,
+			DontResend: true,
+		})
+	}
+
+	return notificationPkgs, nil
+}
+
+func (selfCheck *SelfCheckWorker) sendErrorMessages(events []heartbeatNotificationEvent) {
 	var sendingWG sync.WaitGroup
 
+	selfCheck.sendNotificationToAdmins(moira.Map(
+		events,
+		func(et heartbeatNotificationEvent) moira.NotificationEvent { return et.NotificationEvent },
+	),
+		&sendingWG,
+	)
+	sendingWG.Wait()
+
+	selfCheck.sendNotificationToUsers(events, &sendingWG)
+	sendingWG.Wait()
+}
+
+func (selfCheck *SelfCheckWorker) sendNotificationToUsers(events []heartbeatNotificationEvent, sendingWG *sync.WaitGroup) {
+	notificationPackages, err := selfCheck.constructUserNotification(events)
+	if err != nil {
+		selfCheck.Logger.Warning().
+			Error(err).
+			Msg("Sending notifications via subscriptions has failed")
+	}
+
+	for _, pkg := range notificationPackages {
+		if pkg == nil {
+			continue
+		}
+
+		selfCheck.Notifier.Send(pkg, sendingWG)
+	}
+}
+
+func (selfCheck *SelfCheckWorker) sendNotificationToAdmins(events []moira.NotificationEvent, sendingWG *sync.WaitGroup) {
 	for _, adminContact := range selfCheck.Config.Contacts {
 		pkg := notifier.NotificationPackage{
 			Contact: moira.ContactData{
@@ -94,15 +171,14 @@ func (selfCheck *SelfCheckWorker) sendErrorMessages(events []moira.NotificationE
 			DontResend: true,
 		}
 
-		selfCheck.Notifier.Send(&pkg, &sendingWG)
-		sendingWG.Wait()
+		selfCheck.Notifier.Send(&pkg, sendingWG)
 	}
 }
 
-func generateNotificationEvent(message string, currentValue int64) moira.NotificationEvent {
+func generateNotificationEvent(message string, currentValue, timestamp int64) moira.NotificationEvent {
 	val := float64(currentValue)
 	return moira.NotificationEvent{
-		Timestamp: time.Now().Unix(),
+		Timestamp: timestamp,
 		OldState:  moira.StateNODATA,
 		State:     moira.StateERROR,
 		Metric:    message,
diff --git a/notifier/selfstate/check_test.go b/notifier/selfstate/check_test.go
new file mode 100644
index 000000000..89f86fe6b
--- /dev/null
+++ b/notifier/selfstate/check_test.go
@@ -0,0 +1,171 @@
+package selfstate
+
+import (
+	"fmt"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/moira-alert/moira"
+	logging "github.com/moira-alert/moira/logging/zerolog_adapter"
+	mock_moira_alert "github.com/moira-alert/moira/mock/moira-alert"
+	mock_notifier "github.com/moira-alert/moira/mock/notifier"
+	notifier2 "github.com/moira-alert/moira/notifier"
+	. "github.com/smartystreets/goconvey/convey"
+	"go.uber.org/mock/gomock"
+)
+
+func TestSelfCheckWorker_check(t *testing.T) {
+	Convey("Check should", t, func() {
+		Convey("Send notifications to admins only on database error", func() {
+			worker := createWorker(t)
+			redisError := fmt.Errorf("Redis connection timeout")
+			worker.database.EXPECT().GetChecksUpdatesCount().Return(int64(1), redisError)
+			worker.database.EXPECT().SetNotifierState(gomock.Any()).Return(redisError)
+			worker.database.EXPECT().GetTagsSubscriptions(gomock.Any()).Return(nil, redisError)
+
+			var sendingWG sync.WaitGroup
+			var timeDelta int64 = 100
+			nowTS := time.Now().Unix() + timeDelta
+			val := float64(timeDelta)
+
+			registerAdminNotification(worker, nowTS, "Redis disconnected", val, &sendingWG)
+
+			worker.selfCheckWorker.check(nowTS, 10)
+		})
+
+		Convey("Send notifications to admins and users on single heartbeat error", func() {
+			var timeDelta int64 = 100
+			nowTS := time.Now().Unix() + timeDelta
+			worker := createWorker(t)
+
+			fillDatabase(worker.database)
+			worker.database.EXPECT().GetChecksUpdatesCount().Return(int64(1), fmt.Errorf("Redis connection timeout")) // NOTE: needs to fail database check and do not check other heartbeats.
+
+			val := float64(timeDelta)
+			var sendingWG sync.WaitGroup
+
+			registerAdminNotification(worker, nowTS, "Redis disconnected", val, &sendingWG)
+
+			toUsersPack := notifier2.NotificationPackage{
+				Trigger: moira.TriggerData{
+					Name:       "Moira health check",
+					ErrorValue: float64(0),
+				},
+				Contact: moira.ContactData{
+					ID:    "contact1",
+					Type:  "user-mail",
+					Value: "user@userdomain.com",
+				},
+				DontResend: true,
+				Events: []moira.NotificationEvent{
+					{
+						Timestamp: nowTS,
+						OldState:  moira.StateNODATA,
+						State:     moira.StateERROR,
+						Metric:    "Redis disconnected",
+						Value:     &val,
+					},
+				},
+			}
+
+			worker.notif.EXPECT().Send(&toUsersPack, &sendingWG).Times(1)
+
+			worker.selfCheckWorker.check(nowTS, 10)
+		})
+	})
+}
+
+func registerAdminNotification(worker *selfCheckWorkerMock, nowTS int64, eventMetric string, eventValue float64, sendingWG *sync.WaitGroup) {
+	for _, contact := range worker.conf.Contacts {
+		toAdminPack := notifier2.NotificationPackage{
+			Trigger: moira.TriggerData{
+				Name:       "Moira health check",
+				ErrorValue: float64(0),
+			},
+			Contact: moira.ContactData{
+				Type:  contact["type"],
+				Value: contact["value"],
+			},
+			DontResend: true,
+			Events: []moira.NotificationEvent{
+				{
+					Timestamp: nowTS,
+					OldState:  moira.StateNODATA,
+					State:     moira.StateERROR,
+					Metric:    eventMetric,
+					Value:     &eventValue,
+				},
+			},
+		}
+
+		worker.notif.EXPECT().Send(&toAdminPack, sendingWG).Times(1)
+	}
+}
+
+func createWorker(t *testing.T) *selfCheckWorkerMock {
+	adminContact := map[string]string{
+		"type":  "admin-mail",
+		"value": "admin@company.com",
+	}
+	conf := Config{
+		Enabled: true,
+		Contacts: []map[string]string{
+			adminContact,
+		},
+		RedisDisconnectDelaySeconds:    1,
+		LastMetricReceivedDelaySeconds: 60,
+		LastCheckDelaySeconds:          120,
+		NoticeIntervalSeconds:          60,
+		LastRemoteCheckDelaySeconds:    120,
+		CheckInterval:                  1 * time.Second,
+		Checks: ChecksConfig{
+			Database: HeartbeatConfig{
+				SystemTags: []string{"sys-tag-database", "moira-fatal"},
+			},
+			Filter: HeartbeatConfig{
+				SystemTags: []string{"sys-tag-filter", "moira-fatal"},
+			},
+			LocalChecker: HeartbeatConfig{
+				SystemTags: []string{"sys-tag-local-checker"},
+			},
+			RemoteChecker: HeartbeatConfig{
+				SystemTags: []string{"sys-tag-remote-checker", "moira-fatal"},
+			},
+			Notifier: HeartbeatConfig{
+				SystemTags: []string{"sys-tag-notifier", "moira-fatal"},
+			},
+		},
+	}
+
+	mockCtrl := gomock.NewController(t)
+	database := mock_moira_alert.NewMockDatabase(mockCtrl)
+	logger, _ := logging.GetLogger("SelfState")
+	notif := mock_notifier.NewMockNotifier(mockCtrl)
+	return &selfCheckWorkerMock{
+		selfCheckWorker: NewSelfCheckWorker(logger, database, notif, conf),
+		database:        database,
+		notif:           notif,
+		conf:            conf,
+		mockCtrl:        mockCtrl,
+	}
+}
+
+func fillDatabase(database *mock_moira_alert.MockDatabase) {
+	contacts := []*moira.ContactData{
+		{
+			ID:    "contact1",
+			Type:  "user-mail",
+			Value: "user@userdomain.com",
+		},
+	}
+	database.EXPECT().GetContacts(moira.Map(contacts, func(c *moira.ContactData) string { return c.ID })).Return(contacts, nil)
+	database.EXPECT().SetNotifierState(gomock.Any()).Return(nil)
+	database.EXPECT().GetTagsSubscriptions([]string{"sys-tag-database", "moira-fatal"}).Return([]*moira.SubscriptionData{
+		{
+			Contacts: []string{"contact1"},
+			Tags:     []string{"sys-tag-database", "moira-fatal"},
+			Enabled:  true,
+		},
+	}, nil)
+}
diff --git a/notifier/selfstate/heartbeat/database.go b/notifier/selfstate/heartbeat/database.go
index 5b8cd551f..ff7d61526 100644
--- a/notifier/selfstate/heartbeat/database.go
+++ b/notifier/selfstate/heartbeat/database.go
@@ -1,20 +1,17 @@
 package heartbeat
 
-import (
-	"time"
-
-	"github.com/moira-alert/moira"
-)
+import "github.com/moira-alert/moira"
 
 type databaseHeartbeat struct{ heartbeat }
 
-func GetDatabase(delay int64, logger moira.Logger, database moira.Database) Heartbeater {
+func GetDatabase(delay, lastSuccessfulCheck int64, checkTags []string, logger moira.Logger, database moira.Database) Heartbeater {
 	if delay > 0 {
 		return &databaseHeartbeat{heartbeat{
 			logger:              logger,
 			database:            database,
 			delay:               delay,
-			lastSuccessfulCheck: time.Now().Unix(),
+			lastSuccessfulCheck: lastSuccessfulCheck,
+			checkTags:           checkTags,
 		}}
 	}
 	return nil
@@ -50,3 +47,7 @@ func (databaseHeartbeat) NeedToCheckOthers() bool {
 func (databaseHeartbeat) GetErrorMessage() string {
 	return "Redis disconnected"
 }
+
+func (check *databaseHeartbeat) GetCheckTags() CheckTags {
+	return check.checkTags
+}
diff --git a/notifier/selfstate/heartbeat/database_test.go b/notifier/selfstate/heartbeat/database_test.go
index a5b8b8a5a..396cfeaa1 100644
--- a/notifier/selfstate/heartbeat/database_test.go
+++ b/notifier/selfstate/heartbeat/database_test.go
@@ -20,10 +20,10 @@ func TestDatabaseHeartbeat(t *testing.T) {
 		database := check.database.(*mock_moira_alert.MockDatabase)
 
 		Convey("Checking the created heartbeat database", func() {
-			expected := &databaseHeartbeat{heartbeat{database: check.database, logger: check.logger, delay: 1, lastSuccessfulCheck: now}}
+			expected := &databaseHeartbeat{heartbeat{database: check.database, logger: check.logger, delay: 1, lastSuccessfulCheck: now, checkTags: check.checkTags}}
 
-			So(GetDatabase(0, check.logger, check.database), ShouldBeNil)
-			So(GetDatabase(1, check.logger, check.database), ShouldResemble, expected)
+			So(GetDatabase(0, now, check.checkTags, check.logger, check.database), ShouldBeNil)
+			So(GetDatabase(1, now, check.checkTags, check.logger, check.database), ShouldResemble, expected)
 		})
 
 		Convey("Test update lastSuccessfulCheck", func() {
@@ -68,6 +68,7 @@ func TestDatabaseHeartbeat(t *testing.T) {
 func createRedisDelayTest(t *testing.T) *databaseHeartbeat {
 	mockCtrl := gomock.NewController(t)
 	logger, _ := logging.GetLogger("CheckDelay")
+	checkTags := []string{}
 
-	return GetDatabase(10, logger, mock_moira_alert.NewMockDatabase(mockCtrl)).(*databaseHeartbeat)
+	return GetDatabase(10, time.Now().Unix(), checkTags, logger, mock_moira_alert.NewMockDatabase(mockCtrl)).(*databaseHeartbeat)
 }
diff --git a/notifier/selfstate/heartbeat/filter.go b/notifier/selfstate/heartbeat/filter.go
index 80f000e35..989a552fc 100644
--- a/notifier/selfstate/heartbeat/filter.go
+++ b/notifier/selfstate/heartbeat/filter.go
@@ -1,10 +1,6 @@
 package heartbeat
 
-import (
-	"time"
-
-	"github.com/moira-alert/moira"
-)
+import "github.com/moira-alert/moira"
 
 type filter struct {
 	heartbeat
@@ -12,14 +8,15 @@ type filter struct {
 	firstCheckWasSuccessful bool
 }
 
-func GetFilter(delay int64, logger moira.Logger, database moira.Database) Heartbeater {
+func GetFilter(delay, lastSuccessfulCheck int64, checkTags []string, logger moira.Logger, database moira.Database) Heartbeater {
 	if delay > 0 {
 		return &filter{
 			heartbeat: heartbeat{
 				logger:              logger,
 				database:            database,
 				delay:               delay,
-				lastSuccessfulCheck: time.Now().Unix(),
+				lastSuccessfulCheck: lastSuccessfulCheck,
+				checkTags:           checkTags,
 			},
 			firstCheckWasSuccessful: false,
 		}
@@ -68,3 +65,7 @@ func (check filter) NeedToCheckOthers() bool {
 func (filter) GetErrorMessage() string {
 	return "Moira-Filter does not receive metrics"
 }
+
+func (check filter) GetCheckTags() CheckTags {
+	return check.checkTags
+}
diff --git a/notifier/selfstate/heartbeat/filter_test.go b/notifier/selfstate/heartbeat/filter_test.go
index 678c443d9..7a2cc24bb 100644
--- a/notifier/selfstate/heartbeat/filter_test.go
+++ b/notifier/selfstate/heartbeat/filter_test.go
@@ -29,11 +29,12 @@ func TestFilter(t *testing.T) {
 					logger:              check.logger,
 					delay:               1,
 					lastSuccessfulCheck: now,
+					checkTags:           check.checkTags,
 				},
 			}
 
-			So(GetFilter(0, check.logger, check.database), ShouldBeNil)
-			So(GetFilter(1, check.logger, check.database), ShouldResemble, expected)
+			So(GetFilter(0, now, check.checkTags, check.logger, check.database), ShouldBeNil)
+			So(GetFilter(1, now, check.checkTags, check.logger, check.database), ShouldResemble, expected)
 		})
 
 		Convey("Filter error handling test", func() {
@@ -91,6 +92,7 @@ func TestFilter(t *testing.T) {
 func createFilterTest(t *testing.T) (*filter, *gomock.Controller) {
 	mockCtrl := gomock.NewController(t)
 	logger, _ := logging.GetLogger("MetricDelay")
+	checkTags := []string{}
 
-	return GetFilter(60, logger, mock_moira_alert.NewMockDatabase(mockCtrl)).(*filter), mockCtrl
+	return GetFilter(60, time.Now().Unix(), checkTags, logger, mock_moira_alert.NewMockDatabase(mockCtrl)).(*filter), mockCtrl
 }
diff --git a/notifier/selfstate/heartbeat/heartbeat.go b/notifier/selfstate/heartbeat/heartbeat.go
index 244de1bcd..e2537adb1 100644
--- a/notifier/selfstate/heartbeat/heartbeat.go
+++ b/notifier/selfstate/heartbeat/heartbeat.go
@@ -10,12 +10,17 @@ type Heartbeater interface {
 	NeedTurnOffNotifier() bool
 	NeedToCheckOthers() bool
 	GetErrorMessage() string
+	GetCheckTags() CheckTags
 }
 
+// CheckTags represents a tag collection.
+type CheckTags []string
+
 // heartbeat basic structure for Heartbeater.
 type heartbeat struct {
 	logger   moira.Logger
 	database moira.Database
 
+	checkTags                  []string
 	delay, lastSuccessfulCheck int64
 }
diff --git a/notifier/selfstate/heartbeat/local_checker.go b/notifier/selfstate/heartbeat/local_checker.go
index 7c4c88cfd..10ca9c85d 100644
--- a/notifier/selfstate/heartbeat/local_checker.go
+++ b/notifier/selfstate/heartbeat/local_checker.go
@@ -1,23 +1,20 @@
 package heartbeat
 
-import (
-	"time"
-
-	"github.com/moira-alert/moira"
-)
+import "github.com/moira-alert/moira"
 
 type localChecker struct {
 	heartbeat
 	count int64
 }
 
-func GetLocalChecker(delay int64, logger moira.Logger, database moira.Database) Heartbeater {
+func GetLocalChecker(delay, lastSuccessfulCheck int64, checkTags []string, logger moira.Logger, database moira.Database) Heartbeater {
 	if delay > 0 {
 		return &localChecker{heartbeat: heartbeat{
 			logger:              logger,
 			database:            database,
 			delay:               delay,
-			lastSuccessfulCheck: time.Now().Unix(),
+			lastSuccessfulCheck: lastSuccessfulCheck,
+			checkTags:           checkTags,
 		}}
 	}
 	return nil
@@ -60,3 +57,7 @@ func (check localChecker) NeedTurnOffNotifier() bool {
 func (localChecker) GetErrorMessage() string {
 	return "Moira-Checker does not check triggers"
 }
+
+func (check *localChecker) GetCheckTags() CheckTags {
+	return check.checkTags
+}
diff --git a/notifier/selfstate/heartbeat/local_checker_test.go b/notifier/selfstate/heartbeat/local_checker_test.go
index 7c638907a..c5c2e8ed9 100644
--- a/notifier/selfstate/heartbeat/local_checker_test.go
+++ b/notifier/selfstate/heartbeat/local_checker_test.go
@@ -23,9 +23,9 @@ func TestCheckDelay_Check(t *testing.T) {
 		database := check.database.(*mock_moira_alert.MockDatabase)
 
 		Convey("Test creation localChecker", func() {
-			expected := &localChecker{heartbeat: heartbeat{database: check.database, logger: check.logger, delay: 1, lastSuccessfulCheck: now}}
-			So(GetLocalChecker(0, check.logger, check.database), ShouldBeNil)
-			So(GetLocalChecker(1, check.logger, check.database), ShouldResemble, expected)
+			expected := &localChecker{heartbeat: heartbeat{database: check.database, logger: check.logger, delay: 1, lastSuccessfulCheck: now, checkTags: check.checkTags}}
+			So(GetLocalChecker(0, now, check.checkTags, check.logger, check.database), ShouldBeNil)
+			So(GetLocalChecker(1, now, check.checkTags, check.logger, check.database), ShouldResemble, expected)
 		})
 
 		Convey("GraphiteLocalChecker error handling test", func() {
@@ -83,6 +83,7 @@ func TestCheckDelay_Check(t *testing.T) {
 func createGraphiteLocalCheckerTest(t *testing.T) (*localChecker, *gomock.Controller) {
 	mockCtrl := gomock.NewController(t)
 	logger, _ := logging.GetLogger("CheckDelay")
+	checkTags := []string{}
 
-	return GetLocalChecker(120, logger, mock_moira_alert.NewMockDatabase(mockCtrl)).(*localChecker), mockCtrl
+	return GetLocalChecker(120, time.Now().Unix(), checkTags, logger, mock_moira_alert.NewMockDatabase(mockCtrl)).(*localChecker), mockCtrl
 }
diff --git a/notifier/selfstate/heartbeat/notifier.go b/notifier/selfstate/heartbeat/notifier.go
index 0b418d0c9..fe10922d2 100644
--- a/notifier/selfstate/heartbeat/notifier.go
+++ b/notifier/selfstate/heartbeat/notifier.go
@@ -7,28 +7,28 @@ import (
 )
 
 type notifier struct {
-	db  moira.Database
-	log moira.Logger
+	heartbeat
 }
 
-func GetNotifier(logger moira.Logger, database moira.Database) Heartbeater {
-	return &notifier{
-		db:  database,
-		log: logger,
-	}
+func GetNotifier(checkTags []string, logger moira.Logger, database moira.Database) Heartbeater {
+	return &notifier{heartbeat{
+		database:  database,
+		logger:    logger,
+		checkTags: checkTags,
+	}}
 }
 
 func (check notifier) Check(int64) (int64, bool, error) {
-	state, _ := check.db.GetNotifierState()
+	state, _ := check.database.GetNotifierState()
 	if state != moira.SelfStateOK {
-		check.log.Error().
+		check.logger.Error().
 			String("error", check.GetErrorMessage()).
 			Msg("Notifier is not healthy")
 
 		return 0, true, nil
 	}
 
-	check.log.Debug().
+	check.logger.Debug().
 		String("state", state).
 		Msg("Notifier is healthy")
 
@@ -44,6 +44,10 @@ func (notifier) NeedToCheckOthers() bool {
 }
 
 func (check notifier) GetErrorMessage() string {
-	state, _ := check.db.GetNotifierState()
+	state, _ := check.database.GetNotifierState()
 	return fmt.Sprintf("Moira-Notifier does not send messages. State: %v", state)
 }
+
+func (check *notifier) GetCheckTags() CheckTags {
+	return check.checkTags
+}
diff --git a/notifier/selfstate/heartbeat/notifier_test.go b/notifier/selfstate/heartbeat/notifier_test.go
index ba56a6d7d..c5f354d91 100644
--- a/notifier/selfstate/heartbeat/notifier_test.go
+++ b/notifier/selfstate/heartbeat/notifier_test.go
@@ -18,7 +18,7 @@ func TestNotifierState(t *testing.T) {
 		check := createNotifierStateTest(t)
 
 		Convey("Test get notifier delay", func() {
-			check.db.(*mock_moira_alert.MockDatabase).EXPECT().GetNotifierState().Return(moira.SelfStateOK, nil)
+			check.database.(*mock_moira_alert.MockDatabase).EXPECT().GetNotifierState().Return(moira.SelfStateOK, nil)
 
 			value, needSend, errActual := check.Check(now)
 			So(errActual, ShouldBeNil)
@@ -27,7 +27,7 @@ func TestNotifierState(t *testing.T) {
 		})
 
 		Convey("Test get notification", func() {
-			check.db.(*mock_moira_alert.MockDatabase).EXPECT().GetNotifierState().Return(moira.SelfStateERROR, nil).Times(2)
+			check.database.(*mock_moira_alert.MockDatabase).EXPECT().GetNotifierState().Return(moira.SelfStateERROR, nil).Times(2)
 
 			value, needSend, errActual := check.Check(now)
 			So(errActual, ShouldBeNil)
@@ -45,6 +45,7 @@ func TestNotifierState(t *testing.T) {
 func createNotifierStateTest(t *testing.T) *notifier {
 	mockCtrl := gomock.NewController(t)
 	logger, _ := logging.GetLogger("MetricDelay")
+	checkTags := []string{}
 
-	return GetNotifier(logger, mock_moira_alert.NewMockDatabase(mockCtrl)).(*notifier)
+	return GetNotifier(checkTags, logger, mock_moira_alert.NewMockDatabase(mockCtrl)).(*notifier)
 }
diff --git a/notifier/selfstate/heartbeat/remote_checker.go b/notifier/selfstate/heartbeat/remote_checker.go
index 4b0e76113..039def91c 100644
--- a/notifier/selfstate/heartbeat/remote_checker.go
+++ b/notifier/selfstate/heartbeat/remote_checker.go
@@ -1,23 +1,20 @@
 package heartbeat
 
-import (
-	"time"
-
-	"github.com/moira-alert/moira"
-)
+import "github.com/moira-alert/moira"
 
 type remoteChecker struct {
 	heartbeat
 	count int64
 }
 
-func GetRemoteChecker(delay int64, logger moira.Logger, database moira.Database) Heartbeater {
+func GetRemoteChecker(delay, lastSuccessfulCheck int64, checkTags []string, logger moira.Logger, database moira.Database) Heartbeater {
 	if delay > 0 {
 		return &remoteChecker{heartbeat: heartbeat{
 			logger:              logger,
 			database:            database,
 			delay:               delay,
-			lastSuccessfulCheck: time.Now().Unix(),
+			lastSuccessfulCheck: lastSuccessfulCheck,
+			checkTags:           checkTags,
 		}}
 	}
 	return nil
@@ -58,3 +55,7 @@ func (remoteChecker) NeedToCheckOthers() bool {
 func (remoteChecker) GetErrorMessage() string {
 	return "Moira-Remote-Checker does not check remote triggers"
 }
+
+func (check *remoteChecker) GetCheckTags() CheckTags {
+	return check.checkTags
+}
diff --git a/notifier/selfstate/heartbeat/remote_checker_test.go b/notifier/selfstate/heartbeat/remote_checker_test.go
index a48ee30a8..ab2992382 100644
--- a/notifier/selfstate/heartbeat/remote_checker_test.go
+++ b/notifier/selfstate/heartbeat/remote_checker_test.go
@@ -24,9 +24,9 @@ func TestGraphiteRemoteChecker(t *testing.T) {
 		database := check.database.(*mock_moira_alert.MockDatabase)
 
 		Convey("Checking the created graphite remote checker", func() {
-			expected := &remoteChecker{heartbeat: heartbeat{database: check.database, logger: check.logger, delay: 1, lastSuccessfulCheck: now}}
-			So(GetRemoteChecker(0, check.logger, check.database), ShouldBeNil)
-			So(GetRemoteChecker(1, check.logger, check.database), ShouldResemble, expected)
+			expected := &remoteChecker{heartbeat: heartbeat{database: check.database, logger: check.logger, delay: 1, lastSuccessfulCheck: now, checkTags: check.checkTags}}
+			So(GetRemoteChecker(0, now, check.checkTags, check.logger, check.database), ShouldBeNil)
+			So(GetRemoteChecker(1, now, check.checkTags, check.logger, check.database), ShouldResemble, expected)
 		})
 
 		Convey("GraphiteRemoteChecker error handling test", func() {
@@ -83,6 +83,7 @@ func TestGraphiteRemoteChecker(t *testing.T) {
 func createGraphiteRemoteCheckerTest(t *testing.T) (*remoteChecker, *gomock.Controller) {
 	mockCtrl := gomock.NewController(t)
 	logger, _ := logging.GetLogger("MetricDelay")
+	checkTags := []string{}
 
-	return GetRemoteChecker(120, logger, mock_moira_alert.NewMockDatabase(mockCtrl)).(*remoteChecker), mockCtrl
+	return GetRemoteChecker(120, time.Now().Unix(), checkTags, logger, mock_moira_alert.NewMockDatabase(mockCtrl)).(*remoteChecker), mockCtrl
 }
diff --git a/notifier/selfstate/selfstate.go b/notifier/selfstate/selfstate.go
index 27245cebc..3e59933d9 100644
--- a/notifier/selfstate/selfstate.go
+++ b/notifier/selfstate/selfstate.go
@@ -68,24 +68,25 @@ func (selfCheck *SelfCheckWorker) Stop() error {
 
 func createStandardHeartbeats(logger moira.Logger, database moira.Database, conf Config) []heartbeat.Heartbeater {
 	heartbeats := make([]heartbeat.Heartbeater, 0)
+	nowTS := time.Now().Unix()
 
-	if hb := heartbeat.GetDatabase(conf.RedisDisconnectDelaySeconds, logger, database); hb != nil {
+	if hb := heartbeat.GetDatabase(conf.RedisDisconnectDelaySeconds, nowTS, conf.Checks.Database.SystemTags, logger, database); hb != nil {
 		heartbeats = append(heartbeats, hb)
 	}
 
-	if hb := heartbeat.GetFilter(conf.LastMetricReceivedDelaySeconds, logger, database); hb != nil {
+	if hb := heartbeat.GetFilter(conf.LastMetricReceivedDelaySeconds, nowTS, conf.Checks.Filter.SystemTags, logger, database); hb != nil {
 		heartbeats = append(heartbeats, hb)
 	}
 
-	if hb := heartbeat.GetLocalChecker(conf.LastCheckDelaySeconds, logger, database); hb != nil && hb.NeedToCheckOthers() {
+	if hb := heartbeat.GetLocalChecker(conf.LastCheckDelaySeconds, nowTS, conf.Checks.LocalChecker.SystemTags, logger, database); hb != nil && hb.NeedToCheckOthers() {
 		heartbeats = append(heartbeats, hb)
 	}
 
-	if hb := heartbeat.GetRemoteChecker(conf.LastRemoteCheckDelaySeconds, logger, database); hb != nil && hb.NeedToCheckOthers() {
+	if hb := heartbeat.GetRemoteChecker(conf.LastRemoteCheckDelaySeconds, nowTS, conf.Checks.RemoteChecker.SystemTags, logger, database); hb != nil && hb.NeedToCheckOthers() {
 		heartbeats = append(heartbeats, hb)
 	}
 
-	if hb := heartbeat.GetNotifier(logger, database); hb != nil {
+	if hb := heartbeat.GetNotifier(conf.Checks.Notifier.SystemTags, logger, database); hb != nil {
 		heartbeats = append(heartbeats, hb)
 	}
 
diff --git a/notifier/selfstate/selfstate_test.go b/notifier/selfstate/selfstate_test.go
index aa94d93fe..555279b6b 100644
--- a/notifier/selfstate/selfstate_test.go
+++ b/notifier/selfstate/selfstate_test.go
@@ -6,6 +6,7 @@ import (
 	"time"
 
 	mock_heartbeat "github.com/moira-alert/moira/mock/heartbeat"
+	"github.com/moira-alert/moira/notifier"
 	"github.com/moira-alert/moira/notifier/selfstate/heartbeat"
 
 	"github.com/moira-alert/moira"
@@ -55,22 +56,101 @@ func TestSelfCheckWorker_selfStateChecker(t *testing.T) {
 }
 
 func TestSelfCheckWorker_sendErrorMessages(t *testing.T) {
-	mock := configureWorker(t, true)
-
 	Convey("Should call notifier send", t, func() {
+		mock := configureWorker(t, true)
 		err := mock.selfCheckWorker.Start()
 		So(err, ShouldBeNil)
 
 		mock.notif.EXPECT().Send(gomock.Any(), gomock.Any())
 
-		var events []moira.NotificationEvent
+		var events []heartbeatNotificationEvent
 		mock.selfCheckWorker.sendErrorMessages(events)
 
 		err = mock.selfCheckWorker.Stop()
 		So(err, ShouldBeNil)
+		mock.mockCtrl.Finish()
 	})
+}
 
-	mock.mockCtrl.Finish()
+func TestSelfCheckWorker_constructUserNotification(t *testing.T) {
+	Convey("Should resemble events to contacts trought system tags", t, func() {
+		contact := moira.ContactData{
+			ID:    "some-contact",
+			Type:  "my_type",
+			Value: "123",
+		}
+
+		notifAndTags := []heartbeatNotificationEvent{
+			{
+				NotificationEvent: moira.NotificationEvent{
+					Metric: "Triggered!!!",
+				},
+				CheckTags: heartbeat.CheckTags{
+					"sys-tag1",
+				},
+			},
+			{
+				NotificationEvent: moira.NotificationEvent{
+					Metric: "Some another problem!!!",
+				},
+				CheckTags: heartbeat.CheckTags{
+					"sys-tag2", "sys-tag-common",
+				},
+			},
+		}
+
+		expected := []*notifier.NotificationPackage{
+			{
+				Contact: contact,
+				Trigger: moira.TriggerData{
+					Name:       "Moira health check",
+					ErrorValue: float64(0),
+				},
+				Events: []moira.NotificationEvent{
+					{
+						Metric: "Triggered!!!",
+					},
+					{
+						Metric: "Some another problem!!!",
+					},
+				},
+				DontResend: true,
+			},
+		}
+
+		mockCtrl := gomock.NewController(t)
+		database := mock_moira_alert.NewMockDatabase(mockCtrl)
+
+		database.EXPECT().GetTagsSubscriptions([]string{"sys-tag1"}).Return([]*moira.SubscriptionData{
+			{
+				ID:       "sub-1",
+				Contacts: []string{contact.ID},
+			},
+		}, nil)
+		database.EXPECT().GetTagsSubscriptions([]string{"sys-tag2", "sys-tag-common"}).Return([]*moira.SubscriptionData{
+			{
+				ID:       "sub-2",
+				Contacts: []string{contact.ID},
+			},
+		}, nil)
+
+		database.EXPECT().GetContacts([]string{contact.ID}).Return([]*moira.ContactData{
+			&contact,
+		}, nil).Times(2)
+
+		logger, _ := logging.GetLogger("SelfState")
+		notif := mock_notifier.NewMockNotifier(mockCtrl)
+
+		mock := &selfCheckWorkerMock{
+			selfCheckWorker: NewSelfCheckWorker(logger, database, notif, Config{}),
+			mockCtrl:        mockCtrl,
+		}
+
+		actual, err := mock.selfCheckWorker.constructUserNotification(notifAndTags)
+		So(err, ShouldBeNil)
+		So(actual, ShouldResemble, expected)
+		mock.mockCtrl.Finish()
+	})
 }
 
 func TestSelfCheckWorker_Start(t *testing.T) {
@@ -112,6 +192,7 @@ func TestSelfCheckWorker(t *testing.T) {
 			first.EXPECT().NeedToCheckOthers().Return(false)
 			first.EXPECT().GetErrorMessage().Return(moira.SelfStateERROR)
 			first.EXPECT().Check(now).Return(int64(0), true, nil)
+			first.EXPECT().GetCheckTags().Return([]string{})
 			mock.database.EXPECT().SetNotifierState(moira.SelfStateERROR)
 
 			events := mock.selfCheckWorker.handleCheckServices(now)
@@ -130,6 +211,7 @@ func TestSelfCheckWorker(t *testing.T) {
 			first.EXPECT().GetErrorMessage().Return(moira.SelfStateERROR)
 			first.EXPECT().NeedTurnOffNotifier().Return(true)
 			first.EXPECT().NeedToCheckOthers().Return(false)
+			first.EXPECT().GetCheckTags().Return([]string{})
 			mock.database.EXPECT().SetNotifierState(moira.SelfStateERROR).Return(err)
 			mock.notif.EXPECT().Send(gomock.Any(), gomock.Any())
 


Reviews:
user: login: AleksandrMatsko
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T04:10:40Z
user: login: AleksandrMatsko
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T04:18:28Z
user: login: AleksandrMatsko
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T04:26:17Z
user: login: AleksandrMatsko
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T04:37:06Z
user: login: AleksandrMatsko
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T04:37:34Z
user: login: AleksandrMatsko
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T04:42:25Z
user: login: AleksandrMatsko
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T10:14:45Z
user: login: AleksandrMatsko
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T10:16:54Z
user: login: Tetrergeru
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T10:21:16Z
user: login: Tetrergeru
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T10:23:41Z
user: login: Tetrergeru
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T10:24:34Z
user: login: Tetrergeru
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T10:25:38Z
user: login: HeavyPunk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T10:33:07Z
user: login: HeavyPunk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T10:33:50Z
user: login: HeavyPunk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T10:35:06Z
user: login: HeavyPunk
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T10:40:15Z
user: login: AleksandrMatsko
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-19T10:55:46Z
user: login: Tetrergeru
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-20T13:39:14Z
user: login: kissken
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-21T04:19:35Z

Review comments:
diff_hunk: @@ -17,6 +17,24 @@ type BytesScanner struct {
 	emitEmptySlice bool
 }
 
+// Select applies a transformation function to each element in the input slice and returns a new slice with the transformed elements.
path: helpers.go
user: login: AleksandrMatsko
body: ```suggestion
// Map applies a transformation function to each element in the input slice and returns a new slice with the transformed elements.
```
created_at: 2025-03-19T04:10:39Z
author_association: MEMBER
diff_hunk: @@ -77,7 +87,48 @@ func (selfCheck *SelfCheckWorker) check(nowTS int64, nextSendErrorMessage int64)
 	return nextSendErrorMessage
 }
 
-func (selfCheck *SelfCheckWorker) sendErrorMessages(events []moira.NotificationEvent) {
+func (selfCheck *SelfCheckWorker) getContactsToNotify(events []HeartbeatNotificationEvent) ([]struct {
path: notifier/selfstate/check.go
user: login: AleksandrMatsko
body: Может быть struct { ... } сделать отдельным приватным типом? А то очень плохо читается...
created_at: 2025-03-19T04:18:28Z
author_association: MEMBER
diff_hunk: @@ -90,7 +141,33 @@ func (selfCheck *SelfCheckWorker) sendErrorMessages(events []moira.NotificationE
 				Name:       "Moira health check",
 				ErrorValue: float64(0),
 			},
-			Events:     events,
+			Events:     moira.Map(events, func(et HeartbeatNotificationEvent) moira.NotificationEvent { return et.NotificationEvent }),
+			DontResend: true,
+		}
+
+		selfCheck.Notifier.Send(&pkg, &sendingWG)
+		sendingWG.Wait()
path: notifier/selfstate/check.go
user: login: AleksandrMatsko
body: Получается мы каждый раз ждём отправки? Может получится из тела цикла вынести?
created_at: 2025-03-19T04:26:17Z
author_association: MEMBER
diff_hunk: @@ -77,7 +87,48 @@ func (selfCheck *SelfCheckWorker) check(nowTS int64, nextSendErrorMessage int64)
 	return nextSendErrorMessage
 }
 
-func (selfCheck *SelfCheckWorker) sendErrorMessages(events []moira.NotificationEvent) {
+func (selfCheck *SelfCheckWorker) getContactsToNotify(events []HeartbeatNotificationEvent) ([]struct {
+	*moira.ContactData
+	*moira.NotificationEvents
+}, error,
+) {
+	result := make(map[*moira.ContactData][]moira.NotificationEvent)
+	for _, event := range events {
+		if len(event.CheckTags) == 0 {
+			continue
+		}
+
+		subscriptions, err := selfCheck.Database.GetTagsSubscriptions(event.CheckTags)
+		if err != nil {
+			return nil, err
+		}
+		for _, subscription := range subscriptions {
+			contacts, err := selfCheck.Database.GetContacts(subscription.Contacts)
+			if err != nil {
+				return nil, err
+			}
+			for _, contact := range contacts {
+				result[contact] = append(result[contact], event.NotificationEvent)
+			}
+		}
+	}
+
+	resultList := make([]struct {
+		*moira.ContactData
+		*moira.NotificationEvents
+	}, 0, len(result))
+	for contact, events := range result {
+		r := moira.NotificationEvents(events)
+		resultList = append(resultList, struct {
+			*moira.ContactData
+			*moira.NotificationEvents
+		}{contact, &r})
+	}
+
+	return resultList, nil
+}
+
+func (selfCheck *SelfCheckWorker) sendErrorMessages(events []HeartbeatNotificationEvent) {
path: notifier/selfstate/check.go
user: login: AleksandrMatsko
body: Если я правильно понял, в этом методе происходит:
- сборка `NotificationPackage` для админов
- рассылка админам
- поход в базу за подписками и сборка `NotificationPackage` для пользователей
- рассылка пользователям

Не получится его как-нибудь разбить?
created_at: 2025-03-19T04:37:06Z
author_association: MEMBER
diff_hunk: @@ -90,7 +141,33 @@ func (selfCheck *SelfCheckWorker) sendErrorMessages(events []moira.NotificationE
 				Name:       "Moira health check",
 				ErrorValue: float64(0),
 			},
-			Events:     events,
+			Events:     moira.Map(events, func(et HeartbeatNotificationEvent) moira.NotificationEvent { return et.NotificationEvent }),
+			DontResend: true,
+		}
+
+		selfCheck.Notifier.Send(&pkg, &sendingWG)
+		sendingWG.Wait()
path: notifier/selfstate/check.go
user: login: AleksandrMatsko
body: Ниже тот же вопрос
created_at: 2025-03-19T04:37:34Z
author_association: MEMBER
diff_hunk: @@ -77,7 +87,48 @@ func (selfCheck *SelfCheckWorker) check(nowTS int64, nextSendErrorMessage int64)
 	return nextSendErrorMessage
 }
 
-func (selfCheck *SelfCheckWorker) sendErrorMessages(events []moira.NotificationEvent) {
+func (selfCheck *SelfCheckWorker) getContactsToNotify(events []HeartbeatNotificationEvent) ([]struct {
path: notifier/selfstate/check.go
user: login: AleksandrMatsko
body: Или попробовать сразу `NotificationPackage` собрать?
created_at: 2025-03-19T04:42:25Z
author_association: MEMBER
diff_hunk: @@ -7,8 +7,15 @@ import (
 
 	"github.com/moira-alert/moira"
 	"github.com/moira-alert/moira/notifier"
+	"github.com/moira-alert/moira/notifier/selfstate/heartbeat"
 )
 
+// HeartbeatNotificationEvent contains NotificationEvent and CheckTags.
+type HeartbeatNotificationEvent struct {
path: notifier/selfstate/check.go
user: login: AleksandrMatsko
body: Нужна как публичная?
created_at: 2025-03-19T10:14:44Z
author_association: MEMBER
diff_hunk: @@ -77,9 +87,77 @@ func (selfCheck *SelfCheckWorker) check(nowTS int64, nextSendErrorMessage int64)
 	return nextSendErrorMessage
 }
 
-func (selfCheck *SelfCheckWorker) sendErrorMessages(events []moira.NotificationEvent) {
+func (selfCheck *SelfCheckWorker) constructUserNotification(events []HeartbeatNotificationEvent) ([]*notifier.NotificationPackage, error) {
+	contactToEvents := make(map[*moira.ContactData][]moira.NotificationEvent)
+	for _, event := range events {
+		if len(event.CheckTags) == 0 {
+			continue
+		}
+
+		subscriptions, err := selfCheck.Database.GetTagsSubscriptions(event.CheckTags)
path: notifier/selfstate/check.go
user: login: AleksandrMatsko
body: Помню, что хотели как-то кешировать данные о подписках. В итоге отказались от этой идеи или отдельным PR-ом принесёшь?
created_at: 2025-03-19T10:16:54Z
author_association: MEMBER
diff_hunk: @@ -17,6 +17,24 @@ type BytesScanner struct {
 	emitEmptySlice bool
 }
 
+// Select applies a transformation function to each element in the input slice and returns a new slice with the transformed elements.
path: helpers.go
user: login: Tetrergeru
body: Сразу видно шарповый бэкграунд :smile: 
created_at: 2025-03-19T10:21:16Z
author_association: MEMBER
diff_hunk: @@ -17,6 +17,24 @@ type BytesScanner struct {
 	emitEmptySlice bool
 }
 
+// Select applies a transformation function to each element in the input slice and returns a new slice with the transformed elements.
+func Map[T any, R any](input []T, transform func(T) R) []R {
+	result := make([]R, len(input))
+	for i, v := range input {
+		result[i] = transform(v)
+	}
+	return result
+}
+
+// MapKeysToSlice преобразует map[key]value в []key.
+func MapKeysToSlice[K comparable, V any](m map[K]V) []K {
path: helpers.go
user: login: Tetrergeru
body: В стандартной библиотеке появилась функция `maps.Keys` в версии 1.23. А у нас 1.22. Мб оставим коммент?

`// TODO: Remove after update to go 1.23`
created_at: 2025-03-19T10:23:41Z
author_association: MEMBER
diff_hunk: @@ -77,7 +87,48 @@ func (selfCheck *SelfCheckWorker) check(nowTS int64, nextSendErrorMessage int64)
 	return nextSendErrorMessage
 }
 
-func (selfCheck *SelfCheckWorker) sendErrorMessages(events []moira.NotificationEvent) {
+func (selfCheck *SelfCheckWorker) getContactsToNotify(events []HeartbeatNotificationEvent) ([]struct {
path: notifier/selfstate/check.go
user: login: Tetrergeru
body: А может просто значения возвращать кортежем? Или тут по какой-то причине лучше так не делать?
created_at: 2025-03-19T10:24:33Z
author_association: MEMBER
diff_hunk: @@ -77,7 +87,48 @@ func (selfCheck *SelfCheckWorker) check(nowTS int64, nextSendErrorMessage int64)
 	return nextSendErrorMessage
 }
 
-func (selfCheck *SelfCheckWorker) sendErrorMessages(events []moira.NotificationEvent) {
+func (selfCheck *SelfCheckWorker) getContactsToNotify(events []HeartbeatNotificationEvent) ([]struct {
path: notifier/selfstate/check.go
user: login: Tetrergeru
body: А, я слепой, тут массив структур. Тогда лучше вынести в отдельный тип, да
created_at: 2025-03-19T10:25:38Z
author_association: MEMBER
diff_hunk: @@ -17,6 +17,24 @@ type BytesScanner struct {
 	emitEmptySlice bool
 }
 
+// Select applies a transformation function to each element in the input slice and returns a new slice with the transformed elements.
+func Map[T any, R any](input []T, transform func(T) R) []R {
+	result := make([]R, len(input))
+	for i, v := range input {
+		result[i] = transform(v)
+	}
+	return result
+}
+
+// MapKeysToSlice преобразует map[key]value в []key.
+func MapKeysToSlice[K comparable, V any](m map[K]V) []K {
path: helpers.go
user: login: HeavyPunk
body: Тут ещё как-то godoc на русском пролез, не уследил за нейронкой)
created_at: 2025-03-19T10:33:07Z
author_association: MEMBER
diff_hunk: @@ -77,9 +87,77 @@ func (selfCheck *SelfCheckWorker) check(nowTS int64, nextSendErrorMessage int64)
 	return nextSendErrorMessage
 }
 
-func (selfCheck *SelfCheckWorker) sendErrorMessages(events []moira.NotificationEvent) {
+func (selfCheck *SelfCheckWorker) constructUserNotification(events []HeartbeatNotificationEvent) ([]*notifier.NotificationPackage, error) {
+	contactToEvents := make(map[*moira.ContactData][]moira.NotificationEvent)
+	for _, event := range events {
+		if len(event.CheckTags) == 0 {
+			continue
+		}
+
+		subscriptions, err := selfCheck.Database.GetTagsSubscriptions(event.CheckTags)
path: notifier/selfstate/check.go
user: login: HeavyPunk
body: От этой идеи отказались, можно утечь по памяти
created_at: 2025-03-19T10:33:50Z
author_association: MEMBER
diff_hunk: @@ -7,8 +7,15 @@ import (
 
 	"github.com/moira-alert/moira"
 	"github.com/moira-alert/moira/notifier"
+	"github.com/moira-alert/moira/notifier/selfstate/heartbeat"
 )
 
+// HeartbeatNotificationEvent contains NotificationEvent and CheckTags.
+type HeartbeatNotificationEvent struct {
path: notifier/selfstate/check.go
user: login: HeavyPunk
body: Больше не нужна, щас поправлю
created_at: 2025-03-19T10:35:06Z
author_association: MEMBER
diff_hunk: @@ -17,6 +17,24 @@ type BytesScanner struct {
 	emitEmptySlice bool
 }
 
+// Select applies a transformation function to each element in the input slice and returns a new slice with the transformed elements.
+func Map[T any, R any](input []T, transform func(T) R) []R {
+	result := make([]R, len(input))
+	for i, v := range input {
+		result[i] = transform(v)
+	}
+	return result
+}
+
+// MapKeysToSlice преобразует map[key]value в []key.
+func MapKeysToSlice[K comparable, V any](m map[K]V) []K {
path: helpers.go
user: login: HeavyPunk
body: Вообще, функция больше нигде не используется, просто дропну её
created_at: 2025-03-19T10:40:15Z
author_association: MEMBER

PR comments:
user: login: codecov-commenter
created_at: 2025-03-18T06:45:36Z
body: :warning: Please install the !['codecov app svg image'](https://github.com/codecov/engineering-team/assets/152432831/e90313f4-9d3a-4b63-8b54-cfe14e7ec20d) to ensure uploads and comments are reliably processed by Codecov.

## [Codecov](https://app.codecov.io/gh/moira-alert/moira/pull/1161?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira) Report
:x: Patch coverage is `63.41463%` with `45 lines` in your changes missing coverage. Please review.
:warning: Please [upload](https://docs.codecov.com/docs/codecov-uploader) report for BASE (`feat/system-tags-api@f53bfeb`). [Learn more](https://docs.codecov.io/docs/error-reference?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira#section-missing-base-commit) about missing BASE report.

| [Files with missing lines](https://app.codecov.io/gh/moira-alert/moira/pull/1161?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira) | Patch % | Lines |
|---|---|---|
| [cmd/notifier/config.go](https://app.codecov.io/gh/moira-alert/moira/pull/1161?src=pr&el=tree&filepath=cmd%2Fnotifier%2Fconfig.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira#diff-Y21kL25vdGlmaWVyL2NvbmZpZy5nbw==) | 0.00% | [18 Missing :warning: ](https://app.codecov.io/gh/moira-alert/moira/pull/1161?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira) |
| [mock/heartbeat/heartbeat.go](https://app.codecov.io/gh/moira-alert/moira/pull/1161?src=pr&el=tree&filepath=mock%2Fheartbeat%2Fheartbeat.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira#diff-bW9jay9oZWFydGJlYXQvaGVhcnRiZWF0Lmdv) | 0.00% | [8 Missing :warning: ](https://app.codecov.io/gh/moira-alert/moira/pull/1161?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira) |
| [helpers.go](https://app.codecov.io/gh/moira-alert/moira/pull/1161?src=pr&el=tree&filepath=helpers.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira#diff-aGVscGVycy5nbw==) | 0.00% | [5 Missing :warning: ](https://app.codecov.io/gh/moira-alert/moira/pull/1161?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira) |
| [notifier/selfstate/check.go](https://app.codecov.io/gh/moira-alert/moira/pull/1161?src=pr&el=tree&filepath=notifier%2Fselfstate%2Fcheck.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira#diff-bm90aWZpZXIvc2VsZnN0YXRlL2NoZWNrLmdv) | 92.59% | [2 Missing and 2 partials :warning: ](https://app.codecov.io/gh/moira-alert/moira/pull/1161?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira) |
| [notifier/selfstate/heartbeat/database.go](https://app.codecov.io/gh/moira-alert/moira/pull/1161?src=pr&el=tree&filepath=notifier%2Fselfstate%2Fheartbeat%2Fdatabase.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira#diff-bm90aWZpZXIvc2VsZnN0YXRlL2hlYXJ0YmVhdC9kYXRhYmFzZS5nbw==) | 60.00% | [2 Missing :warning: ](https://app.codecov.io/gh/moira-alert/moira/pull/1161?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira) |
| [notifier/selfstate/heartbeat/filter.go](https://app.codecov.io/gh/moira-alert/moira/pull/1161?src=pr&el=tree&filepath=notifier%2Fselfstate%2Fheartbeat%2Ffilter.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira#diff-bm90aWZpZXIvc2VsZnN0YXRlL2hlYXJ0YmVhdC9maWx0ZXIuZ28=) | 60.00% | [2 Missing :warning: ](https://app.codecov.io/gh/moira-alert/moira/pull/1161?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira) |
| [notifier/selfstate/heartbeat/local\_checker.go](https://app.codecov.io/gh/moira-alert/moira/pull/1161?src=pr&el=tree&filepath=notifier%2Fselfstate%2Fheartbeat%2Flocal_checker.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira#diff-bm90aWZpZXIvc2VsZnN0YXRlL2hlYXJ0YmVhdC9sb2NhbF9jaGVja2VyLmdv) | 60.00% | [2 Missing :warning: ](https://app.codecov.io/gh/moira-alert/moira/pull/1161?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira) |
| [notifier/selfstate/heartbeat/notifier.go](https://app.codecov.io/gh/moira-alert/moira/pull/1161?src=pr&el=tree&filepath=notifier%2Fselfstate%2Fheartbeat%2Fnotifier.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira#diff-bm90aWZpZXIvc2VsZnN0YXRlL2hlYXJ0YmVhdC9ub3RpZmllci5nbw==) | 83.33% | [2 Missing :warning: ](https://app.codecov.io/gh/moira-alert/moira/pull/1161?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira) |
| [notifier/selfstate/heartbeat/remote\_checker.go](https://app.codecov.io/gh/moira-alert/moira/pull/1161?src=pr&el=tree&filepath=notifier%2Fselfstate%2Fheartbeat%2Fremote_checker.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira#diff-bm90aWZpZXIvc2VsZnN0YXRlL2hlYXJ0YmVhdC9yZW1vdGVfY2hlY2tlci5nbw==) | 60.00% | [2 Missing :warning: ](https://app.codecov.io/gh/moira-alert/moira/pull/1161?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira) |
:exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.

<details><summary>Additional details and impacted files</summary>



```diff
@@                   Coverage Diff                   @@
##             feat/system-tags-api    #1161   +/-   ##
=======================================================
  Coverage                        ?   54.17%           
=======================================================
  Files                           ?      296           
  Lines                           ?    17781           
  Branches                        ?        0           
=======================================================
  Hits                            ?     9633           
  Misses                          ?     7560           
  Partials                        ?      588           
```
</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/moira-alert/moira/pull/1161?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Moira).
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: HeavyPunk
created_at: 2025-03-20T07:41:13Z
body: /build
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-03-20T07:43:16Z
body: <!-- add-pr-comment:add-pr-comment -->

Build and push Docker images with tag: feat-system-tags-notifier.2025-03-20.70dc811
author_association: NONE