diff --git a/pkg/services/ngalert/notifier/channels/opsgenie.go b/pkg/services/ngalert/notifier/channels/opsgenie.go index ba8480a9c67..bb45abce242 100644 --- a/pkg/services/ngalert/notifier/channels/opsgenie.go +++ b/pkg/services/ngalert/notifier/channels/opsgenie.go @@ -13,8 +13,8 @@ import ( "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" "github.com/prometheus/common/model" + ptr "github.com/xorcare/pointer" - "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -35,21 +35,14 @@ var ( // OpsgenieNotifier is responsible for sending alert notifications to Opsgenie. type OpsgenieNotifier struct { *Base - APIKey string - APIUrl string - Message string - Description string - AutoClose bool - OverridePriority bool - SendTagsAs string - tmpl *template.Template - log log.Logger - ns notifications.WebhookSender - images ImageStore + tmpl *template.Template + log log.Logger + ns notifications.WebhookSender + images ImageStore + settings *opsgenieSettings } -type OpsgenieConfig struct { - *NotificationChannelConfig +type opsgenieSettings struct { APIKey string APIUrl string Message string @@ -59,62 +52,92 @@ type OpsgenieConfig struct { SendTagsAs string } +func buildOpsgenieSettings(fc FactoryConfig) (*opsgenieSettings, error) { + type rawSettings struct { + APIKey string `json:"apiKey,omitempty" yaml:"apiKey,omitempty"` + APIUrl string `json:"apiUrl,omitempty" yaml:"apiUrl,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + AutoClose *bool `json:"autoClose,omitempty" yaml:"autoClose,omitempty"` + OverridePriority *bool `json:"overridePriority,omitempty" yaml:"overridePriority,omitempty"` + SendTagsAs string `json:"sendTagsAs,omitempty" yaml:"sendTagsAs,omitempty"` + } + + raw := rawSettings{} + err := fc.Config.unmarshalSettings(&raw) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal settings: %w", err) + } + + raw.APIKey = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "apiKey", raw.APIKey) + if raw.APIKey == "" { + return nil, errors.New("could not find api key property in settings") + } + if raw.APIUrl == "" { + raw.APIUrl = OpsgenieAlertURL + } + + if strings.TrimSpace(raw.Message) == "" { + raw.Message = DefaultMessageTitleEmbed + } + + switch raw.SendTagsAs { + case OpsgenieSendTags, OpsgenieSendDetails, OpsgenieSendBoth: + case "": + raw.SendTagsAs = OpsgenieSendTags + default: + return nil, fmt.Errorf("invalid value for sendTagsAs: %q", raw.SendTagsAs) + } + + if raw.AutoClose == nil { + raw.AutoClose = ptr.Bool(true) + } + if raw.OverridePriority == nil { + raw.OverridePriority = ptr.Bool(true) + } + + return &opsgenieSettings{ + APIKey: raw.APIKey, + APIUrl: raw.APIUrl, + Message: raw.Message, + Description: raw.Description, + AutoClose: *raw.AutoClose, + OverridePriority: *raw.OverridePriority, + SendTagsAs: raw.SendTagsAs, + }, nil +} + func OpsgenieFactory(fc FactoryConfig) (NotificationChannel, error) { - cfg, err := NewOpsgenieConfig(fc.Config, fc.DecryptFunc) + notifier, err := NewOpsgenieNotifier(fc) if err != nil { return nil, receiverInitError{ Reason: err.Error(), Cfg: *fc.Config, } } - return NewOpsgenieNotifier(cfg, fc.NotificationService, fc.ImageStore, fc.Template, fc.DecryptFunc), nil -} - -func NewOpsgenieConfig(config *NotificationChannelConfig, decryptFunc GetDecryptedValueFn) (*OpsgenieConfig, error) { - apiKey := decryptFunc(context.Background(), config.SecureSettings, "apiKey", config.Settings.Get("apiKey").MustString()) - if apiKey == "" { - return nil, errors.New("could not find api key property in settings") - } - sendTagsAs := config.Settings.Get("sendTagsAs").MustString(OpsgenieSendTags) - if sendTagsAs != OpsgenieSendTags && - sendTagsAs != OpsgenieSendDetails && - sendTagsAs != OpsgenieSendBoth { - return nil, fmt.Errorf("invalid value for sendTagsAs: %q", sendTagsAs) - } - return &OpsgenieConfig{ - NotificationChannelConfig: config, - APIKey: apiKey, - APIUrl: config.Settings.Get("apiUrl").MustString(OpsgenieAlertURL), - AutoClose: config.Settings.Get("autoClose").MustBool(true), - OverridePriority: config.Settings.Get("overridePriority").MustBool(true), - Message: config.Settings.Get("message").MustString(`{{ template "default.title" . }}`), - Description: config.Settings.Get("description").MustString(""), - SendTagsAs: sendTagsAs, - }, nil + return notifier, nil } // NewOpsgenieNotifier is the constructor for the Opsgenie notifier -func NewOpsgenieNotifier(config *OpsgenieConfig, ns notifications.WebhookSender, images ImageStore, t *template.Template, fn GetDecryptedValueFn) *OpsgenieNotifier { +func NewOpsgenieNotifier(fc FactoryConfig) (*OpsgenieNotifier, error) { + settings, err := buildOpsgenieSettings(fc) + if err != nil { + return nil, err + } return &OpsgenieNotifier{ Base: NewBase(&models.AlertNotification{ - Uid: config.UID, - Name: config.Name, - Type: config.Type, - DisableResolveMessage: config.DisableResolveMessage, - Settings: config.Settings, + Uid: fc.Config.UID, + Name: fc.Config.Name, + Type: fc.Config.Type, + DisableResolveMessage: fc.Config.DisableResolveMessage, + Settings: fc.Config.Settings, }), - APIKey: config.APIKey, - APIUrl: config.APIUrl, - Description: config.Description, - Message: config.Message, - AutoClose: config.AutoClose, - OverridePriority: config.OverridePriority, - SendTagsAs: config.SendTagsAs, - tmpl: t, - log: log.New("alerting.notifier." + config.Name), - ns: ns, - images: images, - } + tmpl: fc.Template, + log: log.New("alerting.notifier.opsgenie"), + ns: fc.NotificationService, + images: fc.ImageStore, + settings: settings, + }, nil } // Notify sends an alert notification to Opsgenie @@ -127,7 +150,7 @@ func (on *OpsgenieNotifier) Notify(ctx context.Context, as ...*types.Alert) (boo return true, nil } - bodyJSON, url, err := on.buildOpsgenieMessage(ctx, alerts, as) + body, url, err := on.buildOpsgenieMessage(ctx, alerts, as) if err != nil { return false, fmt.Errorf("build Opsgenie message: %w", err) } @@ -138,18 +161,13 @@ func (on *OpsgenieNotifier) Notify(ctx context.Context, as ...*types.Alert) (boo return true, nil } - body, err := json.Marshal(bodyJSON) - if err != nil { - return false, fmt.Errorf("marshal json: %w", err) - } - cmd := &models.SendWebhookSync{ Url: url, Body: string(body), HttpMethod: http.MethodPost, HttpHeader: map[string]string{ "Content-Type": "application/json", - "Authorization": fmt.Sprintf("GenieKey %s", on.APIKey), + "Authorization": fmt.Sprintf("GenieKey %s", on.settings.APIKey), }, } @@ -160,28 +178,24 @@ func (on *OpsgenieNotifier) Notify(ctx context.Context, as ...*types.Alert) (boo return true, nil } -func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts model.Alerts, as []*types.Alert) (payload *simplejson.Json, apiURL string, err error) { +func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts model.Alerts, as []*types.Alert) (payload []byte, apiURL string, err error) { key, err := notify.ExtractGroupKey(ctx) if err != nil { return nil, "", err } - var ( - alias = key.Hash() - bodyJSON = simplejson.New() - details = simplejson.New() - ) - if alerts.Status() == model.AlertResolved { // For resolved notification, we only need the source. // Don't need to run other templates. - if on.AutoClose { - bodyJSON := simplejson.New() - bodyJSON.Set("source", "Grafana") - apiURL = fmt.Sprintf("%s/%s/close?identifierType=alias", on.APIUrl, alias) - return bodyJSON, apiURL, nil + if !on.settings.AutoClose { // TODO This should be handled by DisableResolveMessage? + return nil, "", nil } - return nil, "", nil + msg := opsGenieCloseMessage{ + Source: "Grafana", + } + data, err := json.Marshal(msg) + apiURL = fmt.Sprintf("%s/%s/close?identifierType=alias", on.settings.APIUrl, key.Hash()) + return data, apiURL, err } ruleURL := joinUrlPath(on.tmpl.ExternalURL.String(), "/alerting/list", on.log) @@ -189,17 +203,12 @@ func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts mod var tmplErr error tmpl, data := TmplText(ctx, on.tmpl, as, on.log, &tmplErr) - titleTmpl := on.Message - if strings.TrimSpace(titleTmpl) == "" { - titleTmpl = `{{ template "default.title" . }}` + message, truncated := notify.Truncate(tmpl(on.settings.Message), 130) + if truncated { + on.log.Debug("Truncated message", "originalMessage", message) } - title := tmpl(titleTmpl) - if len(title) > 130 { - title = title[:127] + "..." - } - - description := tmpl(on.Description) + description := tmpl(on.settings.Description) if strings.TrimSpace(description) == "" { description = fmt.Sprintf( "%s\n%s\n\n%s", @@ -215,8 +224,7 @@ func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts mod lbls := make(map[string]string, len(data.CommonLabels)) for k, v := range data.CommonLabels { lbls[k] = tmpl(v) - - if k == "og_priority" { + if k == "og_priority" && on.settings.OverridePriority { if ValidPriorities[v] { priority = v } @@ -229,18 +237,13 @@ func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts mod tmplErr = nil } - bodyJSON.Set("message", title) - bodyJSON.Set("source", "Grafana") - bodyJSON.Set("alias", alias) - bodyJSON.Set("description", description) - details.Set("url", ruleURL) - + details := make(map[string]interface{}) + details["url"] = ruleURL if on.sendDetails() { for k, v := range lbls { - details.Set(k, v) + details[k] = v } - - images := []string{} + var images []string _ = withStoredImages(ctx, on.log, on.images, func(_ int, image ngmodels.Image) error { if len(image.URL) == 0 { @@ -252,7 +255,7 @@ func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts mod as...) if len(images) != 0 { - details.Set("image_urls", images) + details["image_urls"] = images } } @@ -264,19 +267,24 @@ func (on *OpsgenieNotifier) buildOpsgenieMessage(ctx context.Context, alerts mod } sort.Strings(tags) - if priority != "" && on.OverridePriority { - bodyJSON.Set("priority", priority) + result := opsGenieCreateMessage{ + Alias: key.Hash(), + Description: description, + Tags: tags, + Source: "Grafana", + Message: message, + Details: details, + Priority: priority, } - bodyJSON.Set("tags", tags) - bodyJSON.Set("details", details) - apiURL = tmpl(on.APIUrl) + apiURL = tmpl(on.settings.APIUrl) if tmplErr != nil { - on.log.Warn("failed to template Opsgenie URL", "error", tmplErr.Error(), "fallback", on.APIUrl) - apiURL = on.APIUrl + on.log.Warn("failed to template Opsgenie URL", "error", tmplErr.Error(), "fallback", on.settings.APIUrl) + apiURL = on.settings.APIUrl } - return bodyJSON, apiURL, nil + b, err := json.Marshal(result) + return b, apiURL, err } func (on *OpsgenieNotifier) SendResolved() bool { @@ -284,9 +292,34 @@ func (on *OpsgenieNotifier) SendResolved() bool { } func (on *OpsgenieNotifier) sendDetails() bool { - return on.SendTagsAs == OpsgenieSendDetails || on.SendTagsAs == OpsgenieSendBoth + return on.settings.SendTagsAs == OpsgenieSendDetails || on.settings.SendTagsAs == OpsgenieSendBoth } func (on *OpsgenieNotifier) sendTags() bool { - return on.SendTagsAs == OpsgenieSendTags || on.SendTagsAs == OpsgenieSendBoth + return on.settings.SendTagsAs == OpsgenieSendTags || on.settings.SendTagsAs == OpsgenieSendBoth +} + +type opsGenieCreateMessage struct { + Alias string `json:"alias"` + Message string `json:"message"` + Description string `json:"description,omitempty"` + Details map[string]interface{} `json:"details"` + Source string `json:"source"` + Responders []opsGenieCreateMessageResponder `json:"responders,omitempty"` + Tags []string `json:"tags"` + Note string `json:"note,omitempty"` + Priority string `json:"priority,omitempty"` + Entity string `json:"entity,omitempty"` + Actions []string `json:"actions,omitempty"` +} + +type opsGenieCreateMessageResponder struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Username string `json:"username,omitempty"` + Type string `json:"type"` // team, user, escalation, schedule etc. +} + +type opsGenieCloseMessage struct { + Source string `json:"source"` } diff --git a/pkg/services/ngalert/notifier/channels/opsgenie_test.go b/pkg/services/ngalert/notifier/channels/opsgenie_test.go index 04b507517cb..6236cc81fbf 100644 --- a/pkg/services/ngalert/notifier/channels/opsgenie_test.go +++ b/pkg/services/ngalert/notifier/channels/opsgenie_test.go @@ -95,7 +95,7 @@ func TestOpsgenieNotifier(t *testing.T) { "details": { "url": "http://localhost/alerting/list" }, - "message": "IyJnsW78xQoiBJ7L7NqASv31JCFf0At3r9KUykqBVxSiC6qkDhvDLDW9VImiFcq0Iw2XwFy5fX4FcbTmlkaZzUzjVwx9VUuokhzqQlJVhWDYFqhj3a5wX0LjyvNQjsq...", + "message": "IyJnsW78xQoiBJ7L7NqASv31JCFf0At3r9KUykqBVxSiC6qkDhvDLDW9VImiFcq0Iw2XwFy5fX4FcbTmlkaZzUzjVwx9VUuokhzqQlJVhWDYFqhj3a5wX0LjyvNQjsqT9…", "source": "Grafana", "tags": ["alertname:alert1", "lbl1:val1"] }`, @@ -234,28 +234,33 @@ func TestOpsgenieNotifier(t *testing.T) { require.NoError(t, err) secureSettings := make(map[string][]byte) - m := &NotificationChannelConfig{ - Name: "opsgenie_testing", - Type: "opsgenie", - Settings: settingsJSON, - SecureSettings: secureSettings, - } - webhookSender := mockNotificationService() webhookSender.Webhook.Body = "" secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) decryptFn := secretsService.GetDecryptedValue - cfg, err := NewOpsgenieConfig(m, decryptFn) + + fc := FactoryConfig{ + Config: &NotificationChannelConfig{ + Name: "opsgenie_testing", + Type: "opsgenie", + Settings: settingsJSON, + SecureSettings: secureSettings, + }, + NotificationService: webhookSender, + DecryptFunc: decryptFn, + ImageStore: &UnavailableImageStore{}, + Template: tmpl, + } + + ctx := notify.WithGroupKey(context.Background(), "alertname") + ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) + pn, err := NewOpsgenieNotifier(fc) if c.expInitError != "" { require.Error(t, err) require.Equal(t, c.expInitError, err.Error()) return } require.NoError(t, err) - - ctx := notify.WithGroupKey(context.Background(), "alertname") - ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""}) - pn := NewOpsgenieNotifier(cfg, webhookSender, &UnavailableImageStore{}, tmpl, decryptFn) ok, err := pn.Notify(ctx, c.alerts...) if c.expMsgError != nil { require.False(t, ok) diff --git a/pkg/services/ngalert/notifier/channels/pagerduty.go b/pkg/services/ngalert/notifier/channels/pagerduty.go index 3765a0696c7..a84347bb1d1 100644 --- a/pkg/services/ngalert/notifier/channels/pagerduty.go +++ b/pkg/services/ngalert/notifier/channels/pagerduty.go @@ -24,6 +24,8 @@ const ( pagerDutyEventResolve = "resolve" defaultSeverity = "critical" + defaultClass = "default" + defaultGroup = "default" ) var ( @@ -39,7 +41,7 @@ type PagerdutyNotifier struct { log log.Logger ns notifications.WebhookSender images ImageStore - settings pagerdutySettings + settings *pagerdutySettings } type pagerdutySettings struct { @@ -52,6 +54,44 @@ type pagerdutySettings struct { Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` } +func buildPagerdutySettings(fc FactoryConfig) (*pagerdutySettings, error) { + settings := pagerdutySettings{} + err := fc.Config.unmarshalSettings(&settings) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal settings: %w", err) + } + + settings.Key = fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "integrationKey", settings.Key) + if settings.Key == "" { + return nil, errors.New("could not find integration key property in settings") + } + + settings.customDetails = map[string]string{ + "firing": `{{ template "__text_alert_list" .Alerts.Firing }}`, + "resolved": `{{ template "__text_alert_list" .Alerts.Resolved }}`, + "num_firing": `{{ .Alerts.Firing | len }}`, + "num_resolved": `{{ .Alerts.Resolved | len }}`, + } + + if settings.Severity == "" { + settings.Severity = defaultSeverity + } + if settings.Class == "" { + settings.Class = defaultClass + } + if settings.Component == "" { + settings.Component = "Grafana" + } + if settings.Group == "" { + settings.Group = defaultGroup + } + if settings.Summary == "" { + settings.Summary = DefaultMessageTitleEmbed + } + + return &settings, nil +} + func PagerdutyFactory(fc FactoryConfig) (NotificationChannel, error) { pdn, err := newPagerdutyNotifier(fc) if err != nil { @@ -65,9 +105,9 @@ func PagerdutyFactory(fc FactoryConfig) (NotificationChannel, error) { // NewPagerdutyNotifier is the constructor for the PagerDuty notifier func newPagerdutyNotifier(fc FactoryConfig) (*PagerdutyNotifier, error) { - key := fc.DecryptFunc(context.Background(), fc.Config.SecureSettings, "integrationKey", fc.Config.Settings.Get("integrationKey").MustString()) - if key == "" { - return nil, errors.New("could not find integration key property in settings") + settings, err := buildPagerdutySettings(fc) + if err != nil { + return nil, err } return &PagerdutyNotifier{ @@ -78,24 +118,11 @@ func newPagerdutyNotifier(fc FactoryConfig) (*PagerdutyNotifier, error) { DisableResolveMessage: fc.Config.DisableResolveMessage, Settings: fc.Config.Settings, }), - tmpl: fc.Template, - log: log.New("alerting.notifier." + fc.Config.Name), - ns: fc.NotificationService, - images: fc.ImageStore, - settings: pagerdutySettings{ - Key: key, - Severity: fc.Config.Settings.Get("severity").MustString(defaultSeverity), - customDetails: map[string]string{ - "firing": `{{ template "__text_alert_list" .Alerts.Firing }}`, - "resolved": `{{ template "__text_alert_list" .Alerts.Resolved }}`, - "num_firing": `{{ .Alerts.Firing | len }}`, - "num_resolved": `{{ .Alerts.Resolved | len }}`, - }, - Class: fc.Config.Settings.Get("class").MustString("default"), - Component: fc.Config.Settings.Get("component").MustString("Grafana"), - Group: fc.Config.Settings.Get("group").MustString("default"), - Summary: fc.Config.Settings.Get("summary").MustString(DefaultMessageTitleEmbed), - }, + tmpl: fc.Template, + log: log.New("alerting.notifier." + fc.Config.Name), + ns: fc.NotificationService, + images: fc.ImageStore, + settings: settings, }, nil } @@ -192,9 +219,9 @@ func (pn *PagerdutyNotifier) buildPagerdutyMessage(ctx context.Context, alerts m }, as...) - if len(msg.Payload.Summary) > 1024 { - // This is the Pagerduty limit. - msg.Payload.Summary = msg.Payload.Summary[:1021] + "..." + if summary, truncated := notify.Truncate(msg.Payload.Summary, 1024); truncated { + pn.log.Debug("Truncated summary", "original", msg.Payload.Summary) + msg.Payload.Summary = summary } if hostname, err := os.Hostname(); err == nil { diff --git a/pkg/services/ngalert/notifier/channels/pagerduty_test.go b/pkg/services/ngalert/notifier/channels/pagerduty_test.go index 47c8d49dee5..21ee9c88acc 100644 --- a/pkg/services/ngalert/notifier/channels/pagerduty_test.go +++ b/pkg/services/ngalert/notifier/channels/pagerduty_test.go @@ -3,8 +3,11 @@ package channels import ( "context" "encoding/json" + "fmt" + "math/rand" "net/url" "os" + "strings" "testing" "github.com/prometheus/alertmanager/notify" @@ -184,7 +187,43 @@ func TestPagerdutyNotifier(t *testing.T) { Links: []pagerDutyLink{{HRef: "http://localhost", Text: "External URL"}}, }, expMsgError: nil, - }, { + }, + { + name: "should truncate long summary", + settings: fmt.Sprintf(`{"integrationKey": "abcdefgh0123456789", "summary": "%s"}`, strings.Repeat("1", rand.Intn(100)+1025)), + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"}, + }, + }, + }, + expMsg: &pagerDutyMessage{ + RoutingKey: "abcdefgh0123456789", + DedupKey: "6e3538104c14b583da237e9693b76debbc17f0f8058ef20492e5853096cf8733", + EventAction: "trigger", + Payload: pagerDutyPayload{ + Summary: fmt.Sprintf("%s…", strings.Repeat("1", 1023)), + Source: hostname, + Severity: "critical", + Class: "default", + Component: "Grafana", + Group: "default", + CustomDetails: map[string]string{ + "firing": "\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n", + "num_firing": "1", + "num_resolved": "0", + "resolved": "", + }, + }, + Client: "Grafana", + ClientURL: "http://localhost", + Links: []pagerDutyLink{{HRef: "http://localhost", Text: "External URL"}}, + }, + expMsgError: nil, + }, + { name: "Error in initing", settings: `{}`, expInitError: `could not find integration key property in settings`,