Alerting: Notifiication history (#107644)

* Add unified_alerting.notification_history to ini files

* Parse notification history settings

* Move Loki client to a separate package

* Loki client: add params for metrics and traces

* add NotificationHistorian

* rm writeDuration

* remove RangeQuery stuff

* wip

* wip

* wip

* wip

* pass notification historian in tests

* unify loki settings

* unify loki settings

* add test

* update grafana/alerting

* make update-workspace

* add feature toggle

* fix configureNotificationHistorian

* Revert "add feature toggle"

This reverts commit de7af8f7

* add feature toggle

* more tests

* RuleUID

* fix metrics test

* met.Info.Set(0)
This commit is contained in:
Vadim Stepanov
2025-07-17 14:26:26 +01:00
committed by GitHub
parent 810868c156
commit bccc980b90
32 changed files with 851 additions and 227 deletions
@@ -8,6 +8,7 @@ import (
"sort"
"time"
"github.com/grafana/grafana/pkg/services/ngalert/lokiclient"
"golang.org/x/exp/constraints"
"github.com/grafana/grafana/pkg/components/simplejson"
@@ -44,7 +45,7 @@ type RuleStore interface {
}
type lokiQueryClient interface {
RangeQuery(ctx context.Context, query string, start, end, limit int64) (historian.QueryRes, error)
RangeQuery(ctx context.Context, query string, start, end, limit int64) (lokiclient.QueryRes, error)
MaxQuerySize() int
}
@@ -60,14 +61,15 @@ func NewLokiHistorianStore(cfg setting.UnifiedAlertingStateHistorySettings, db d
if !useStore(cfg) {
return nil
}
lokiCfg, err := historian.NewLokiConfig(cfg)
lokiCfg, err := lokiclient.NewLokiConfig(cfg.LokiSettings)
if err != nil {
// this config error is already handled elsewhere
return nil
}
metrics := ngmetrics.NewHistorianMetrics(reg, subsystem)
return &LokiHistorianStore{
client: historian.NewLokiClient(lokiCfg, historian.NewRequester(), ngmetrics.NewHistorianMetrics(reg, subsystem), log, tracer),
client: lokiclient.NewLokiClient(lokiCfg, lokiclient.NewRequester(), metrics.BytesWritten, metrics.WriteDuration, log, tracer, historian.LokiClientSpanName),
db: db,
log: log,
ruleStore: ruleStore,
@@ -142,7 +144,7 @@ func (r *LokiHistorianStore) Get(ctx context.Context, query annotations.ItemQuer
return items, err
}
func (r *LokiHistorianStore) annotationsFromStream(stream historian.Stream, ac accesscontrol.AccessResources) []*annotations.ItemDTO {
func (r *LokiHistorianStore) annotationsFromStream(stream lokiclient.Stream, ac accesscontrol.AccessResources) []*annotations.ItemDTO {
items := make([]*annotations.ItemDTO, 0, len(stream.Values))
for _, sample := range stream.Values {
entry := historian.LokiEntry{}
@@ -10,6 +10,7 @@ import (
"testing"
"time"
"github.com/grafana/grafana/pkg/services/ngalert/lokiclient"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
"golang.org/x/exp/maps"
@@ -84,7 +85,7 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) {
t.Run("can query history by alert id", func(t *testing.T) {
rule := dashboardRules[dashboard1.UID][0]
fakeLokiClient.rangeQueryRes = []historian.Stream{
fakeLokiClient.rangeQueryRes = []lokiclient.Stream{
historian.StatesToStream(ruleMetaFromRule(t, rule), transitions, map[string]string{}, log.NewNopLogger()),
}
@@ -111,7 +112,7 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) {
t.Run("can query history by alert uid", func(t *testing.T) {
rule := dashboardRules[dashboard1.UID][0]
fakeLokiClient.rangeQueryRes = []historian.Stream{
fakeLokiClient.rangeQueryRes = []lokiclient.Stream{
historian.StatesToStream(ruleMetaFromRule(t, rule), transitions, map[string]string{}, log.NewNopLogger()),
}
@@ -186,7 +187,7 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) {
})
t.Run("can query history by dashboard id", func(t *testing.T) {
fakeLokiClient.rangeQueryRes = []historian.Stream{
fakeLokiClient.rangeQueryRes = []lokiclient.Stream{
historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][0]), transitions, map[string]string{}, log.NewNopLogger()),
historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][1]), transitions, map[string]string{}, log.NewNopLogger()),
}
@@ -212,7 +213,7 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) {
})
t.Run("should return empty results when type is annotation", func(t *testing.T) {
fakeLokiClient.rangeQueryRes = []historian.Stream{
fakeLokiClient.rangeQueryRes = []lokiclient.Stream{
historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][0]), transitions, map[string]string{}, log.NewNopLogger()),
historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][1]), transitions, map[string]string{}, log.NewNopLogger()),
}
@@ -236,7 +237,7 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) {
})
t.Run("should return empty results when history is outside time range", func(t *testing.T) {
fakeLokiClient.rangeQueryRes = []historian.Stream{
fakeLokiClient.rangeQueryRes = []lokiclient.Stream{
historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][0]), transitions, map[string]string{}, log.NewNopLogger()),
historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][1]), transitions, map[string]string{}, log.NewNopLogger()),
}
@@ -262,7 +263,7 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) {
})
t.Run("should return partial results when history is partly outside clamped time range", func(t *testing.T) {
fakeLokiClient.rangeQueryRes = []historian.Stream{
fakeLokiClient.rangeQueryRes = []lokiclient.Stream{
historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][0]), transitions, map[string]string{}, log.NewNopLogger()),
historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][1]), transitions, map[string]string{}, log.NewNopLogger()),
}
@@ -295,7 +296,7 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) {
})
t.Run("should sort history by time and be able to query by dashboard uid", func(t *testing.T) {
fakeLokiClient.rangeQueryRes = []historian.Stream{
fakeLokiClient.rangeQueryRes = []lokiclient.Stream{
historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][0]), transitions, map[string]string{}, log.NewNopLogger()),
historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][1]), transitions, map[string]string{}, log.NewNopLogger()),
}
@@ -329,7 +330,7 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) {
})
t.Run("should return nothing if query is for tags only", func(t *testing.T) {
fakeLokiClient.rangeQueryRes = []historian.Stream{
fakeLokiClient.rangeQueryRes = []lokiclient.Stream{
historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][0]), transitions, map[string]string{}, log.NewNopLogger()),
historian.StatesToStream(ruleMetaFromRule(t, dashboardRules[dashboard1.UID][1]), transitions, map[string]string{}, log.NewNopLogger()),
}
@@ -360,13 +361,13 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) {
store := createTestLokiStore(t, sql, fakeLokiClient)
t.Run("should return empty list when no streams", func(t *testing.T) {
items := store.annotationsFromStream(historian.Stream{}, annotation_ac.AccessResources{})
items := store.annotationsFromStream(lokiclient.Stream{}, annotation_ac.AccessResources{})
require.Empty(t, items)
})
t.Run("should return empty list when no entries", func(t *testing.T) {
items := store.annotationsFromStream(historian.Stream{
Values: []historian.Sample{},
items := store.annotationsFromStream(lokiclient.Stream{
Values: []lokiclient.Sample{},
}, annotation_ac.AccessResources{})
require.Empty(t, items)
})
@@ -419,7 +420,7 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) {
rule = createAlertRule(t, sql, "Test rule", gen)
stream2 := historian.StatesToStream(ruleMetaFromRule(t, rule), transitions, map[string]string{}, log.NewNopLogger())
stream := historian.Stream{
stream := lokiclient.Stream{
Values: append(stream1.Values, stream2.Values...),
Stream: stream1.Stream,
}
@@ -450,7 +451,7 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) {
rule.DashboardUID = nil
stream2 := historian.StatesToStream(ruleMetaFromRule(t, rule), transitions, map[string]string{}, log.NewNopLogger())
stream := historian.Stream{
stream := lokiclient.Stream{
Values: append(stream1.Values, stream2.Values...),
Stream: stream1.Stream,
}
@@ -811,23 +812,23 @@ func compareAnnotationItem(t *testing.T, expected, actual *annotations.ItemDTO)
type FakeLokiClient struct {
client client.Requester
cfg historian.LokiConfig
cfg lokiclient.LokiConfig
metrics *metrics.Historian
log log.Logger
rangeQueryRes []historian.Stream
rangeQueryRes []lokiclient.Stream
}
func NewFakeLokiClient() *FakeLokiClient {
url, _ := url.Parse("http://some.url")
req := historian.NewFakeRequester()
req := lokiclient.NewFakeRequester()
metrics := metrics.NewHistorianMetrics(prometheus.NewRegistry(), "annotations_test")
return &FakeLokiClient{
client: client.NewTimedClient(req, metrics.WriteDuration),
cfg: historian.LokiConfig{
cfg: lokiclient.LokiConfig{
WritePathURL: url,
ReadPathURL: url,
Encoder: historian.JsonEncoder{},
Encoder: lokiclient.JsonEncoder{},
MaxQueryLength: 721 * time.Hour,
MaxQuerySize: 65536,
},
@@ -836,15 +837,15 @@ func NewFakeLokiClient() *FakeLokiClient {
}
}
func (c *FakeLokiClient) RangeQuery(ctx context.Context, query string, from, to, limit int64) (historian.QueryRes, error) {
streams := make([]historian.Stream, len(c.rangeQueryRes))
func (c *FakeLokiClient) RangeQuery(ctx context.Context, query string, from, to, limit int64) (lokiclient.QueryRes, error) {
streams := make([]lokiclient.Stream, len(c.rangeQueryRes))
// clamp time range using logic from historian
from, to = historian.ClampRange(from, to, c.cfg.MaxQueryLength.Nanoseconds())
from, to = lokiclient.ClampRange(from, to, c.cfg.MaxQueryLength.Nanoseconds())
for n, stream := range c.rangeQueryRes {
streams[n].Stream = stream.Stream
streams[n].Values = []historian.Sample{}
streams[n].Values = []lokiclient.Sample{}
for _, sample := range stream.Values {
if sample.T.UnixNano() < from || sample.T.UnixNano() >= to { // matches Loki behavior
continue
@@ -853,14 +854,14 @@ func (c *FakeLokiClient) RangeQuery(ctx context.Context, query string, from, to,
}
}
res := historian.QueryRes{
Data: historian.QueryData{
res := lokiclient.QueryRes{
Data: lokiclient.QueryData{
Result: streams,
},
}
// reset expected streams on read
c.rangeQueryRes = []historian.Stream{}
c.rangeQueryRes = []lokiclient.Stream{}
return res, nil
}