mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Attach image URLs or upload files to Discord notifications. (#49439)
* Images in discord * Drop duplicated field initialization * Fix tests * Use the proper context
This commit is contained in:
parent
33b2897552
commit
d7c65d3323
@ -1,14 +1,20 @@
|
|||||||
package channels
|
package channels
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/prometheus/alertmanager/template"
|
"github.com/prometheus/alertmanager/template"
|
||||||
"github.com/prometheus/alertmanager/types"
|
"github.com/prometheus/alertmanager/types"
|
||||||
|
"github.com/prometheus/common/model"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
@ -21,6 +27,7 @@ type DiscordNotifier struct {
|
|||||||
*Base
|
*Base
|
||||||
log log.Logger
|
log log.Logger
|
||||||
ns notifications.WebhookSender
|
ns notifications.WebhookSender
|
||||||
|
images ImageStore
|
||||||
tmpl *template.Template
|
tmpl *template.Template
|
||||||
Content string
|
Content string
|
||||||
AvatarURL string
|
AvatarURL string
|
||||||
@ -36,6 +43,16 @@ type DiscordConfig struct {
|
|||||||
UseDiscordUsername bool
|
UseDiscordUsername bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type discordAttachment struct {
|
||||||
|
url string
|
||||||
|
reader io.ReadCloser
|
||||||
|
name string
|
||||||
|
alertName string
|
||||||
|
state model.AlertStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
const DiscordMaxEmbeds = 10
|
||||||
|
|
||||||
func NewDiscordConfig(config *NotificationChannelConfig) (*DiscordConfig, error) {
|
func NewDiscordConfig(config *NotificationChannelConfig) (*DiscordConfig, error) {
|
||||||
discordURL := config.Settings.Get("url").MustString()
|
discordURL := config.Settings.Get("url").MustString()
|
||||||
if discordURL == "" {
|
if discordURL == "" {
|
||||||
@ -58,10 +75,10 @@ func DiscordFactory(fc FactoryConfig) (NotificationChannel, error) {
|
|||||||
Cfg: *fc.Config,
|
Cfg: *fc.Config,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return NewDiscordNotifier(cfg, fc.NotificationService, fc.Template), nil
|
return NewDiscordNotifier(cfg, fc.NotificationService, fc.ImageStore, fc.Template), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDiscordNotifier(config *DiscordConfig, ns notifications.WebhookSender, t *template.Template) *DiscordNotifier {
|
func NewDiscordNotifier(config *DiscordConfig, ns notifications.WebhookSender, images ImageStore, t *template.Template) *DiscordNotifier {
|
||||||
return &DiscordNotifier{
|
return &DiscordNotifier{
|
||||||
Base: NewBase(&models.AlertNotification{
|
Base: NewBase(&models.AlertNotification{
|
||||||
Uid: config.UID,
|
Uid: config.UID,
|
||||||
@ -76,6 +93,7 @@ func NewDiscordNotifier(config *DiscordConfig, ns notifications.WebhookSender, t
|
|||||||
WebhookURL: config.WebhookURL,
|
WebhookURL: config.WebhookURL,
|
||||||
log: log.New("alerting.notifier.discord"),
|
log: log.New("alerting.notifier.discord"),
|
||||||
ns: ns,
|
ns: ns,
|
||||||
|
images: images,
|
||||||
tmpl: t,
|
tmpl: t,
|
||||||
UseDiscordUsername: config.UseDiscordUsername,
|
UseDiscordUsername: config.UseDiscordUsername,
|
||||||
}
|
}
|
||||||
@ -116,18 +134,33 @@ func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool,
|
|||||||
"icon_url": "https://grafana.com/assets/img/fav32.png",
|
"icon_url": "https://grafana.com/assets/img/fav32.png",
|
||||||
}
|
}
|
||||||
|
|
||||||
embed := simplejson.New()
|
linkEmbed := simplejson.New()
|
||||||
embed.Set("title", tmpl(DefaultMessageTitleEmbed))
|
linkEmbed.Set("title", tmpl(DefaultMessageTitleEmbed))
|
||||||
embed.Set("footer", footer)
|
linkEmbed.Set("footer", footer)
|
||||||
embed.Set("type", "rich")
|
linkEmbed.Set("type", "rich")
|
||||||
|
|
||||||
color, _ := strconv.ParseInt(strings.TrimLeft(getAlertStatusColor(alerts.Status()), "#"), 16, 0)
|
color, _ := strconv.ParseInt(strings.TrimLeft(getAlertStatusColor(alerts.Status()), "#"), 16, 0)
|
||||||
embed.Set("color", color)
|
linkEmbed.Set("color", color)
|
||||||
|
|
||||||
ruleURL := joinUrlPath(d.tmpl.ExternalURL.String(), "/alerting/list", d.log)
|
ruleURL := joinUrlPath(d.tmpl.ExternalURL.String(), "/alerting/list", d.log)
|
||||||
embed.Set("url", ruleURL)
|
linkEmbed.Set("url", ruleURL)
|
||||||
|
|
||||||
bodyJSON.Set("embeds", []interface{}{embed})
|
embeds := []interface{}{linkEmbed}
|
||||||
|
|
||||||
|
attachments := d.constructAttachments(ctx, as, DiscordMaxEmbeds-1)
|
||||||
|
for _, a := range attachments {
|
||||||
|
color, _ := strconv.ParseInt(strings.TrimLeft(getAlertStatusColor(alerts.Status()), "#"), 16, 0)
|
||||||
|
embed := map[string]interface{}{
|
||||||
|
"image": map[string]interface{}{
|
||||||
|
"url": a.url,
|
||||||
|
},
|
||||||
|
"color": color,
|
||||||
|
"title": a.alertName,
|
||||||
|
}
|
||||||
|
embeds = append(embeds, embed)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyJSON.Set("embeds", embeds)
|
||||||
|
|
||||||
if tmplErr != nil {
|
if tmplErr != nil {
|
||||||
d.log.Warn("failed to template Discord message", "err", tmplErr.Error())
|
d.log.Warn("failed to template Discord message", "err", tmplErr.Error())
|
||||||
@ -144,11 +177,10 @@ func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
cmd := &models.SendWebhookSync{
|
|
||||||
Url: u,
|
cmd, err := d.buildRequest(ctx, u, body, attachments)
|
||||||
HttpMethod: "POST",
|
if err != nil {
|
||||||
ContentType: "application/json",
|
return false, err
|
||||||
Body: string(body),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := d.ns.SendWebhookSync(ctx, cmd); err != nil {
|
if err := d.ns.SendWebhookSync(ctx, cmd); err != nil {
|
||||||
@ -161,3 +193,111 @@ func (d DiscordNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool,
|
|||||||
func (d DiscordNotifier) SendResolved() bool {
|
func (d DiscordNotifier) SendResolved() bool {
|
||||||
return !d.GetDisableResolveMessage()
|
return !d.GetDisableResolveMessage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d DiscordNotifier) constructAttachments(ctx context.Context, as []*types.Alert, embedQuota int) []discordAttachment {
|
||||||
|
attachments := make([]discordAttachment, 0)
|
||||||
|
for i := range as {
|
||||||
|
if embedQuota == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
imgToken := getTokenFromAnnotations(as[i].Annotations)
|
||||||
|
if len(imgToken) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutCtx, cancel := context.WithTimeout(ctx, ImageStoreTimeout)
|
||||||
|
imgURL, err := d.images.GetURL(timeoutCtx, imgToken)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, ErrImagesUnavailable) {
|
||||||
|
// Ignore errors. Don't log "ImageUnavailable", which means the storage doesn't exist.
|
||||||
|
d.log.Warn("failed to retrieve image url from store", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(imgURL) > 0 {
|
||||||
|
attachments = append(attachments, discordAttachment{
|
||||||
|
url: imgURL,
|
||||||
|
state: as[i].Status(),
|
||||||
|
alertName: as[i].Name(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Need to upload the file. Tell Discord that we're embedding an attachment.
|
||||||
|
timeoutCtx, cancel := context.WithTimeout(ctx, ImageStoreTimeout)
|
||||||
|
fp, err := d.images.GetFilepath(timeoutCtx, imgToken)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, ErrImagesUnavailable) {
|
||||||
|
// Ignore errors. Don't log "ImageUnavailable", which means the storage doesn't exist.
|
||||||
|
d.log.Warn("failed to retrieve image filepath from store", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
base := filepath.Base(fp)
|
||||||
|
url := fmt.Sprintf("attachment://%s", base)
|
||||||
|
timeoutCtx, cancel = context.WithTimeout(ctx, ImageStoreTimeout)
|
||||||
|
reader, err := d.images.GetData(timeoutCtx, imgToken)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, ErrImagesUnavailable) {
|
||||||
|
// Ignore errors. Don't log "ImageUnavailable", which means the storage doesn't exist.
|
||||||
|
d.log.Warn("failed to retrieve image data from store", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attachments = append(attachments, discordAttachment{
|
||||||
|
url: url,
|
||||||
|
name: base,
|
||||||
|
reader: reader,
|
||||||
|
state: as[i].Status(),
|
||||||
|
alertName: as[i].Name(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
embedQuota++
|
||||||
|
}
|
||||||
|
return attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d DiscordNotifier) buildRequest(ctx context.Context, url string, body []byte, attachments []discordAttachment) (*models.SendWebhookSync, error) {
|
||||||
|
cmd := &models.SendWebhookSync{
|
||||||
|
Url: url,
|
||||||
|
HttpMethod: "POST",
|
||||||
|
}
|
||||||
|
if len(attachments) == 0 {
|
||||||
|
cmd.ContentType = "application/json"
|
||||||
|
cmd.Body = string(body)
|
||||||
|
return cmd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var b bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&b)
|
||||||
|
defer func() {
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
// Shouldn't matter since we already close w explicitly on the non-error path
|
||||||
|
d.log.Warn("Failed to close multipart writer", "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
payload, err := w.CreateFormField("payload_json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := payload.Write(body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, a := range attachments {
|
||||||
|
part, err := w.CreateFormFile("", a.name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(part, a.reader); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
|
||||||
|
}
|
||||||
|
cmd.ContentType = w.FormDataContentType()
|
||||||
|
cmd.Body = b.String()
|
||||||
|
return cmd, nil
|
||||||
|
}
|
||||||
|
@ -276,10 +276,11 @@ func TestDiscordNotifier(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
imageStore := &UnavailableImageStore{}
|
||||||
|
|
||||||
ctx := notify.WithGroupKey(context.Background(), "alertname")
|
ctx := notify.WithGroupKey(context.Background(), "alertname")
|
||||||
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""})
|
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""})
|
||||||
dn := NewDiscordNotifier(cfg, webhookSender, tmpl)
|
dn := NewDiscordNotifier(cfg, webhookSender, imageStore, tmpl)
|
||||||
ok, err := dn.Notify(ctx, c.alerts...)
|
ok, err := dn.Notify(ctx, c.alerts...)
|
||||||
if c.expMsgError != nil {
|
if c.expMsgError != nil {
|
||||||
require.False(t, ok)
|
require.False(t, ok)
|
||||||
|
Loading…
Reference in New Issue
Block a user