Alerting: Foundations of historian app. (#114463)

We have two historians in alerting - alert state and notification. The intention
of this app is to provide query capabilities for both.

In this initial commit, the existing /history API is simply cloned to the new
app. It is identical except that it will send Kubernetes-style error responses
instead of Grafana-style.

This approach was taken to implement the new app more iteratively - ideally we
would define a new API, but this requires quite a significant overhaul of the
backend code.
This commit is contained in:
Steve Simpson
2025-11-28 11:51:56 +01:00
committed by GitHub
parent 725df38dad
commit eafc8ab1cd
31 changed files with 1782 additions and 4 deletions
@@ -0,0 +1,60 @@
package historian
import (
"context"
"encoding/json"
"net/http"
"github.com/grafana/grafana-app-sdk/app"
"github.com/grafana/grafana-plugin-sdk-go/data"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/ngalert/api"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
type Historian interface {
Query(ctx context.Context, query models.HistoryQuery) (*data.Frame, error)
}
type handlers struct {
historian Historian
}
func (h handlers) GetAlertStateHistoryHandler(ctx context.Context, writer app.CustomRouteResponseWriter, request *app.CustomRouteRequest) error {
user, err := identity.GetRequester(ctx)
if err != nil {
return &apierrors.StatusError{
ErrStatus: metav1.Status{
Status: metav1.StatusFailure,
Code: http.StatusUnauthorized,
Message: "authentication required",
}}
}
query, err := api.ParseHistoryQuery(user.GetOrgID(), user, request.URL.Query())
if err != nil {
return &apierrors.StatusError{
ErrStatus: metav1.Status{
Status: metav1.StatusFailure,
Code: http.StatusBadRequest,
Message: err.Error(),
}}
}
frame, err := h.historian.Query(ctx, query)
if err != nil {
return &apierrors.StatusError{
ErrStatus: metav1.Status{
Status: metav1.StatusFailure,
Code: http.StatusInternalServerError,
Message: err.Error(),
}}
}
writer.Header().Add("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
return json.NewEncoder(writer).Encode(frame)
}
@@ -0,0 +1,309 @@
package historian
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana-app-sdk/app"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
type mockHistorian struct {
queryFunc func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error)
}
func (m *mockHistorian) Query(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
if m.queryFunc != nil {
return m.queryFunc(ctx, query)
}
return nil, errors.New("not implemented")
}
type mockResponseWriter struct {
*httptest.ResponseRecorder
headers http.Header
}
func newMockResponseWriter() *mockResponseWriter {
return &mockResponseWriter{
ResponseRecorder: httptest.NewRecorder(),
headers: make(http.Header),
}
}
func (m *mockResponseWriter) Header() http.Header {
return m.headers
}
func TestGetAlertStateHistoryHandler(t *testing.T) {
t.Run("returns data frame when query succeeds", func(t *testing.T) {
now := time.Now()
testFrame := data.NewFrame("test",
data.NewField("Time", nil, []time.Time{now, now.Add(time.Second)}),
data.NewField("Line", nil, []string{"alert fired", "alert resolved"}),
)
mock := &mockHistorian{
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
assert.Equal(t, int64(123), query.OrgID)
assert.NotNil(t, query.SignedInUser)
return testFrame, nil
},
}
h := handlers{historian: mock}
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{
OrgID: 123,
})
writer := newMockResponseWriter()
req := &app.CustomRouteRequest{
URL: &url.URL{RawQuery: ""},
}
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, writer.Code)
assert.Equal(t, "application/json", writer.headers.Get("Content-Type"))
var result *data.Frame
err = json.Unmarshal(writer.Body.Bytes(), &result)
require.NoError(t, err)
assert.Equal(t, "test", result.Name)
assert.Equal(t, 2, result.Rows())
})
t.Run("passes query parameters to historian", func(t *testing.T) {
testFrame := data.NewFrame("test",
data.NewField("Time", nil, []time.Time{time.Now()}),
data.NewField("Line", nil, []string{"test"}),
)
var capturedQuery models.HistoryQuery
mock := &mockHistorian{
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
capturedQuery = query
return testFrame, nil
},
}
h := handlers{historian: mock}
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 99})
params := url.Values{}
params.Set("ruleUID", "rule-123")
params.Set("dashboardUID", "dash-456")
params.Set("panelID", "7")
params.Set("from", "1000")
params.Set("to", "2000")
params.Set("limit", "50")
writer := newMockResponseWriter()
req := &app.CustomRouteRequest{
URL: &url.URL{RawQuery: params.Encode()},
}
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
require.NoError(t, err)
assert.Equal(t, "rule-123", capturedQuery.RuleUID)
assert.Equal(t, "dash-456", capturedQuery.DashboardUID)
assert.Equal(t, int64(7), capturedQuery.PanelID)
assert.Equal(t, time.Unix(1000, 0), capturedQuery.From)
assert.Equal(t, time.Unix(2000, 0), capturedQuery.To)
assert.Equal(t, 50, capturedQuery.Limit)
})
t.Run("handles label matchers in query", func(t *testing.T) {
testFrame := data.NewFrame("test",
data.NewField("Time", nil, []time.Time{time.Now()}),
data.NewField("Line", nil, []string{"test"}),
)
var capturedQuery models.HistoryQuery
mock := &mockHistorian{
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
capturedQuery = query
return testFrame, nil
},
}
h := handlers{historian: mock}
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1})
params := url.Values{}
params.Add("labels", `env=prod`)
params.Add("labels", `region=us-west`)
writer := newMockResponseWriter()
req := &app.CustomRouteRequest{
URL: &url.URL{RawQuery: params.Encode()},
}
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
require.NoError(t, err)
if len(capturedQuery.Labels) > 0 {
assert.NotEmpty(t, capturedQuery.Labels)
}
})
t.Run("returns unauthorized when no user in context", func(t *testing.T) {
h := handlers{historian: &mockHistorian{}}
ctx := context.Background()
writer := newMockResponseWriter()
req := &app.CustomRouteRequest{
URL: &url.URL{RawQuery: ""},
}
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
require.Error(t, err)
assert.Contains(t, err.Error(), "authentication required")
})
t.Run("returns internal error when historian query fails", func(t *testing.T) {
mock := &mockHistorian{
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
return nil, errors.New("database connection failed")
},
}
h := handlers{historian: mock}
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1})
writer := newMockResponseWriter()
req := &app.CustomRouteRequest{
URL: &url.URL{RawQuery: ""},
}
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
require.Error(t, err)
assert.Contains(t, err.Error(), "database connection failed")
})
t.Run("returns empty frame when no results", func(t *testing.T) {
emptyFrame := data.NewFrame("empty")
mock := &mockHistorian{
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
return emptyFrame, nil
},
}
h := handlers{historian: mock}
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1})
writer := newMockResponseWriter()
req := &app.CustomRouteRequest{
URL: &url.URL{RawQuery: ""},
}
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, writer.Code)
var result *data.Frame
err = json.Unmarshal(writer.Body.Bytes(), &result)
require.NoError(t, err)
assert.Equal(t, 0, result.Rows())
})
t.Run("encodes complex data frame with multiple fields", func(t *testing.T) {
now := time.Now()
complexFrame := data.NewFrame("complex",
data.NewField("Time", nil, []time.Time{now}),
data.NewField("Line", nil, []string{"alert fired"}),
data.NewField("Value", nil, []float64{42.5}),
data.NewField("Labels", nil, []string{`{"env":"prod"}`}),
)
mock := &mockHistorian{
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
return complexFrame, nil
},
}
h := handlers{historian: mock}
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 1})
writer := newMockResponseWriter()
req := &app.CustomRouteRequest{
URL: &url.URL{RawQuery: ""},
}
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, writer.Code)
var result *data.Frame
err = json.Unmarshal(writer.Body.Bytes(), &result)
require.NoError(t, err)
assert.Equal(t, 4, len(result.Fields))
assert.Equal(t, 1, result.Rows())
})
}
func TestParseHistoryQueryIntegration(t *testing.T) {
t.Run("parses all supported query parameters", func(t *testing.T) {
testFrame := data.NewFrame("test",
data.NewField("Time", nil, []time.Time{time.Now()}),
data.NewField("Line", nil, []string{"test"}),
)
var capturedQuery models.HistoryQuery
mock := &mockHistorian{
queryFunc: func(ctx context.Context, query models.HistoryQuery) (*data.Frame, error) {
capturedQuery = query
return testFrame, nil
},
}
h := handlers{historian: mock}
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{OrgID: 5})
params := url.Values{}
params.Set("ruleUID", "test-rule")
params.Set("dashboardUID", "test-dash")
params.Set("panelID", "3")
params.Set("from", "1609459200")
params.Set("to", "1609545600")
params.Set("limit", "100")
params.Set("current", "alerting")
params.Set("previous", "normal")
writer := newMockResponseWriter()
req := &app.CustomRouteRequest{
URL: &url.URL{RawQuery: params.Encode()},
}
err := h.GetAlertStateHistoryHandler(ctx, writer, req)
require.NoError(t, err)
assert.Equal(t, int64(5), capturedQuery.OrgID)
assert.Equal(t, "test-rule", capturedQuery.RuleUID)
assert.Equal(t, "test-dash", capturedQuery.DashboardUID)
assert.Equal(t, int64(3), capturedQuery.PanelID)
assert.Equal(t, time.Unix(1609459200, 0), capturedQuery.From)
assert.Equal(t, time.Unix(1609545600, 0), capturedQuery.To)
assert.Equal(t, 100, capturedQuery.Limit)
assert.Equal(t, "alerting", capturedQuery.Current)
assert.Equal(t, "normal", capturedQuery.Previous)
})
}
@@ -0,0 +1,58 @@
package historian
import (
"github.com/grafana/grafana-app-sdk/app"
appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver"
"github.com/grafana/grafana-app-sdk/simple"
restclient "k8s.io/client-go/rest"
"github.com/grafana/grafana/apps/alerting/historian/pkg/apis"
historianApp "github.com/grafana/grafana/apps/alerting/historian/pkg/app"
historianAppConfig "github.com/grafana/grafana/apps/alerting/historian/pkg/app/config"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/ngalert"
"github.com/grafana/grafana/pkg/setting"
)
var (
_ appsdkapiserver.AppInstaller = (*AlertingHistorianAppInstaller)(nil)
)
type AlertingHistorianAppInstaller struct {
appsdkapiserver.AppInstaller
}
func RegisterAppInstaller(
cfg *setting.Cfg,
ng *ngalert.AlertNG,
) (*AlertingHistorianAppInstaller, error) {
if ng.IsDisabled() {
log.New("app-registry").Info("Skipping Kubernetes Alerting Historian apiserver (historian.alerting.grafana.app): Unified Alerting is disabled")
return nil, nil
}
installer := &AlertingHistorianAppInstaller{}
handlers := &handlers{
historian: ng.Api.Historian,
}
appSpecificConfig := historianAppConfig.RuntimeConfig{
GetAlertStateHistoryHandler: handlers.GetAlertStateHistoryHandler,
}
provider := simple.NewAppProvider(apis.LocalManifest(), appSpecificConfig, historianApp.New)
appConfig := app.Config{
KubeConfig: restclient.Config{},
ManifestData: *apis.LocalManifest().ManifestData,
SpecificConfig: appSpecificConfig,
}
i, err := appsdkapiserver.NewDefaultAppInstaller(provider, appConfig, &apis.GoTypeAssociator{})
if err != nil {
return nil, err
}
installer.AppInstaller = i
return installer, nil
}
+6
View File
@@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/registry/apps/advisor"
"github.com/grafana/grafana/pkg/registry/apps/alerting/historian"
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications"
"github.com/grafana/grafana/pkg/registry/apps/alerting/rules"
"github.com/grafana/grafana/pkg/registry/apps/annotation"
@@ -42,6 +43,7 @@ func ProvideAppInstallers(
annotationAppInstaller *annotation.AnnotationAppInstaller,
exampleAppInstaller *example.ExampleAppInstaller,
advisorAppInstaller *advisor.AdvisorAppInstaller,
alertingHistorianAppInstaller *historian.AlertingHistorianAppInstaller,
) []appsdkapiserver.AppInstaller {
installers := []appsdkapiserver.AppInstaller{
playlistAppInstaller,
@@ -75,6 +77,10 @@ func ProvideAppInstallers(
if features.IsEnabledGlobally(featuremgmt.FlagGrafanaAdvisor) {
installers = append(installers, advisorAppInstaller)
}
//nolint:staticcheck // not yet migrated to OpenFeature
if features.IsEnabledGlobally(featuremgmt.FlagKubernetesAlertingHistorian) && alertingHistorianAppInstaller != nil {
installers = append(installers, alertingHistorianAppInstaller)
}
return installers
}
+4 -1
View File
@@ -6,6 +6,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/registry/apps/advisor"
"github.com/grafana/grafana/pkg/registry/apps/alerting/historian"
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications"
"github.com/grafana/grafana/pkg/registry/apps/alerting/rules"
"github.com/grafana/grafana/pkg/registry/apps/annotation"
@@ -25,6 +26,8 @@ func TestProvideAppInstallers_Table(t *testing.T) {
annotationAppInstaller := &annotation.AnnotationAppInstaller{}
exampleAppInstaller := &example.ExampleAppInstaller{}
advisorAppInstaller := &advisor.AdvisorAppInstaller{}
historianAppInstaller := &historian.AlertingHistorianAppInstaller{}
tests := []struct {
name string
flags []any
@@ -40,7 +43,7 @@ func TestProvideAppInstallers_Table(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
features := featuremgmt.WithFeatures(tt.flags...)
got := ProvideAppInstallers(features, playlistInstaller, pluginsInstaller, nil, tt.rulesInst, correlationsAppInstaller, notificationsAppInstaller, nil, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller)
got := ProvideAppInstallers(features, playlistInstaller, pluginsInstaller, nil, tt.rulesInst, correlationsAppInstaller, notificationsAppInstaller, nil, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, historianAppInstaller)
if tt.expectRulesApp {
require.Contains(t, got, tt.rulesInst)
} else {
+2
View File
@@ -3,6 +3,7 @@ package appregistry
import (
"github.com/google/wire"
"github.com/grafana/grafana/pkg/registry/apps/alerting/historian"
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications"
"github.com/grafana/grafana/pkg/registry/apps/alerting/rules"
"github.com/grafana/grafana/pkg/registry/apps/annotation"
@@ -25,6 +26,7 @@ var WireSet = wire.NewSet(
correlations.RegisterAppInstaller,
rules.RegisterAppInstaller,
notifications.RegisterAppInstaller,
historian.RegisterAppInstaller,
logsdrilldown.RegisterAppInstaller,
annotation.RegisterAppInstaller,
example.RegisterAppInstaller,
+11 -2
View File
@@ -80,6 +80,7 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/userstorage"
"github.com/grafana/grafana/pkg/registry/apps"
advisor2 "github.com/grafana/grafana/pkg/registry/apps/advisor"
"github.com/grafana/grafana/pkg/registry/apps/alerting/historian"
notifications2 "github.com/grafana/grafana/pkg/registry/apps/alerting/notifications"
"github.com/grafana/grafana/pkg/registry/apps/alerting/rules"
"github.com/grafana/grafana/pkg/registry/apps/annotation"
@@ -825,7 +826,11 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
if err != nil {
return nil, err
}
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller)
alertingHistorianAppInstaller, err := historian.RegisterAppInstaller(cfg, alertNG)
if err != nil {
return nil, err
}
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, alertingHistorianAppInstaller)
builderMetrics := builder.ProvideBuilderMetrics(registerer)
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics)
if err != nil {
@@ -1475,7 +1480,11 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
if err != nil {
return nil, err
}
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller)
alertingHistorianAppInstaller, err := historian.RegisterAppInstaller(cfg, alertNG)
if err != nil {
return nil, err
}
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, appInstaller, shortURLAppInstaller, alertingRulesAppInstaller, correlationsAppInstaller, alertingNotificationsAppInstaller, logsDrilldownAppInstaller, annotationAppInstaller, exampleAppInstaller, advisorAppInstaller, alertingHistorianAppInstaller)
builderMetrics := builder.ProvideBuilderMetrics(registerer)
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics)
if err != nil {
+1 -1
View File
@@ -3633,4 +3633,4 @@
}
}
]
}
}