mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
238
pkg/services/ngalert/notifier/channels/slack.go
Normal file
238
pkg/services/ngalert/notifier/channels/slack.go
Normal 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()
|
||||
}
|
||||
176
pkg/services/ngalert/notifier/channels/slack_test.go
Normal file
176
pkg/services/ngalert/notifier/channels/slack_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
34
pkg/services/ngalert/notifier/channels/utils.go
Normal file
34
pkg/services/ngalert/notifier/channels/utils.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user