Introduction

With the recent release of Grafana 8 a new opt-in feature for alerting has been made available. Grafana alerting has dramatically changed, and in my opinion, for the good. Why? Primarily due to the fact you are no longer limited to a dashboard. Alerts, rules, can be made directly in the alert manager tab.

With this change in Grafana I was hoping to find a GO project that would consist of a REST API wrapper for this new feature; however, I could not find anything. Therefore, I decided to quickly write a small program to print current alerts.

Code

To begin, I wrote a simple main.go to read a config.yaml that contains that Grafana host and apikey information. This feature utilizes viper package. I then create an HTTP client that interacts or rather calls the REST API endpoints of the Grafana server. In this case, to retrieve all alerts:

// main.go
package main

import (
	"context"
	"fmt"
	"time"

	"github.com/inancgumus/screen"
	log "github.com/sirupsen/logrus"
	"github.com/spf13/viper"

	"grafana-golang-sdk/pkg/grafana"
)

func main() {
	viper.SetConfigName("config")
	viper.SetConfigType("yaml")
	viper.AddConfigPath(".")
	err := viper.ReadInConfig()
	if err != nil {
		log.Fatal(err)
	}
	host := viper.GetString("host")
	apikey := viper.GetString("apikey")
	if host != "" && apikey != "" {
		c, err := grafana.NewClient(host, apikey, grafana.DefaultHTTPClient)
		if err != nil {
			log.Fatal(err)
		}
		alerts, err := c.GetAllAlerts(context.TODO())
		if err != nil {
			log.Fatal(err)
		} else {
			for t := range time.Tick(10 * time.Second) {
				screen.Clear()
				screen.MoveTopLeft()
				fmt.Println("")
				fmt.Println("")
				fmt.Println("")
				go alertSleep(t, alerts)
			}
		}
	}
}

func alertSleep(tick time.Time, alerts []grafana.Alert) {
	for _, v := range alerts {
		log.WithFields(log.Fields{
			"status":      v.Status.State,
			"severity":    v.Labels.Severity,
			"description": v.Annotations.Description,
			"tick":        tick.Format("15:04:05.000"),
		}).Info(v.Annotations.Summary)
	}
}

I am a big fan of the logrus package, and I commonly employ the package in all my projects. I use a time.Tick set at 10 seconds to call the alertSleep function that prints all current/firing alerts.

The heart of the project is found in the connection.go. The file should be self-explanatory; however, for brevity the most important portion is the type Alert struct, which is used to convert/unmarshal the JSON response from Grafana. A very useful website, JSON-to-Go, quickly converts JSON to a usable struct in GO.

// pkg/grafana/connection.go
package grafana

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/ioutil"
	"net/http"
	"net/url"
	"path"
	"strings"
	"time"
)

// DefaultHTTPClient for monkey patching
var DefaultHTTPClient = http.DefaultClient

// errors
var (
	errBasicAuth = "basic auth not allowed"
)

// Client uses Grafana REST API for interacting with Grafana server.
type Client struct {
	baseURL       string
	authorization string
	client        *http.Client
	// get specific function for get query type.
	get func(context.Context, *http.Client, string, string, string, url.Values) ([]byte, int, error)
}

// NewClient initializes Client for interacting with an instance of Grafana server.
func NewClient(host, token string, client *http.Client) (*Client, error) {
	baseURL, err := url.Parse(host)
	if err != nil {
		return nil, err
	}
	if !strings.Contains(token, ":") {
		return &Client{
			baseURL:       baseURL.String(),
			authorization: fmt.Sprintf("Bearer %s", token),
			client:        client,
			get: func(ctx context.Context, client *http.Client, baseURL, authorization, query string, params url.Values) ([]byte, int, error) {
				return request(ctx, client, baseURL, authorization, "GET", query, params, nil)
			},
		}, nil
	} else {
		return nil, errors.New(errBasicAuth)
	}
}

