diff --git a/cmd/api/main.go b/cmd/api/main.go index fc034ddb2..65face808 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -15,6 +15,7 @@ import ( "github.com/moira-alert/moira/api/handler" "github.com/moira-alert/moira/cmd" "github.com/moira-alert/moira/database/redis" + "github.com/moira-alert/moira/database/stats" "github.com/moira-alert/moira/index" logging "github.com/moira-alert/moira/logging/zerolog_adapter" _ "go.uber.org/automaxprocs" @@ -120,9 +121,13 @@ func main() { Msg("Failed to initialize metric sources") } - stats := newTriggerStats(metricSourceProvider.GetClusterList(), logger, database, telemetry.Metrics) - stats.start() - defer stats.stop() //nolint + // Start stats manager + statsManager := stats.NewStatsManager( + stats.NewTriggerStats(telemetry.Metrics, database, logger, metricSourceProvider.GetClusterList()), + stats.NewContactStats(telemetry.Metrics, database, logger), + ) + statsManager.Start() + defer statsManager.Stop() //nolint webConfig := applicationConfig.Web.getSettings(len(metricSourceProvider.GetAllSources()) > 0, applicationConfig.Remotes) diff --git a/database/stats/contact.go b/database/stats/contact.go new file mode 100644 index 000000000..fba1c0daf --- /dev/null +++ b/database/stats/contact.go @@ -0,0 +1,68 @@ +package stats + +import ( + "time" + + "github.com/moira-alert/moira" + "github.com/moira-alert/moira/metrics" +) + +type contactStats struct { + metrics *metrics.ContactsMetrics + database moira.Database + logger moira.Logger +} + +// NewContactStats creates and initializes a new contactStats object. +func NewContactStats( + metricsRegistry metrics.Registry, + database moira.Database, + logger moira.Logger, +) *contactStats { + return &contactStats{ + metrics: metrics.NewContactsMetrics(metricsRegistry), + database: database, + logger: logger, + } +} + +// StartReport starts reporting statistics about contacts. +func (stats *contactStats) StartReport(stop <-chan struct{}) { + checkTicker := time.NewTicker(time.Minute) + defer checkTicker.Stop() + + stats.logger.Info().Msg("Start contact statistics reporter") + + for { + select { + case <-stop: + stats.logger.Info().Msg("Stop contact statistics reporter") + return + + case <-checkTicker.C: + stats.checkContactsCount() + } + } +} + +func (stats *contactStats) checkContactsCount() { + contacts, err := stats.database.GetAllContacts() + if err != nil { + stats.logger.Warning(). + Error(err). + Msg("Failed to get all contacts") + return + } + + contactsCounter := make(map[string]int64) + + for _, contact := range contacts { + if contact != nil { + contactsCounter[contact.Type]++ + } + } + + for contact, count := range contactsCounter { + stats.metrics.Mark(contact, count) + } +} diff --git a/database/stats/contact_test.go b/database/stats/contact_test.go new file mode 100644 index 000000000..ca5051514 --- /dev/null +++ b/database/stats/contact_test.go @@ -0,0 +1,107 @@ +package stats + +import ( + "errors" + "testing" + + "github.com/golang/mock/gomock" + + "github.com/moira-alert/moira" + logging "github.com/moira-alert/moira/logging/zerolog_adapter" + "github.com/moira-alert/moira/metrics" + mock_moira_alert "github.com/moira-alert/moira/mock/moira-alert" + mock_metrics "github.com/moira-alert/moira/mock/moira-alert/metrics" + . "github.com/smartystreets/goconvey/convey" +) + +const metricPrefix = "contacts" + +var testContacts = []*moira.ContactData{ + { + Type: "test1", + }, + { + Type: "test1", + }, + { + Type: "test2", + }, + { + Type: "test3", + }, + { + Type: "test2", + }, + { + Type: "test1", + }, +} + +func TestNewContactsStats(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + registry := mock_metrics.NewMockRegistry(mockCtrl) + database := mock_moira_alert.NewMockDatabase(mockCtrl) + logger, _ := logging.GetLogger("Test") + + Convey("Successfully created new contacts stats", t, func() { + stats := NewContactStats(registry, database, logger) + + So(stats, ShouldResemble, &contactStats{ + metrics: metrics.NewContactsMetrics(registry), + database: database, + logger: logger, + }) + }) +} + +func TestCheckingContactsCount(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + registry := mock_metrics.NewMockRegistry(mockCtrl) + database := mock_moira_alert.NewMockDatabase(mockCtrl) + logger := mock_moira_alert.NewMockLogger(mockCtrl) + eventBuilder := mock_moira_alert.NewMockEventBuilder(mockCtrl) + + test1Meter := mock_metrics.NewMockMeter(mockCtrl) + test2Meter := mock_metrics.NewMockMeter(mockCtrl) + test3Meter := mock_metrics.NewMockMeter(mockCtrl) + + var test1ContactCount, test2ContactCount, test3ContactCount int64 + var test1ContactType, test2ContactType, test3ContactType string + test1ContactCount, test1ContactType = 3, "test1" + test2ContactCount, test2ContactType = 2, "test2" + test3ContactCount, test3ContactType = 1, "test3" + + getAllContactsErr := errors.New("failed to get all contacts") + + Convey("Test checking contacts count", t, func() { + Convey("Successfully checking contacts count", func() { + database.EXPECT().GetAllContacts().Return(testContacts, nil).Times(1) + + registry.EXPECT().NewMeter(metricPrefix, test1ContactType).Return(test1Meter).Times(1) + registry.EXPECT().NewMeter(metricPrefix, test2ContactType).Return(test2Meter).Times(1) + registry.EXPECT().NewMeter(metricPrefix, test3ContactType).Return(test3Meter).Times(1) + + test1Meter.EXPECT().Mark(test1ContactCount) + test2Meter.EXPECT().Mark(test2ContactCount) + test3Meter.EXPECT().Mark(test3ContactCount) + + stats := NewContactStats(registry, database, logger) + stats.checkContactsCount() + }) + + Convey("Get error from get all contacts", func() { + database.EXPECT().GetAllContacts().Return(nil, getAllContactsErr).Times(1) + + logger.EXPECT().Warning().Return(eventBuilder).Times(1) + eventBuilder.EXPECT().Error(getAllContactsErr).Return(eventBuilder).Times(1) + eventBuilder.EXPECT().Msg("Failed to get all contacts").Times(1) + + stats := NewContactStats(registry, database, logger) + stats.checkContactsCount() + }) + }) +} diff --git a/database/stats/stats.go b/database/stats/stats.go new file mode 100644 index 000000000..0d6da505e --- /dev/null +++ b/database/stats/stats.go @@ -0,0 +1,40 @@ +package stats + +import ( + "gopkg.in/tomb.v2" +) + +// StatsReporter represents an interface for objects that report statistics. +type StatsReporter interface { + StartReport(stop <-chan struct{}) +} + +type statsManager struct { + tomb tomb.Tomb + reporters []StatsReporter +} + +// NewStatsManager creates a new statsManager instance with the given StatsReporters. +func NewStatsManager(reporters ...StatsReporter) *statsManager { + return &statsManager{ + reporters: reporters, + } +} + +// Start starts reporting statistics for all registered StatsReporters. +func (manager *statsManager) Start() { + for _, reporter := range manager.reporters { + reporter := reporter + + manager.tomb.Go(func() error { + reporter.StartReport(manager.tomb.Dying()) + return nil + }) + } +} + +// Stop stops all reporting activities and waits for the completion. +func (manager *statsManager) Stop() error { + manager.tomb.Kill(nil) + return manager.tomb.Wait() +} diff --git a/database/stats/stats_test.go b/database/stats/stats_test.go new file mode 100644 index 000000000..1e73d5572 --- /dev/null +++ b/database/stats/stats_test.go @@ -0,0 +1,35 @@ +package stats + +import ( + "testing" + + "github.com/golang/mock/gomock" + mock_moira_alert "github.com/moira-alert/moira/mock/moira-alert" + . "github.com/smartystreets/goconvey/convey" +) + +func TestNewStatsManager(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + triggerStats := mock_moira_alert.NewMockStatsReporter(mockCtrl) + contactStats := mock_moira_alert.NewMockStatsReporter(mockCtrl) + + Convey("Test new stats manager", t, func() { + Convey("Successfully create new stats manager", func() { + manager := NewStatsManager(triggerStats, contactStats) + + So(manager.reporters, ShouldResemble, []StatsReporter{triggerStats, contactStats}) + }) + + Convey("Successfully start stats manager", func() { + manager := NewStatsManager(triggerStats, contactStats) + + triggerStats.EXPECT().StartReport(manager.tomb.Dying()).Times(1) + contactStats.EXPECT().StartReport(manager.tomb.Dying()).Times(1) + + manager.Start() + defer manager.Stop() //nolint + }) + }) +} diff --git a/cmd/api/trigger_stats.go b/database/stats/trigger.go similarity index 67% rename from cmd/api/trigger_stats.go rename to database/stats/trigger.go index e73d3f5ae..b13df7e9a 100644 --- a/cmd/api/trigger_stats.go +++ b/database/stats/trigger.go @@ -1,26 +1,25 @@ -package main +package stats import ( "time" "github.com/moira-alert/moira" "github.com/moira-alert/moira/metrics" - "gopkg.in/tomb.v2" ) type triggerStats struct { - tomb tomb.Tomb metrics *metrics.TriggersMetrics - clusters []moira.ClusterKey database moira.Database logger moira.Logger + clusters []moira.ClusterKey } -func newTriggerStats( - clusters []moira.ClusterKey, - logger moira.Logger, - database moira.Database, +// NewTriggerStats creates and initializes a new triggerStats object. +func NewTriggerStats( metricsRegistry metrics.Registry, + database moira.Database, + logger moira.Logger, + clusters []moira.ClusterKey, ) *triggerStats { return &triggerStats{ logger: logger, @@ -30,16 +29,18 @@ func newTriggerStats( } } -func (stats *triggerStats) start() { - stats.tomb.Go(stats.startCheckingTriggerCount) -} +// StartReport starts reporting statistics about triggers. +func (stats *triggerStats) StartReport(stop <-chan struct{}) { + checkTicker := time.NewTicker(time.Minute) + defer checkTicker.Stop() + + stats.logger.Info().Msg("Start trigger statistics reporter") -func (stats *triggerStats) startCheckingTriggerCount() error { - checkTicker := time.NewTicker(time.Second * 60) for { select { - case <-stats.tomb.Dying(): - return nil + case <-stop: + stats.logger.Info().Msg("Stop trigger statistics reporter") + return case <-checkTicker.C: stats.checkTriggerCount() @@ -47,11 +48,6 @@ func (stats *triggerStats) startCheckingTriggerCount() error { } } -func (stats *triggerStats) stop() error { - stats.tomb.Kill(nil) - return stats.tomb.Wait() -} - func (stats *triggerStats) checkTriggerCount() { triggersCount, err := stats.database.GetTriggerCount(stats.clusters) if err != nil { diff --git a/cmd/api/trigger_stats_test.go b/database/stats/trigger_test.go similarity index 90% rename from cmd/api/trigger_stats_test.go rename to database/stats/trigger_test.go index 636141b4c..9866d42a5 100644 --- a/cmd/api/trigger_stats_test.go +++ b/database/stats/trigger_test.go @@ -1,4 +1,4 @@ -package main +package stats import ( "testing" @@ -30,8 +30,8 @@ func TestTriggerStatsCheckTriggerCount(t *testing.T) { registry.EXPECT().NewMeter("triggers", moira.GraphiteRemote.String(), moira.DefaultCluster.String()).Return(graphiteRemoteMeter) registry.EXPECT().NewMeter("triggers", moira.PrometheusRemote.String(), moira.DefaultCluster.String()).Return(prometheusRemoteMeter) - dataBase := mock_moira_alert.NewMockDatabase(mockCtrl) - dataBase.EXPECT().GetTriggerCount(gomock.Any()).Return(map[moira.ClusterKey]int64{ + database := mock_moira_alert.NewMockDatabase(mockCtrl) + database.EXPECT().GetTriggerCount(gomock.Any()).Return(map[moira.ClusterKey]int64{ moira.DefaultLocalCluster: graphiteLocalCount, moira.DefaultGraphiteRemoteCluster: graphiteRemoteCount, moira.DefaultPrometheusRemoteCluster: prometheusRemoteCount, @@ -47,7 +47,7 @@ func TestTriggerStatsCheckTriggerCount(t *testing.T) { moira.DefaultGraphiteRemoteCluster, moira.DefaultPrometheusRemoteCluster, } - triggerStats := newTriggerStats(clusters, logger, dataBase, registry) + triggerStats := NewTriggerStats(registry, database, logger, clusters) triggerStats.checkTriggerCount() }) diff --git a/generate_mocks.sh b/generate_mocks.sh index 43e6fd982..63f468629 100755 --- a/generate_mocks.sh +++ b/generate_mocks.sh @@ -26,4 +26,6 @@ mockgen -destination=mock/moira-alert/metrics/registry.go -package=mock_moira_al mockgen -destination=mock/moira-alert/metrics/meter.go -package=mock_moira_alert github.com/moira-alert/moira/metrics Meter mockgen -destination=mock/moira-alert/prometheus_api.go -package=mock_moira_alert github.com/moira-alert/moira/metric_source/prometheus PrometheusApi +mockgen -destination=mock/moira-alert/database_stats.go -package=mock_moira_alert github.com/moira-alert/moira/database/stats StatsReporter + git add mock/* diff --git a/metrics/contacts.go b/metrics/contacts.go new file mode 100644 index 000000000..ba9cfcefa --- /dev/null +++ b/metrics/contacts.go @@ -0,0 +1,36 @@ +package metrics + +import "regexp" + +var nonAllowedMetricCharsRegex = regexp.MustCompile("[^a-zA-Z0-9_]") + +// Collection of metrics for contacts counting. +type ContactsMetrics struct { + contactsCount map[string]Meter + registry Registry +} + +// Creates and configurates the instance of ContactsMetrics. +func NewContactsMetrics(registry Registry) *ContactsMetrics { + meters := make(map[string]Meter) + + return &ContactsMetrics{ + contactsCount: meters, + registry: registry, + } +} + +// replaceNonAllowedMetricCharacters replaces non-allowed characters in the given metric string with underscores. +func (metrics *ContactsMetrics) replaceNonAllowedMetricCharacters(metric string) string { + return nonAllowedMetricCharsRegex.ReplaceAllString(metric, "_") +} + +// Marks the number of contacts of different types. +func (metrics *ContactsMetrics) Mark(contact string, count int64) { + if _, ok := metrics.contactsCount[contact]; !ok { + metric := metrics.replaceNonAllowedMetricCharacters(contact) + metrics.contactsCount[contact] = metrics.registry.NewMeter("contacts", metric) + } + + metrics.contactsCount[contact].Mark(count) +} diff --git a/mock/moira-alert/database_stats.go b/mock/moira-alert/database_stats.go new file mode 100644 index 000000000..ebcf93022 --- /dev/null +++ b/mock/moira-alert/database_stats.go @@ -0,0 +1,46 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/moira-alert/moira/database/stats (interfaces: StatsReporter) + +// Package mock_moira_alert is a generated GoMock package. +package mock_moira_alert + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockStatsReporter is a mock of StatsReporter interface. +type MockStatsReporter struct { + ctrl *gomock.Controller + recorder *MockStatsReporterMockRecorder +} + +// MockStatsReporterMockRecorder is the mock recorder for MockStatsReporter. +type MockStatsReporterMockRecorder struct { + mock *MockStatsReporter +} + +// NewMockStatsReporter creates a new mock instance. +func NewMockStatsReporter(ctrl *gomock.Controller) *MockStatsReporter { + mock := &MockStatsReporter{ctrl: ctrl} + mock.recorder = &MockStatsReporterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStatsReporter) EXPECT() *MockStatsReporterMockRecorder { + return m.recorder +} + +// StartReport mocks base method. +func (m *MockStatsReporter) StartReport(arg0 <-chan struct{}) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "StartReport", arg0) +} + +// StartReport indicates an expected call of StartReport. +func (mr *MockStatsReporterMockRecorder) StartReport(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartReport", reflect.TypeOf((*MockStatsReporter)(nil).StartReport), arg0) +}