Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions observability-lib/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/grafana/grafana-foundation-sdk/go v0.0.18
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
golang.org/x/sync v0.22.0
gopkg.in/yaml.v3 v3.0.1
)

Expand Down
2 changes: 2 additions & 0 deletions observability-lib/go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

91 changes: 76 additions & 15 deletions observability-lib/grafana/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ func (o *Observability) GenerateJSON() ([]byte, error) {
return output, nil
}

// defaultConcurrency is the default bound on in-flight HTTP calls for
// alert-rule writes when DeployOptions.Concurrency is unset.
const defaultConcurrency = 8

type DeployOptions struct {
GrafanaURL string
GrafanaToken string
Expand All @@ -45,21 +49,51 @@ type DeployOptions struct {
EnableAlerts bool
RuleGroupFromDashboard bool // if true, set the alert rule group to the dashboard title on all alerts
NotificationTemplates string
// Concurrency bounds in-flight HTTP calls for alert-rule writes (each rule
// is addressed by UID, so rules deploy independently). 0 uses
// defaultConcurrency; 1 restores the previous serial behavior.
Concurrency int
// Cache, when set, memoizes folder resolution and the full alert-rule
// fetch across the DeployToGrafana calls sharing it, so composite deploys
// pay those lookups once instead of once per dashboard. See DeployCache
// for scoping rules.
Cache *DeployCache
}

func (o *DeployOptions) concurrency() int {
if o.Concurrency <= 0 {
return defaultConcurrency
}
return o.Concurrency
}

func resolveDeployFolder(client *api.Client, options *DeployOptions) (*api.Folder, error) {
func resolveDeployFolder(client *api.Client, cache deployCache, options *DeployOptions) (*api.Folder, error) {
if options.FolderUID != "" {
key := "uid_" + options.FolderUID
if folder, ok := cache.folder(key); ok {
return folder, nil
}
folder, err := client.GetFolderByUID(options.FolderUID)
if err != nil {
return nil, err
}
if folder == nil {
return nil, fmt.Errorf("folder with UID %q not found", options.FolderUID)
}
cache.setFolder(key, folder)
return folder, nil
}
if options.FolderName != "" {
return client.FindOrCreateFolder(options.FolderName)
key := "name_" + options.FolderName
if folder, ok := cache.folder(key); ok {
return folder, nil
}
folder, err := client.FindOrCreateFolder(options.FolderName)
if err != nil {
return nil, err
}
cache.setFolder(key, folder)
return folder, nil
}
return nil, nil
}
Expand All @@ -82,26 +116,42 @@ func getAlertRuleByTitle(alerts []alerting.Rule, title string) *alerting.Rule {
return nil
}

func getAlertRules(grafanaClient *api.Client, dashboardUID *string, folderUID string, alertGroups []alerting.RuleGroup) ([]alerting.Rule, error) {
func getAlertRules(grafanaClient *api.Client, cache deployCache, dashboardUID *string, folderUID string, alertGroups []alerting.RuleGroup) ([]alerting.Rule, error) {
// Fetch the full rule list exactly once to amortize the cost of
// fetching alert rules.
allRules, cached := cache.alertRules()
if !cached {
var errGetAlertRules error
allRules, _, errGetAlertRules = grafanaClient.GetAlertRules()
if errGetAlertRules != nil {
return nil, errGetAlertRules
}
cache.setAlertRules(allRules)
}

var alertsRule []alerting.Rule
var errGetAlertRules error

// check for alert rules by dashboard UID
if dashboardUID != nil {
alertsRule, errGetAlertRules = grafanaClient.GetAlertRulesByDashboardUID(*dashboardUID)
if errGetAlertRules != nil {
return nil, errGetAlertRules
for _, rule := range allRules {
if rule.Annotations["__dashboardUid__"] == *dashboardUID {
alertsRule = append(alertsRule, rule)
}
Comment thread
cedric-cordenier marked this conversation as resolved.
}
}

// check for alert rules by folder UID and group name
if len(alertGroups) > 0 {
groupNames := make(map[string]bool, len(alertGroups))
for _, alertGroup := range alertGroups {
alertsRulePerGroup, errGetAlertRulesPerGroup := grafanaClient.GetAlertRulesByFolderUIDAndGroupName(folderUID, *alertGroup.Title)
if errGetAlertRulesPerGroup != nil {
return nil, errGetAlertRulesPerGroup
if alertGroup.Title != nil {
groupNames[*alertGroup.Title] = true
}
}
for _, rule := range allRules {
if rule.FolderUID != "" && rule.FolderUID == folderUID && groupNames[rule.RuleGroup] {
alertsRule = append(alertsRule, rule)
}
alertsRule = append(alertsRule, alertsRulePerGroup...)
}
}

Expand All @@ -114,8 +164,15 @@ func (o *Observability) DeployToGrafana(options *DeployOptions) error {
options.GrafanaToken,
)

// Substitute a no-op cache when the caller didn't configure one, so the
// rest of the deploy path never handles a nil cache.
cache := deployCache(noopDeployCache{})
if options.Cache != nil {
cache = options.Cache
}

// Create or update folder
folder, errFolder := resolveDeployFolder(grafanaClient, options)
folder, errFolder := resolveDeployFolder(grafanaClient, cache, options)
if errFolder != nil {
return errFolder
}
Expand Down Expand Up @@ -152,7 +209,7 @@ func (o *Observability) DeployToGrafana(options *DeployOptions) error {

// If disabling alerts delete alerts for the folder and alert groups scope
if folder != nil && !options.EnableAlerts && o.Alerts != nil && len(o.Alerts) > 0 {
alertsRule, errGetAlertRules := getAlertRules(grafanaClient, newDashboard.UID, folder.UID, o.AlertGroups)
alertsRule, errGetAlertRules := getAlertRules(grafanaClient, cache, newDashboard.UID, folder.UID, o.AlertGroups)
if errGetAlertRules != nil {
return errGetAlertRules
}
Expand All @@ -167,7 +224,7 @@ func (o *Observability) DeployToGrafana(options *DeployOptions) error {

// Create or update alerts
if folder != nil && options.EnableAlerts && o.Alerts != nil && len(o.Alerts) > 0 {
alertsRule, errGetAlertRules := getAlertRules(grafanaClient, newDashboard.UID, folder.UID, o.AlertGroups)
alertsRule, errGetAlertRules := getAlertRules(grafanaClient, cache, newDashboard.UID, folder.UID, o.AlertGroups)
if errGetAlertRules != nil {
return errGetAlertRules
}
Expand All @@ -183,7 +240,7 @@ func (o *Observability) DeployToGrafana(options *DeployOptions) error {
}

// Create alert rules
for _, alert := range o.Alerts {
errUpsertAlerts := parallelFor(o.Alerts, options.concurrency(), func(alert alerting.Rule) error {
if folder.UID != "" {
alert.FolderUID = folder.UID
}
Expand Down Expand Up @@ -235,6 +292,10 @@ func (o *Observability) DeployToGrafana(options *DeployOptions) error {
return errPostAlertRule
}
}
return nil
})
if errUpsertAlerts != nil {
return errUpsertAlerts
}
}

Expand Down
187 changes: 187 additions & 0 deletions observability-lib/grafana/dashboard_deploy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package grafana_test

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"

"github.com/grafana/grafana-foundation-sdk/go/alerting"
"github.com/grafana/grafana-foundation-sdk/go/dashboard"
"github.com/stretchr/testify/require"

"github.com/smartcontractkit/chainlink-common/observability-lib/grafana"
)

// fakeGrafana stubs the Grafana endpoints used by DeployToGrafana and records
// how the alert-rule endpoints are called.
type fakeGrafana struct {
mu sync.Mutex

folderGets int
alertRuleGets int
alertRulePosts int
inFlightPosts int
maxInFlight int
}

func (f *fakeGrafana) handler(t *testing.T) http.Handler {
mux := http.NewServeMux()

mux.HandleFunc("GET /api/folders", func(w http.ResponseWriter, _ *http.Request) {
f.mu.Lock()
f.folderGets++
f.mu.Unlock()
writeJSON(t, w, []map[string]any{{"id": 1, "uid": "folder-uid", "title": "Folder"}})
})
mux.HandleFunc("GET /api/search", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(t, w, []map[string]any{})
})
mux.HandleFunc("POST /api/dashboards/db", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(t, w, map[string]any{"uid": "dash-uid", "url": "/d/dash-uid"})
})
mux.HandleFunc("GET /api/v1/provisioning/alert-rules", func(w http.ResponseWriter, _ *http.Request) {
f.mu.Lock()
f.alertRuleGets++
f.mu.Unlock()
writeJSON(t, w, []map[string]any{})
})
mux.HandleFunc("POST /api/v1/provisioning/alert-rules", func(w http.ResponseWriter, _ *http.Request) {
f.mu.Lock()
f.alertRulePosts++
f.inFlightPosts++
if f.inFlightPosts > f.maxInFlight {
f.maxInFlight = f.inFlightPosts
}
f.mu.Unlock()

// Hold the request so overlapping POSTs are observable.
time.Sleep(50 * time.Millisecond)

f.mu.Lock()
f.inFlightPosts--
f.mu.Unlock()

w.WriteHeader(http.StatusCreated)
writeJSON(t, w, map[string]any{})
})
mux.HandleFunc("PUT /api/v1/provisioning/folder/{folderUID}/rule-groups/{group}", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(t, w, map[string]any{})
})

return mux
}

func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(v))
}

func TestDeployToGrafanaFetchesRulesOnceAndWritesConcurrently(t *testing.T) {
fake := &fakeGrafana{}
server := httptest.NewServer(fake.handler(t))
t.Cleanup(server.Close)

const numAlerts = 20

title := "Test Dashboard"
o := &grafana.Observability{
Dashboard: &dashboard.Dashboard{ //nolint:staticcheck
Title: &title,
},
Alerts: make([]alerting.Rule, numAlerts),
AlertGroups: nil,
}
for i := range o.Alerts {
o.Alerts[i] = alerting.Rule{
Title: fmt.Sprintf("alert-%d", i),
RuleGroup: "group",
Condition: "A",
Data: []alerting.Query{},
}
}

// Alert groups exercise the folder+group lookup in getAlertRules; with the
// dashboard UID lookup it must still fetch the full rule list only once.
group, err := grafana.NewAlertGroup(&grafana.AlertGroupOptions{Title: "group", Interval: 60}).Build()
require.NoError(t, err)
o.AlertGroups = []alerting.RuleGroup{group}

err = o.DeployToGrafana(&grafana.DeployOptions{
GrafanaURL: server.URL,
GrafanaToken: "test-token",
FolderName: "Folder",
EnableAlerts: true,
RuleGroupFromDashboard: true,
})
require.NoError(t, err)

require.Equal(t, 1, fake.alertRuleGets, "full alert rule list must be fetched exactly once per deploy")
require.Equal(t, numAlerts, fake.alertRulePosts)
require.Greater(t, fake.maxInFlight, 1, "alert rule writes should overlap")
require.LessOrEqual(t, fake.maxInFlight, 8, "alert rule writes must respect the default concurrency bound")
}

// deployOne deploys a single-dashboard observability with the given options,
// failing the test on any error.
func deployOne(t *testing.T, title string, opts *grafana.DeployOptions) {
t.Helper()
o := &grafana.Observability{
Dashboard: &dashboard.Dashboard{Title: &title}, //nolint:staticcheck
Alerts: []alerting.Rule{{
Title: "alert-" + title,
RuleGroup: "group",
Condition: "A",
Data: []alerting.Query{},
}},
}
group, err := grafana.NewAlertGroup(&grafana.AlertGroupOptions{Title: "group", Interval: 60}).Build()
require.NoError(t, err)
o.AlertGroups = []alerting.RuleGroup{group}

require.NoError(t, o.DeployToGrafana(opts))
}

func TestDeployToGrafanaSharesCacheAcrossDeploys(t *testing.T) {
fake := &fakeGrafana{}
server := httptest.NewServer(fake.handler(t))
t.Cleanup(server.Close)

cache := &grafana.DeployCache{}
for _, title := range []string{"Dashboard A", "Dashboard B"} {
deployOne(t, title, &grafana.DeployOptions{
GrafanaURL: server.URL,
GrafanaToken: "test-token",
FolderName: "Folder",
EnableAlerts: true,
RuleGroupFromDashboard: true,
Cache: cache,
})
}

require.Equal(t, 1, fake.folderGets, "shared cache must resolve the folder once across deploys")
require.Equal(t, 1, fake.alertRuleGets, "shared cache must fetch the full alert rule list once across deploys")
}

func TestDeployToGrafanaWithoutCacheFetchesPerDeploy(t *testing.T) {
fake := &fakeGrafana{}
server := httptest.NewServer(fake.handler(t))
t.Cleanup(server.Close)

for _, title := range []string{"Dashboard A", "Dashboard B"} {
deployOne(t, title, &grafana.DeployOptions{
GrafanaURL: server.URL,
GrafanaToken: "test-token",
FolderName: "Folder",
EnableAlerts: true,
RuleGroupFromDashboard: true,
})
}

require.Equal(t, 2, fake.folderGets, "without a cache each deploy resolves the folder")
require.Equal(t, 2, fake.alertRuleGets, "without a cache each deploy fetches the full alert rule list")
}
Loading
Loading