// Alert struct that represents the json response from the REST API endpoint.
type Alert struct {
	Annotations struct {
		ValueString string `json:"__value_string__"`
		Description string `json:"description"`
		Summary     string `json:"summary"`
	} `json:"annotations,omitempty"`
	EndsAt      time.Time `json:"endsAt"`
	Fingerprint string    `json:"fingerprint"`
	Receivers   []struct {
		Name string `json:"name"`
	} `json:"receivers"`
	StartsAt time.Time `json:"startsAt"`
	Status   struct {
		InhibitedBy []interface{} `json:"inhibitedBy"`
		SilencedBy  []interface{} `json:"silencedBy"`
		State       string        `json:"state"`
	} `json:"status"`
	UpdatedAt    time.Time `json:"updatedAt"`
	GeneratorURL string    `json:"generatorURL"`
	Labels       struct {
		AlertRuleUID             string `json:"__alert_rule_uid__"`
		Name                     string `json:"__name__"`
		AlertName                string `json:"alertname"`
		AppKubernetesIoInstance  string `json:"app_kubernetes_io_instance"`
		AppKubernetesIoManagedBy string `json:"app_kubernetes_io_managed_by"`
		AppKubernetesIoName      string `json:"app_kubernetes_io_name"`
		HelmShChart              string `json:"helm_sh_chart"`
		Instance                 string `json:"instance"`
		Job                      string `json:"job"`
		JobName                  string `json:"job_name"`
		KubernetesName           string `json:"kubernetes_name"`
		KubernetesNamespace      string `json:"kubernetes_namespace"`
		KubernetesNode           string `json:"kubernetes_node"`
		Namespace                string `json:"namespace"`
		Reason                   string `json:"reason"`
		Severity                 string `json:"severity"`
	} `json:"labels,omitempty"`
}

// GetAllAlerts gets all alerts.
func (c *Client) GetAllAlerts(ctx context.Context) ([]Alert, error) {
	var (
		raw  []byte
		an   []Alert
		code int
		err  error
	)
	if raw, code, err = c.get(ctx, c.client, c.baseURL, c.authorization, "api/alertmanager/grafana/api/v2/alerts", nil); err != nil {
		return nil, err
	}
	if code != 200 {
		return nil, fmt.Errorf("HTTP error %d: returns %s", code, raw)
	}
	err = json.Unmarshal(raw, &an)
	return an, err
}

type HttpClient interface {
	Do(req *http.Request) (*http.Response, error)
}

// request is a generic function for requests.
func request(ctx context.Context, client HttpClient, baseURL, authorization, method, query string, params url.Values, buf io.Reader) ([]byte, int, error) {
	u, _ := url.Parse(baseURL)
	u.Path = path.Join(u.Path, query)
	if params != nil {
		u.RawQuery = params.Encode()
	}
	req, err := http.NewRequest(method, u.String(), buf)
	if err != nil {
		return nil, 0, err
	}
	req = req.WithContext(ctx)
	req.Header.Set("Authorization", authorization)
	req.Header.Set("Accept", "application/json")
	req.Header.Set("Content-Type", "application/json")
	resp, err := client.Do(req)
	if err != nil {
		return nil, 0, err
	}
	data, err := ioutil.ReadAll(resp.Body)
	resp.Body.Close()
	return data, resp.StatusCode, err
}

Don’t forget unit testing:

// pkg/grafana/connection_test.go
package grafana

import (
	"context"
	"io"
	"net/http"
	"net/url"
	"reflect"
	"strings"
	"testing"
)

func TestNewClient(t *testing.T) {
	type args struct {
		host   string
		token  string
		client *http.Client
	}
	tests := []struct {
		name    string
		args    args
		want    *Client
		wantErr bool
	}{
		{
			name: "test",
			args: args{
				host:   "http://localhost",
				token:  "test",
				client: http.DefaultClient,
			},
			want: &Client{
				baseURL:       "http://localhost",
				authorization: "Bearer test",
				client:        http.DefaultClient,
			},
			wantErr: false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := NewClient(tt.args.host, tt.args.token, tt.args.client)
			if (err != nil) != tt.wantErr {
				t.Errorf("NewClient() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if !reflect.DeepEqual(got, tt.want) {
				t.Errorf("NewClient() = %v, want %v", got, tt.want)
			}
		})
	}
}

