Skip to content

Commit 4bc00a7

Browse files
committed
feat: incident.io Notifier
- Adds the technical implementation, and tests, for the incident.io notifier - Configured through the following config: ```yaml receivers: - name: 'incidentio-notifications' incidentio_configs: - url: '$alert_source_url' alert_source_token: '$alert_source_token' ``` Signed-off-by: Rory Malcolm <[email protected]>
1 parent e060127 commit 4bc00a7

File tree

6 files changed

+558
-0
lines changed

6 files changed

+558
-0
lines changed

config/config.go

+1
Original file line numberDiff line numberDiff line change
@@ -1007,6 +1007,7 @@ type Receiver struct {
10071007

10081008
DiscordConfigs []*DiscordConfig `yaml:"discord_configs,omitempty" json:"discord_configs,omitempty"`
10091009
EmailConfigs []*EmailConfig `yaml:"email_configs,omitempty" json:"email_configs,omitempty"`
1010+
IncidentioConfigs []*IncidentioConfig `yaml:"incidentio_configs,omitempty" json:"incidentio_configs,omitempty"`
10101011
PagerdutyConfigs []*PagerdutyConfig `yaml:"pagerduty_configs,omitempty" json:"pagerduty_configs,omitempty"`
10111012
SlackConfigs []*SlackConfig `yaml:"slack_configs,omitempty" json:"slack_configs,omitempty"`
10121013
WebhookConfigs []*WebhookConfig `yaml:"webhook_configs,omitempty" json:"webhook_configs,omitempty"`

config/notifiers.go

+50
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ import (
2828
)
2929

3030
var (
31+
// DefaultIncidentioConfig defines default values for Incident.io configurations.
32+
DefaultIncidentioConfig = IncidentioConfig{
33+
NotifierConfig: NotifierConfig{
34+
VSendResolved: true,
35+
},
36+
}
37+
3138
// DefaultWebhookConfig defines default values for Webhook configurations.
3239
DefaultWebhookConfig = WebhookConfig{
3340
NotifierConfig: NotifierConfig{
@@ -521,6 +528,49 @@ func (c *SlackConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
521528
return nil
522529
}
523530

531+
// IncidentioConfig configures notifications via incident.io.
532+
type IncidentioConfig struct {
533+
NotifierConfig `yaml:",inline" json:",inline"`
534+
535+
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`
536+
537+
// URL to send POST request to.
538+
URL *SecretURL `yaml:"url" json:"url"`
539+
URLFile string `yaml:"url_file" json:"url_file"`
540+
541+
// AlertSourceToken is the key used to authenticate with the alert source in incident.io.
542+
AlertSourceToken Secret `yaml:"alert_source_token,omitempty" json:"alert_source_token,omitempty"`
543+
AlertSourceTokenFile string `yaml:"alert_source_token_file,omitempty" json:"alert_source_token_file,omitempty"`
544+
545+
// MaxAlerts is the maximum number of alerts to be sent per incident.io message.
546+
// Alerts exceeding this threshold will be truncated. Setting this to 0
547+
// allows an unlimited number of alerts.
548+
MaxAlerts uint64 `yaml:"max_alerts" json:"max_alerts"`
549+
550+
// Timeout is the maximum time allowed to invoke incident.io. Setting this to 0
551+
// does not impose a timeout.
552+
Timeout time.Duration `yaml:"timeout" json:"timeout"`
553+
}
554+
555+
// UnmarshalYAML implements the yaml.Unmarshaler interface.
556+
func (c *IncidentioConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
557+
*c = DefaultIncidentioConfig
558+
type plain IncidentioConfig
559+
if err := unmarshal((*plain)(c)); err != nil {
560+
return err
561+
}
562+
if c.URL == nil && c.URLFile == "" {
563+
return errors.New("one of url or url_file must be configured")
564+
}
565+
if c.URL != nil && c.URLFile != "" {
566+
return errors.New("at most one of url & url_file must be configured")
567+
}
568+
if c.AlertSourceToken != "" && c.AlertSourceTokenFile != "" {
569+
return errors.New("at most one of alert_source_token & alert_source_token_file must be configured")
570+
}
571+
return nil
572+
}
573+
524574
// WebhookConfig configures notifications via a generic webhook.
525575
type WebhookConfig struct {
526576
NotifierConfig `yaml:",inline" json:",inline"`

config/receiver/receiver.go

+4
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/prometheus/alertmanager/notify"
2424
"github.com/prometheus/alertmanager/notify/discord"
2525
"github.com/prometheus/alertmanager/notify/email"
26+
"github.com/prometheus/alertmanager/notify/incidentio"
2627
"github.com/prometheus/alertmanager/notify/jira"
2728
"github.com/prometheus/alertmanager/notify/msteams"
2829
"github.com/prometheus/alertmanager/notify/msteamsv2"
@@ -106,6 +107,9 @@ func BuildReceiverIntegrations(nc config.Receiver, tmpl *template.Template, logg
106107
for i, c := range nc.JiraConfigs {
107108
add("jira", i, c, func(l *slog.Logger) (notify.Notifier, error) { return jira.New(c, tmpl, l, httpOpts...) })
108109
}
110+
for i, c := range nc.IncidentioConfigs {
111+
add("incidentio", i, c, func(l *slog.Logger) (notify.Notifier, error) { return incidentio.New(c, tmpl, l, httpOpts...) })
112+
}
109113
for i, c := range nc.RocketchatConfigs {
110114
add("rocketchat", i, c, func(l *slog.Logger) (notify.Notifier, error) { return rocketchat.New(c, tmpl, l, httpOpts...) })
111115
}

notify/incidentio/incidentio.go

+204
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Copyright 2025 Prometheus Team
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package incidentio
15+
16+
import (
17+
"bytes"
18+
"context"
19+
"encoding/json"
20+
"fmt"
21+
"io"
22+
"log/slog"
23+
"net/http"
24+
"os"
25+
"strings"
26+
27+
commoncfg "github.com/prometheus/common/config"
28+
29+
"github.com/prometheus/alertmanager/config"
30+
"github.com/prometheus/alertmanager/notify"
31+
"github.com/prometheus/alertmanager/template"
32+
"github.com/prometheus/alertmanager/types"
33+
)
34+
35+
// Notifier implements a Notifier for incident.io.
36+
type Notifier struct {
37+
conf *config.IncidentioConfig
38+
tmpl *template.Template
39+
logger *slog.Logger
40+
client *http.Client
41+
retrier *notify.Retrier
42+
}
43+
44+
// New returns a new incident.io notifier.
45+
func New(conf *config.IncidentioConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
46+
// If alert source token is specified, set authorization in HTTP config
47+
if conf.HTTPConfig == nil {
48+
conf.HTTPConfig = &commoncfg.HTTPClientConfig{}
49+
}
50+
51+
if conf.AlertSourceToken != "" {
52+
if conf.HTTPConfig.Authorization == nil {
53+
conf.HTTPConfig.Authorization = &commoncfg.Authorization{
54+
Type: "Bearer",
55+
Credentials: commoncfg.Secret(conf.AlertSourceToken),
56+
}
57+
}
58+
} else if conf.AlertSourceTokenFile != "" {
59+
content, err := os.ReadFile(conf.AlertSourceTokenFile)
60+
if err != nil {
61+
return nil, fmt.Errorf("failed to read alert_source_token_file: %w", err)
62+
}
63+
64+
if conf.HTTPConfig.Authorization == nil {
65+
conf.HTTPConfig.Authorization = &commoncfg.Authorization{
66+
Type: "Bearer",
67+
Credentials: commoncfg.Secret(strings.TrimSpace(string(content))),
68+
}
69+
}
70+
}
71+
72+
client, err := commoncfg.NewClientFromConfig(*conf.HTTPConfig, "incidentio", httpOpts...)
73+
if err != nil {
74+
return nil, err
75+
}
76+
77+
return &Notifier{
78+
conf: conf,
79+
tmpl: t,
80+
logger: l,
81+
client: client,
82+
// Always retry on 429 (rate limiting) and 5xx response codes.
83+
retrier: &notify.Retrier{
84+
RetryCodes: []int{
85+
http.StatusTooManyRequests, // 429
86+
http.StatusInternalServerError,
87+
http.StatusBadGateway,
88+
http.StatusServiceUnavailable,
89+
http.StatusGatewayTimeout,
90+
},
91+
CustomDetailsFunc: errDetails,
92+
},
93+
}, nil
94+
}
95+
96+
// Message defines the JSON object sent to incident.io endpoints.
97+
type Message struct {
98+
*template.Data
99+
100+
// The protocol version.
101+
Version string `json:"version"`
102+
GroupKey string `json:"groupKey"`
103+
TruncatedAlerts uint64 `json:"truncatedAlerts"`
104+
}
105+
106+
func truncateAlerts(maxAlerts uint64, alerts []*types.Alert) ([]*types.Alert, uint64) {
107+
if maxAlerts != 0 && uint64(len(alerts)) > maxAlerts {
108+
return alerts[:maxAlerts], uint64(len(alerts)) - maxAlerts
109+
}
110+
111+
return alerts, 0
112+
}
113+
114+
// Notify implements the Notifier interface.
115+
func (n *Notifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) {
116+
alerts, numTruncated := truncateAlerts(n.conf.MaxAlerts, alerts)
117+
data := notify.GetTemplateData(ctx, n.tmpl, alerts, n.logger)
118+
119+
groupKey, err := notify.ExtractGroupKey(ctx)
120+
if err != nil {
121+
return false, err
122+
}
123+
124+
n.logger.Debug("incident.io notification", "groupKey", groupKey)
125+
126+
msg := &Message{
127+
Version: "4",
128+
Data: data,
129+
GroupKey: groupKey.String(),
130+
TruncatedAlerts: numTruncated,
131+
}
132+
133+
var buf bytes.Buffer
134+
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
135+
return false, err
136+
}
137+
138+
var url string
139+
if n.conf.URL != nil {
140+
url = n.conf.URL.String()
141+
} else {
142+
content, err := os.ReadFile(n.conf.URLFile)
143+
if err != nil {
144+
return false, fmt.Errorf("read url_file: %w", err)
145+
}
146+
url = strings.TrimSpace(string(content))
147+
}
148+
149+
if n.conf.Timeout > 0 {
150+
postCtx, cancel := context.WithTimeoutCause(ctx, n.conf.Timeout, fmt.Errorf("configured incident.io timeout reached (%s)", n.conf.Timeout))
151+
defer cancel()
152+
ctx = postCtx
153+
}
154+
155+
resp, err := notify.PostJSON(ctx, n.client, url, &buf)
156+
if err != nil {
157+
if ctx.Err() != nil {
158+
err = fmt.Errorf("%w: %w", err, context.Cause(ctx))
159+
}
160+
return true, notify.RedactURL(err)
161+
}
162+
defer notify.Drain(resp)
163+
164+
shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body)
165+
if err != nil {
166+
return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
167+
}
168+
return shouldRetry, err
169+
}
170+
171+
// errDetails extracts error details from the response for better error messages.
172+
func errDetails(status int, body io.Reader) string {
173+
if body == nil {
174+
return ""
175+
}
176+
177+
// Try to decode the error message from JSON response
178+
var errorResponse struct {
179+
Message string `json:"message"`
180+
Errors []string `json:"errors"`
181+
Error string `json:"error"`
182+
}
183+
184+
if err := json.NewDecoder(body).Decode(&errorResponse); err != nil {
185+
return ""
186+
}
187+
188+
// Format the error message
189+
var parts []string
190+
if errorResponse.Message != "" {
191+
parts = append(parts, errorResponse.Message)
192+
}
193+
if errorResponse.Error != "" {
194+
parts = append(parts, errorResponse.Error)
195+
}
196+
if len(errorResponse.Errors) > 0 {
197+
parts = append(parts, strings.Join(errorResponse.Errors, ", "))
198+
}
199+
200+
if len(parts) > 0 {
201+
return strings.Join(parts, ": ")
202+
}
203+
return ""
204+
}

0 commit comments

Comments
 (0)