AlertingNG: Slack notification channel (#32675)

* AlertingNG: Slack notification channel

Signed-off-by: Ganesh Vernekar <ganeshvern@gmail.com>

* Add tests

Signed-off-by: Ganesh Vernekar <ganeshvern@gmail.com>

* Fix review comments

Signed-off-by: Ganesh Vernekar <ganeshvern@gmail.com>

* Fix review comments and small refactoring

Signed-off-by: Ganesh Vernekar <ganeshvern@gmail.com>
This commit is contained in:
Ganesh Vernekar
2021-04-15 16:01:41 +05:30
committed by GitHub
parent aeb64ecb16
commit 04a8d5407e
6 changed files with 464 additions and 20 deletions

View File

@@ -334,6 +334,8 @@ func (am *Alertmanager) buildReceiverIntegrations(receiver *apimodels.PostableAp
n, err = channels.NewEmailNotifier(cfg, externalURL)
case "pagerduty":
n, err = channels.NewPagerdutyNotifier(cfg, tmpl, externalURL)
case "slack":
n, err = channels.NewSlackNotifier(cfg, tmpl, externalURL)
}
if err != nil {
return nil, err

View File

@@ -2,22 +2,20 @@ package channels
import (
"context"
"fmt"
"net/url"
"strings"
gokit_log "github.com/go-kit/kit/log"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting"
old_notifiers "github.com/grafana/grafana/pkg/services/alerting/notifiers"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/bus"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
)
// EmailNotifier is responsible for sending
@@ -34,6 +32,10 @@ type EmailNotifier struct {
// NewEmailNotifier is the constructor function
// for the EmailNotifier.
func NewEmailNotifier(model *models.AlertNotification, externalUrl *url.URL) (*EmailNotifier, error) {
if model.Settings == nil {
return nil, alerting.ValidationError{Reason: "No Settings Supplied"}
}
addressesString := model.Settings.Get("addresses").MustString()
singleEmail := model.Settings.Get("singleEmail").MustBool(false)
autoResolve := model.Settings.Get("autoResolve").MustBool(true)
@@ -95,18 +97,6 @@ func (en *EmailNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool,
return true, nil
}
func getTitleFromTemplateData(data *template.Data) string {
title := "[" + data.Status
if data.Status == string(model.AlertFiring) {
title += fmt.Sprintf(":%d", len(data.Alerts.Firing()))
}
title += "] " + strings.Join(data.GroupLabels.SortedPairs().Values(), " ") + " "
if len(data.CommonLabels) > len(data.GroupLabels) {
title += "(" + strings.Join(data.CommonLabels.Remove(data.GroupLabels.Names()).Values(), " ") + ")"
}
return title
}
func (en *EmailNotifier) SendResolved() bool {
return en.AutoResolve
}

View File

@@ -48,6 +48,10 @@ type PagerdutyNotifier struct {
// NewPagerdutyNotifier is the constructor for the PagerDuty notifier
func NewPagerdutyNotifier(model *models.AlertNotification, t *template.Template, externalUrl *url.URL) (*PagerdutyNotifier, error) {
if model.Settings == nil {
return nil, alerting.ValidationError{Reason: "No Settings Supplied"}
}
key := model.DecryptedValue("integrationKey", model.Settings.Get("integrationKey").MustString())
if key == "" {
return nil, alerting.ValidationError{Reason: "Could not find integration key property in settings"}

View File

@@ -0,0 +1,238 @@
package channels
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"time"
gokit_log "github.com/go-kit/kit/log"
"github.com/pkg/errors"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting"
old_notifiers "github.com/grafana/grafana/pkg/services/alerting/notifiers"
"github.com/grafana/grafana/pkg/setting"
)
// SlackNotifier is responsible for sending
// alert notification to Slack.
type SlackNotifier struct {
old_notifiers.NotifierBase
log log.Logger
tmpl *template.Template
externalUrl *url.URL
URL string
Username string
IconEmoji string
IconURL string
Recipient string
Text string
Title string
Fallback string
MentionUsers []string
MentionGroups []string
MentionChannel string
Token string
}
var reRecipient *regexp.Regexp = regexp.MustCompile("^((@[a-z0-9][a-zA-Z0-9._-]*)|(#[^ .A-Z]{1,79})|([a-zA-Z0-9]+))$")
// NewSlackNotifier is the constructor for the Slack notifier
func NewSlackNotifier(model *models.AlertNotification, t *template.Template, externalUrl *url.URL) (*SlackNotifier, error) {
if model.Settings == nil {
return nil, alerting.ValidationError{Reason: "No Settings Supplied"}
}
slackURL := model.DecryptedValue("url", model.Settings.Get("url").MustString())
if slackURL == "" {
return nil, alerting.ValidationError{Reason: "Could not find url property in settings"}
}
recipient := strings.TrimSpace(model.Settings.Get("recipient").MustString())
if recipient != "" && !reRecipient.MatchString(recipient) {
return nil, alerting.ValidationError{Reason: fmt.Sprintf("Recipient on invalid format: %q", recipient)}
}
mentionChannel := model.Settings.Get("mentionChannel").MustString()
if mentionChannel != "" && mentionChannel != "here" && mentionChannel != "channel" {
return nil, alerting.ValidationError{
Reason: fmt.Sprintf("Invalid value for mentionChannel: %q", mentionChannel),
}
}
mentionUsersStr := model.Settings.Get("mentionUsers").MustString()
mentionUsers := []string{}
for _, u := range strings.Split(mentionUsersStr, ",") {
u = strings.TrimSpace(u)
if u != "" {
mentionUsers = append(mentionUsers, u)
}
}
mentionGroupsStr := model.Settings.Get("mentionGroups").MustString()
mentionGroups := []string{}
for _, g := range strings.Split(mentionGroupsStr, ",") {
g = strings.TrimSpace(g)
if g != "" {
mentionGroups = append(mentionGroups, g)
}
}
return &SlackNotifier{
NotifierBase: old_notifiers.NewNotifierBase(model),
URL: slackURL,
Recipient: recipient,
MentionUsers: mentionUsers,
MentionGroups: mentionGroups,
MentionChannel: mentionChannel,
Username: model.Settings.Get("username").MustString("Grafana"),
IconEmoji: model.Settings.Get("icon_emoji").MustString(),
IconURL: model.Settings.Get("icon_url").MustString(),
Token: model.DecryptedValue("token", model.Settings.Get("token").MustString()),
Text: model.Settings.Get("text").MustString(`{{ template "slack.default.text" . }}`),
Title: model.Settings.Get("title").MustString(`{{ template "slack.default.title" . }}`),
Fallback: model.Settings.Get("fallback").MustString(`{{ template "slack.default.title" . }}`),
log: log.New("alerting.notifier.slack"),
tmpl: t,
externalUrl: externalUrl,
}, nil
}
// slackMessage is the slackMessage for sending a slack notification.
type slackMessage struct {
Channel string `json:"channel,omitempty"`
Username string `json:"username,omitempty"`
IconEmoji string `json:"icon_emoji,omitempty"`
IconURL string `json:"icon_url,omitempty"`
Attachments []attachment `json:"attachments"`
Blocks []map[string]interface{} `json:"blocks"`
}
// attachment is used to display a richly-formatted message block.
type attachment struct {
Title string `json:"title,omitempty"`
TitleLink string `json:"title_link,omitempty"`
Text string `json:"text"`
Fallback string `json:"fallback"`
Fields []config.SlackField `json:"fields,omitempty"`
Footer string `json:"footer"`
FooterIcon string `json:"footer_icon"`
Color string `json:"color,omitempty"`
Ts int64 `json:"ts,omitempty"`
}
func (sn *SlackNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
msg, err := sn.buildSlackMessage(ctx, as)
if err != nil {
return false, errors.Wrap(err, "build slack message")
}
b, err := json.Marshal(msg)
if err != nil {
return false, errors.Wrap(err, "marshal json")
}
cmd := &models.SendWebhookSync{
Url: sn.URL,
Body: string(b),
HttpMethod: http.MethodPost,
}
if sn.Token != "" {
sn.log.Debug("Adding authorization header to HTTP request")
cmd.HttpHeader = map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", sn.Token),
}
}
if err := bus.DispatchCtx(ctx, cmd); err != nil {
sn.log.Error("Failed to send slack notification", "error", err, "webhook", sn.Name)
return false, err
}
return true, nil
}
func (sn *SlackNotifier) buildSlackMessage(ctx context.Context, as []*types.Alert) (*slackMessage, error) {
var tmplErr error
data := notify.GetTemplateData(ctx, &template.Template{ExternalURL: sn.externalUrl}, as, gokit_log.NewNopLogger())
alerts := types.Alerts(as...)
tmpl := notify.TmplText(sn.tmpl, data, &tmplErr)
req := &slackMessage{
Channel: tmpl(sn.Recipient),
Username: tmpl(sn.Username),
IconEmoji: tmpl(sn.IconEmoji),
IconURL: tmpl(sn.IconURL),
Attachments: []attachment{
{
Color: getAlertStatusColor(alerts.Status()),
Title: tmpl(sn.Title),
Fallback: tmpl(sn.Fallback),
Footer: "Grafana v" + setting.BuildVersion,
FooterIcon: FooterIconURL,
Ts: time.Now().Unix(),
TitleLink: "TODO: rule URL",
Text: tmpl(sn.Text),
Fields: nil, // TODO. Should be a config.
},
},
}
mentionsBuilder := strings.Builder{}
appendSpace := func() {
if mentionsBuilder.Len() > 0 {
mentionsBuilder.WriteString(" ")
}
}
mentionChannel := strings.TrimSpace(sn.MentionChannel)
if mentionChannel != "" {
mentionsBuilder.WriteString(fmt.Sprintf("<!%s|%s>", mentionChannel, mentionChannel))
}
if len(sn.MentionGroups) > 0 {
appendSpace()
for _, g := range sn.MentionGroups {
mentionsBuilder.WriteString(fmt.Sprintf("<!subteam^%s>", g))
}
}
if len(sn.MentionUsers) > 0 {
appendSpace()
for _, u := range sn.MentionUsers {
mentionsBuilder.WriteString(fmt.Sprintf("<@%s>", u))
}
}
if mentionsBuilder.Len() > 0 {
req.Blocks = []map[string]interface{}{
{
"type": "section",
"text": map[string]interface{}{
"type": "mrkdwn",
"text": mentionsBuilder.String(),
},
},
}
}
if tmplErr != nil {
tmplErr = errors.Wrap(tmplErr, "failed to template Slack message")
}
return req, tmplErr
}
func (sn *SlackNotifier) SendResolved() bool {
return !sn.GetDisableResolveMessage()
}

View File

@@ -0,0 +1,176 @@
package channels
import (
"context"
"encoding/json"
"errors"
"net/url"
"testing"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/alerting"
)
func TestSlackNotifier(t *testing.T) {
tmpl, err := template.FromGlobs("templates/default.tmpl")
require.NoError(t, err)
cases := []struct {
name string
settings string
alerts []*types.Alert
expMsg *slackMessage
expInitError error
expMsgError error
}{
{
name: "Correct config with one alert",
settings: `{
"url": "https://test.slack.com",
"recipient": "#testchannel",
"icon_emoji": ":emoji:"
}`,
alerts: []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
Annotations: model.LabelSet{"ann1": "annv1"},
},
},
},
expMsg: &slackMessage{
Channel: "#testchannel",
Username: "Grafana",
IconEmoji: ":emoji:",
Attachments: []attachment{
{
Title: "[FIRING:1] (val1)",
TitleLink: "TODO: rule URL",
Text: "",
Fallback: "[FIRING:1] (val1)",
Fields: nil,
Footer: "Grafana v",
FooterIcon: "https://grafana.com/assets/img/fav32.png",
Color: "#D63232",
Ts: 0,
},
},
},
expInitError: nil,
expMsgError: nil,
},
{
name: "Correct config with multiple alerts and template",
settings: `{
"url": "https://test.slack.com",
"recipient": "#testchannel",
"icon_emoji": ":emoji:",
"title": "{{ .Alerts.Firing | len }} firing, {{ .Alerts.Resolved | len }} resolved"
}`,
alerts: []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
Annotations: model.LabelSet{"ann1": "annv1"},
},
},
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"},
Annotations: model.LabelSet{"ann1": "annv2"},
},
},
},
expMsg: &slackMessage{
Channel: "#testchannel",
Username: "Grafana",
IconEmoji: ":emoji:",
Attachments: []attachment{
{
Title: "2 firing, 0 resolved",
TitleLink: "TODO: rule URL",
Text: "",
Fallback: "[FIRING:2] ",
Fields: nil,
Footer: "Grafana v",
FooterIcon: "https://grafana.com/assets/img/fav32.png",
Color: "#D63232",
Ts: 0,
},
},
},
expInitError: nil,
expMsgError: nil,
}, {
name: "Error in initing",
settings: `{}`,
expInitError: alerting.ValidationError{Reason: "Could not find url property in settings"},
}, {
name: "Error in building message",
settings: `{
"url": "https://test.slack.com",
"title": "{{ .BrokenTemplate }"
}`,
expMsgError: errors.New("build slack message: failed to template Slack message: template: :1: unexpected \"}\" in operand"),
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
settingsJSON, err := simplejson.NewJson([]byte(c.settings))
require.NoError(t, err)
m := &models.AlertNotification{
Name: "slack_testing",
Type: "slack",
Settings: settingsJSON,
}
externalURL, err := url.Parse("http://localhost")
require.NoError(t, err)
pn, err := NewSlackNotifier(m, tmpl, externalURL)
if c.expInitError != nil {
require.Error(t, err)
require.Equal(t, c.expInitError.Error(), err.Error())
return
}
require.NoError(t, err)
body := ""
bus.AddHandlerCtx("test", func(ctx context.Context, webhook *models.SendWebhookSync) error {
body = webhook.Body
return nil
})
ctx := notify.WithGroupKey(context.Background(), "alertname")
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""})
ok, err := pn.Notify(ctx, c.alerts...)
if c.expMsgError != nil {
require.False(t, ok)
require.Error(t, err)
require.Equal(t, c.expMsgError.Error(), err.Error())
return
}
require.True(t, ok)
require.NoError(t, err)
// Getting Ts from actual since that can't be predicted.
obj := &slackMessage{}
require.NoError(t, json.Unmarshal([]byte(body), obj))
c.expMsg.Attachments[0].Ts = obj.Attachments[0].Ts
expBody, err := json.Marshal(c.expMsg)
require.NoError(t, err)
require.Equal(t, string(expBody), body)
})
}
}

View File

@@ -0,0 +1,34 @@
package channels
import (
"fmt"
"strings"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/common/model"
)
const (
FooterIconURL = "https://grafana.com/assets/img/fav32.png"
ColorAlertFiring = "#D63232"
ColorAlertResolved = "#36a64f"
)
func getAlertStatusColor(status model.AlertStatus) string {
if status == model.AlertFiring {
return ColorAlertFiring
}
return ColorAlertResolved
}
func getTitleFromTemplateData(data *template.Data) string {
title := "[" + data.Status
if data.Status == string(model.AlertFiring) {
title += fmt.Sprintf(":%d", len(data.Alerts.Firing()))
}
title += "] " + strings.Join(data.GroupLabels.SortedPairs().Values(), " ") + " "
if len(data.CommonLabels) > len(data.GroupLabels) {
title += "(" + strings.Join(data.CommonLabels.Remove(data.GroupLabels.Names()).Values(), " ") + ")"
}
return title
}