func TestClient_GetAllAlerts(t *testing.T) {
	type args struct {
		ctx context.Context
	}
	tests := []struct {
		name    string
		c       *Client
		args    args
		want    []Alert
		wantErr bool
	}{
		{
			name: "test",
			c: &Client{
				baseURL:       "http://localhost",
				authorization: "Bearer test",
				client:        nil,
				get: func(ctx context.Context, client *http.Client, s string, s2 string, s3 string, values url.Values) ([]byte, int, error) {
					return []byte(`[
  {
    "annotations": {
      "__value_string__": "test",
      "description": "test",
      "summary": "test"
    },
    "status": {
      "state": "active"
    },
    "labels": {
      "severity": "warning"
    }
  }
]`), 200, nil
				},
			},
			args: args{context.TODO()},
			want: []Alert{{
				Annotations: struct {
					ValueString string `json:"__value_string__"`
					Description string `json:"description"`
					Summary     string `json:"summary"`
				}{"test", "test", "test"},
				Fingerprint: "",
				Receivers:   nil,
				Status: struct {
					InhibitedBy []interface{} `json:"inhibitedBy"`
					SilencedBy  []interface{} `json:"silencedBy"`
					State       string        `json:"state"`
				}{nil, nil, "active"},
				GeneratorURL: "",
				Labels: struct {
					AlertRuleUID             string `json:"__alert_rule_uid__"`
					Name                     string `json:"__name__"`
					AlertName                string `json:"alertname"`
					AppKubernetesIoInstance  string `json:"app_kubernetes_io_instance"`
					AppKubernetesIoManagedBy string `json:"app_kubernetes_io_managed_by"`
					AppKubernetesIoName      string `json:"app_kubernetes_io_name"`
					HelmShChart              string `json:"helm_sh_chart"`
					Instance                 string `json:"instance"`
					Job                      string `json:"job"`
					JobName                  string `json:"job_name"`
					KubernetesName           string `json:"kubernetes_name"`
					KubernetesNamespace      string `json:"kubernetes_namespace"`
					KubernetesNode           string `json:"kubernetes_node"`
					Namespace                string `json:"namespace"`
					Reason                   string `json:"reason"`
					Severity                 string `json:"severity"`
				}{
					AlertRuleUID:             "",
					Name:                     "",
					AlertName:                "",
					AppKubernetesIoInstance:  "",
					AppKubernetesIoManagedBy: "",
					AppKubernetesIoName:      "",
					HelmShChart:              "",
					Instance:                 "",
					Job:                      "",
					JobName:                  "",
					KubernetesName:           "",
					KubernetesNamespace:      "",
					KubernetesNode:           "",
					Namespace:                "",
					Reason:                   "",
					Severity:                 "warning",
				},
			}},
			wantErr: false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := tt.c.GetAllAlerts(tt.args.ctx)
			if (err != nil) != tt.wantErr {
				t.Errorf("Client.GetAllAlerts() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if !reflect.DeepEqual(got, tt.want) {
				t.Errorf("Client.GetAllAlerts() = %v, want %v", got, tt.want)
			}
		})
	}
}

type ClientMock struct{}

func (c *ClientMock) Do(*http.Request) (*http.Response, error) {
	r := io.NopCloser(strings.NewReader("test"))
	return &http.Response{
		Status:           "OK",
		StatusCode:       200,
		Proto:            "",
		ProtoMajor:       0,
		ProtoMinor:       0,
		Header:           http.Header{},
		Body:             r,
		ContentLength:    0,
		TransferEncoding: nil,
		Close:            false,
		Uncompressed:     false,
		Trailer:          nil,
		Request:          nil,
		TLS:              nil,
	}, nil
}

func Test_request(t *testing.T) {
	type args struct {
		ctx           context.Context
		client        HttpClient
		baseURL       string
		authorization string
		method        string
		query         string
		params        url.Values
		buf           io.Reader
	}
	tests := []struct {
		name    string
		args    args
		want    []byte
		want1   int
		wantErr bool
	}{
		{
			name: "test",
			args: args{
				ctx:           context.TODO(),
				client:        &ClientMock{},
				baseURL:       "test",
				authorization: "test",
				method:        "GET",
				query:         "test",
				params:        nil,
				buf:           nil,
			},
			want:    []byte(`test`),
			want1:   200,
			wantErr: false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, got1, err := request(tt.args.ctx, tt.args.client, tt.args.baseURL, tt.args.authorization, tt.args.method, tt.args.query, tt.args.params, tt.args.buf)
			if (err != nil) != tt.wantErr {
				t.Errorf("request() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if !reflect.DeepEqual(got, tt.want) {
				t.Errorf("request() got = %v, want %v", got, tt.want)
			}
			if got1 != tt.want1 {
				t.Errorf("request() got1 = %v, want %v", got1, tt.want1)
			}
		})
	}
}

Coverage is about 35%. Therefore, lots of room for improvement; however, some basic testing was required.

Final Words

A super simple GO app to watch alerts on Grafana 8.1. If you have a suggestion, I suppose use the change me link above.