From f0fee594d955cc808c1754a1badae42e92d87a8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Mierzwa?= Date: Sat, 6 Jul 2019 10:57:05 +0100 Subject: [PATCH] feat(api): add labels stats to the api response These will be used by an overview modal, showing top labels. --- alerts.go | 42 ++++++++ api_test.go | 192 ++++++++++++++++++++++++++++++++++++ internal/models/api.go | 47 +++++++++ internal/models/api_test.go | 178 +++++++++++++++++++++++++++++++++ ui/src/Stores/AlertStore.js | 3 +- ui/src/__mocks__/Fetch.js | 1 + views.go | 6 ++ views_test.go | 3 + 8 files changed, 471 insertions(+), 1 deletion(-) diff --git a/alerts.go b/alerts.go index 5d06bd338..0b50f20be 100644 --- a/alerts.go +++ b/alerts.go @@ -1,6 +1,9 @@ package main import ( + "math" + "sort" + "github.com/prymitive/karma/internal/alertmanager" "github.com/prymitive/karma/internal/filters" "github.com/prymitive/karma/internal/models" @@ -22,6 +25,45 @@ func getFiltersFromQuery(filterStrings []string) ([]filters.FilterT, bool) { return matchFilters, validFilters } +func countLabel(countStore map[string]map[string]int, key string, val string) { + if _, found := countStore[key]; !found { + countStore[key] = make(map[string]int) + } + if _, found := countStore[key][val]; found { + countStore[key][val]++ + } else { + countStore[key][val] = 1 + } +} + +func countersToLabelStats(counters map[string]map[string]int) models.LabelNameStatsList { + data := models.LabelNameStatsList{} + + for name, valueMap := range counters { + nameStats := models.LabelNameStats{ + Name: name, + Values: models.LabelValueStatsList{}, + } + for value, hits := range valueMap { + nameStats.Hits += hits + valueStats := models.LabelValueStats{ + Value: value, + Hits: hits, + } + nameStats.Values = append(nameStats.Values, valueStats) + } + for i, value := range nameStats.Values { + nameStats.Values[i].Percent = int(math.Round((float64(value.Hits) / float64(nameStats.Hits)) * 100.0)) + } + sort.Sort(nameStats.Values) + data = append(data, nameStats) + } + + sort.Sort(data) + + return data +} + func getUpstreams() models.AlertmanagerAPISummary { summary := models.AlertmanagerAPISummary{} diff --git a/api_test.go b/api_test.go index a94bc076c..fc94dd7ad 100644 --- a/api_test.go +++ b/api_test.go @@ -647,6 +647,160 @@ var groupTests = []groupTest{ }, } +var countsMap = models.LabelNameStatsList{ + { + Name: "@receiver", + Hits: 24, + Values: models.LabelValueStatsList{ + models.LabelValueStats{ + Value: "by-cluster-service", + Hits: 12, + Percent: 50, + }, + models.LabelValueStats{ + Value: "by-name", + Hits: 12, + Percent: 50, + }, + }, + }, + { + Name: "@state", + Hits: 24, + Values: models.LabelValueStatsList{ + models.LabelValueStats{ + Value: "active", + Hits: 16, + Percent: 67, + }, + models.LabelValueStats{ + Value: "suppressed", + Hits: 8, + Percent: 33, + }, + }, + }, + { + Name: "alertname", + Hits: 24, + Values: models.LabelValueStatsList{ + models.LabelValueStats{ + Value: "Free_Disk_Space_Too_Low", + Hits: 2, + Percent: 8, + }, + models.LabelValueStats{ + Value: "HTTP_Probe_Failed", + Hits: 4, + Percent: 17, + }, + models.LabelValueStats{ + Value: "Host_Down", + Hits: 16, + Percent: 67, + }, + models.LabelValueStats{ + Value: "Memory_Usage_Too_High", + Hits: 2, + Percent: 8, + }, + }, + }, + { + Name: "cluster", + Hits: 24, + Values: models.LabelValueStatsList{ + models.LabelValueStats{ + Value: "dev", + Hits: 10, + Percent: 42, + }, + models.LabelValueStats{ + Value: "prod", + Hits: 6, + Percent: 25, + }, + models.LabelValueStats{ + Value: "staging", + Hits: 8, + Percent: 33, + }, + }, + }, + { + Name: "instance", + Hits: 24, + Values: models.LabelValueStatsList{ + models.LabelValueStats{ + Value: "server1", + Hits: 2, + Percent: 8, + }, + models.LabelValueStats{ + Value: "server2", + Hits: 4, + Percent: 17, + }, + models.LabelValueStats{ + Value: "server3", + Hits: 2, + Percent: 8, + }, + models.LabelValueStats{ + Value: "server4", + Hits: 2, + Percent: 8, + }, + models.LabelValueStats{ + Value: "server5", + Hits: 4, + Percent: 17, + }, + models.LabelValueStats{ + Value: "server6", + Hits: 2, + Percent: 8, + }, + models.LabelValueStats{ + Value: "server7", + Hits: 2, + Percent: 8, + }, + models.LabelValueStats{ + Value: "server8", + Hits: 2, + Percent: 8, + }, + models.LabelValueStats{ + Value: "web1", + Hits: 2, + Percent: 8, + }, + models.LabelValueStats{ + Value: "web2", + Hits: 2, + Percent: 8, + }, + }, + }, + { + Name: "job", + Hits: 24, + Values: models.LabelValueStatsList{ + models.LabelValueStats{ + Value: "node_exporter", + Hits: 8, + Percent: 33, + }, + models.LabelValueStats{ + Value: "node_ping", + Hits: 16, + Percent: 67, + }, + }, + }, +} + var filtersExpected = []models.Filter{} func compareAlertGroups(testCase groupTest, group models.APIAlertGroup) bool { @@ -836,6 +990,44 @@ func TestVerifyAllGroups(t *testing.T) { t.Errorf("[%s] Silences mismatch, expected >0 but got %d", version, len(am)) } + for _, expectedNameStats := range countsMap { + var foundName bool + for _, nameStats := range ur.Counters { + if nameStats.Name == expectedNameStats.Name { + if nameStats.Hits != expectedNameStats.Hits { + t.Errorf("[%s] Counters mismatch for '%s', expected %v hits but got %v", + version, nameStats.Name, expectedNameStats.Hits, nameStats.Hits) + } + for _, expectedValueStats := range expectedNameStats.Values { + var foundValue bool + for _, valueStats := range nameStats.Values { + if valueStats.Value == expectedValueStats.Value { + if valueStats.Hits != expectedValueStats.Hits { + t.Errorf("[%s] Counters mismatch for '%s: %s', expected %v hits but got %v", + version, nameStats.Name, valueStats.Value, expectedValueStats.Hits, valueStats.Hits) + } + if valueStats.Percent != expectedValueStats.Percent { + t.Errorf("[%s] Percent mismatch for '%s: %s', expected %v%% but got %v%%", + version, nameStats.Name, valueStats.Value, expectedValueStats.Percent, valueStats.Percent) + } + foundValue = true + break + } + } + if !foundValue { + if !foundName { + t.Errorf("[%s] Counters missing for label '%s: %s'", version, expectedNameStats.Name, expectedValueStats.Value) + } + } + } + foundName = true + break + } + } + if !foundName { + t.Errorf("[%s] Counters missing for label '%s'", version, expectedNameStats.Name) + } + } if !reflect.DeepEqual(ur.Filters, filtersExpected) { t.Errorf("[%s] Filters mismatch, expected %v but got %v", version, filtersExpected, ur.Filters) } diff --git a/internal/models/api.go b/internal/models/api.go index 41999007d..9284d3349 100644 --- a/internal/models/api.go +++ b/internal/models/api.go @@ -35,6 +35,52 @@ type LabelColors struct { // LabelsColorMap is a map of "Label Key" -> "Label Value" -> karmaLabelColors type LabelsColorMap map[string]map[string]LabelColors +// LabelsCountMap is a map of "Label Key" -> "Label Value" -> number of occurence +type LabelsCountMap map[string]map[string]int + +type LabelValueStats struct { + Value string `json:"value"` + Hits int `json:"hits"` + Percent int `json:"percent"` +} + +type LabelValueStatsList []LabelValueStats + +func (lvsl LabelValueStatsList) Len() int { + return len(lvsl) +} +func (lvsl LabelValueStatsList) Swap(i, j int) { + lvsl[i], lvsl[j] = lvsl[j], lvsl[i] +} +func (lvsl LabelValueStatsList) Less(i, j int) bool { + if lvsl[i].Hits == lvsl[j].Hits { + return lvsl[i].Value > lvsl[j].Value + } + return lvsl[i].Hits > lvsl[j].Hits +} + +// LabelStats is used in the overview modal, it shows top labels across alerts +type LabelNameStats struct { + Name string `json:"name"` + Values LabelValueStatsList `json:"values"` + Hits int `json:"hits"` +} + +type LabelNameStatsList []LabelNameStats + +func (lnsl LabelNameStatsList) Len() int { + return len(lnsl) +} +func (lnsl LabelNameStatsList) Swap(i, j int) { + lnsl[i], lnsl[j] = lnsl[j], lnsl[i] +} +func (lnsl LabelNameStatsList) Less(i, j int) bool { + if lnsl[i].Hits == lnsl[j].Hits { + return lnsl[i].Name > lnsl[j].Name + } + return lnsl[i].Hits > lnsl[j].Hits +} + // APIAlertGroupSharedMaps defines shared part of APIAlertGroup type APIAlertGroupSharedMaps struct { Annotations Annotations `json:"annotations"` @@ -248,6 +294,7 @@ type AlertsResponse struct { TotalAlerts int `json:"totalAlerts"` Colors LabelsColorMap `json:"colors"` Filters []Filter `json:"filters"` + Counters LabelNameStatsList `json:"counters"` Settings Settings `json:"settings"` } diff --git a/internal/models/api_test.go b/internal/models/api_test.go index 8e1797798..557ab9c9a 100644 --- a/internal/models/api_test.go +++ b/internal/models/api_test.go @@ -2,6 +2,7 @@ package models_test import ( "encoding/json" + "sort" "testing" "github.com/pmezard/go-difflib/difflib" @@ -255,3 +256,180 @@ func TestDedupSharedMapsWithSingleAlert(t *testing.T) { } ag.DedupSharedMaps() } + +func TestNameStatsSort(t *testing.T) { + var nameStats = models.LabelNameStatsList{ + { + Name: "@state", + Hits: 24, + Values: models.LabelValueStatsList{ + models.LabelValueStats{ + Value: "suppressed", + Hits: 8, + Percent: 33, + }, + models.LabelValueStats{ + Value: "active", + Hits: 16, + Percent: 67, + }, + }, + }, + { + Name: "cluster", + Hits: 24, + Values: models.LabelValueStatsList{ + models.LabelValueStats{ + Value: "dev", + Hits: 10, + Percent: 42, + }, + models.LabelValueStats{ + Value: "prod", + Hits: 6, + Percent: 25, + }, + models.LabelValueStats{ + Value: "staging", + Hits: 8, + Percent: 33, + }, + }, + }, + { + Name: "alertname", + Hits: 24, + Values: models.LabelValueStatsList{ + models.LabelValueStats{ + Value: "HTTP_Probe_Failed", + Hits: 4, + Percent: 17, + }, + models.LabelValueStats{ + Value: "Host_Down", + Hits: 16, + Percent: 67, + }, + models.LabelValueStats{ + Value: "Free_Disk_Space_Too_Low", + Hits: 2, + Percent: 8, + }, + models.LabelValueStats{ + Value: "Memory_Usage_Too_High", + Hits: 2, + Percent: 8, + }, + }, + }, + { + Name: "instance", + Hits: 24, + Values: models.LabelValueStatsList{ + models.LabelValueStats{ + Value: "server4", + Hits: 2, + Percent: 8, + }, + models.LabelValueStats{ + Value: "server5", + Hits: 4, + Percent: 17, + }, + models.LabelValueStats{ + Value: "server6", + Hits: 2, + Percent: 8, + }, + models.LabelValueStats{ + Value: "server1", + Hits: 2, + Percent: 8, + }, + models.LabelValueStats{ + Value: "server2", + Hits: 4, + Percent: 17, + }, + models.LabelValueStats{ + Value: "server3", + Hits: 2, + Percent: 8, + }, + models.LabelValueStats{ + Value: "server7", + Hits: 2, + Percent: 8, + }, + models.LabelValueStats{ + Value: "server8", + Hits: 2, + Percent: 8, + }, + models.LabelValueStats{ + Value: "web1", + Hits: 2, + Percent: 8, + }, + models.LabelValueStats{ + Value: "web2", + Hits: 2, + Percent: 8, + }, + }, + }, + { + Name: "@receiver", + Hits: 24, + Values: models.LabelValueStatsList{ + models.LabelValueStats{ + Value: "by-name", + Hits: 12, + Percent: 50, + }, + models.LabelValueStats{ + Value: "by-cluster-service", + Hits: 12, + Percent: 50, + }, + }, + }, + { + Name: "job", + Hits: 16, + Values: models.LabelValueStatsList{ + models.LabelValueStats{ + Value: "node_exporter", + Hits: 8, + Percent: 50, + }, + models.LabelValueStats{ + Value: "node_ping", + Hits: 8, + Percent: 50, + }, + }, + }, + } + + b, err := json.Marshal(nameStats) + if err != nil { + t.Error(err) + } + before := string(b) + + for _, n := range nameStats { + sort.Sort(n.Values) + } + sort.Sort(nameStats) + + a, err := json.Marshal(nameStats) + if err != nil { + t.Error(err) + } + after := string(a) + + if after == before { + t.Errorf("Sorting LabelNameStatsList produces the same output as unsorted instance") + } +} diff --git a/ui/src/Stores/AlertStore.js b/ui/src/Stores/AlertStore.js index 25059aa8c..99a5d38ba 100644 --- a/ui/src/Stores/AlertStore.js +++ b/ui/src/Stores/AlertStore.js @@ -137,6 +137,7 @@ class AlertStore { data = observable( { colors: {}, + counters: {}, groups: {}, silences: {}, upstreams: { instances: [], clusters: {} }, @@ -302,7 +303,7 @@ class AlertStore { let updates = {}; // update data dicts if they changed - for (const key of ["colors", "silences", "upstreams"]) { + for (const key of ["colors", "counters", "silences", "upstreams"]) { if (!equal(this.data[key], result[key])) { updates[key] = result[key]; } diff --git a/ui/src/__mocks__/Fetch.js b/ui/src/__mocks__/Fetch.js index f074457a9..9763a8c0b 100644 --- a/ui/src/__mocks__/Fetch.js +++ b/ui/src/__mocks__/Fetch.js @@ -22,6 +22,7 @@ const EmptyAPIResponse = () => ({ isValid: true } ], + counters: {}, settings: { sorting: { grid: { diff --git a/views.go b/views.go index 47c69625c..22ab2ba4e 100644 --- a/views.go +++ b/views.go @@ -122,6 +122,7 @@ func alerts(c *gin.Context) { // set pointers for data store objects, need a lock until end of view is reached alerts := map[string]models.APIAlertGroup{} colors := models.LabelsColorMap{} + counters := map[string]map[string]int{} dedupedAlerts := alertmanager.DedupAlerts() dedupedColors := alertmanager.DedupColors() @@ -170,6 +171,9 @@ func alerts(c *gin.Context) { alert.UpdateFingerprints() agCopy.Alerts = append(agCopy.Alerts, alert) + countLabel(counters, "@state", alert.State) + + countLabel(counters, "@receiver", alert.Receiver) if ck, foundKey := dedupedColors["@receiver"]; foundKey { if cv, foundVal := ck[alert.Receiver]; foundVal { if _, found := colors["@receiver"]; !found { @@ -209,6 +213,7 @@ func alerts(c *gin.Context) { colors[key][value] = color } } + countLabel(counters, key, value) } } } @@ -248,6 +253,7 @@ func alerts(c *gin.Context) { resp.AlertGroups = alerts resp.Silences = silences resp.Colors = colors + resp.Counters = countersToLabelStats(counters) resp.Filters = populateAPIFilters(matchFilters) data, err := json.Marshal(resp) diff --git a/views_test.go b/views_test.go index f51bbf7b2..26a115fc5 100644 --- a/views_test.go +++ b/views_test.go @@ -136,6 +136,9 @@ func TestAlerts(t *testing.T) { if ur.Status != "success" { t.Errorf("[%s] Invalid status in response: %s", version, ur.Status) } + if len(ur.Counters) != 6 { + t.Errorf("[%s] Invalid number of counters in response (%d): %v", version, len(ur.Counters), ur.Counters) + } for _, ag := range ur.AlertGroups { for _, a := range ag.Alerts { linkCount := 0