Skip to content

Commit 51ea7d7

Browse files
committed
feat: add config types, validation, and docs for Slack message strategies
Signed-off-by: Santiago Fernández Núñez <santiago.nunez@nubank.com.br>
1 parent 3ce0f5c commit 51ea7d7

11 files changed

Lines changed: 351 additions & 12 deletions

config/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,9 @@ func (c *Config) UnmarshalYAML(unmarshal func(any) error) error {
426426
if sc.APIURL == nil && len(sc.APIURLFile) == 0 && sc.AppToken == "" && len(sc.AppTokenFile) == 0 {
427427
return errors.New("no Slack API URL nor App token set either inline or in a file")
428428
}
429+
if err := sc.ValidateMessageStrategy(); err != nil {
430+
return err
431+
}
429432
if sc.HTTPConfig == nil {
430433
// we don't want to change the global http config when setting the receiver's http config, do we do a copy
431434
httpconfig := *c.Global.HTTPConfig

config/config_test.go

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,13 +1072,108 @@ func TestSlackBothAppTokenAndAPIURL(t *testing.T) {
10721072
}
10731073
}
10741074

1075-
func TestSlackUpdateMessageWebhookURL(t *testing.T) {
1076-
_, err := LoadFile("testdata/conf.slack-update-message-and-webhook.yml")
1075+
func TestSlackMessageStrategyWithWrongAPIURL(t *testing.T) {
1076+
t.Parallel()
1077+
1078+
tests := []struct {
1079+
name string
1080+
file string
1081+
strategy string
1082+
}{
1083+
{
1084+
name: "update strategy with webhook URL",
1085+
file: "testdata/conf.slack-update-message-and-webhook.yml",
1086+
strategy: `update`,
1087+
},
1088+
{
1089+
name: "update strategy with webhook URL (new field)",
1090+
file: "testdata/conf.slack-update-message-and-webhook-with-new-field.yml",
1091+
strategy: `update`,
1092+
},
1093+
{
1094+
name: "thread strategy with webhook URL",
1095+
file: "testdata/conf.slack-thread-message-and-webhook.yml",
1096+
strategy: `thread`,
1097+
},
1098+
}
1099+
1100+
for _, tt := range tests {
1101+
tt := tt
1102+
t.Run(tt.name, func(t *testing.T) {
1103+
t.Parallel()
1104+
1105+
_, err := LoadFile(tt.file)
1106+
if err == nil {
1107+
t.Fatal("Expected an error")
1108+
}
1109+
expectedErrMsg := fmt.Sprintf("message_strategy %q requires a bot token; api_url must be https://slack.com/api/chat.postMessage", tt.strategy)
1110+
if err.Error() != expectedErrMsg {
1111+
t.Errorf("Expected: %s\nGot: %s", expectedErrMsg, err.Error())
1112+
}
1113+
})
1114+
}
1115+
}
1116+
1117+
func TestSlackMessageStrategyWithGlobalAPIURL(t *testing.T) {
1118+
t.Parallel()
1119+
1120+
_, err := LoadFile("testdata/conf.slack-thread-strategy-with-global-api-url.yml")
1121+
if err != nil {
1122+
t.Fatalf("Expected no error when message_strategy uses global slack_api_url, got: %s", err)
1123+
}
1124+
}
1125+
1126+
func TestSlackMessageStrategyWithoutAPIURL(t *testing.T) {
1127+
t.Parallel()
1128+
1129+
_, err := LoadFile("testdata/conf.slack-thread-strategy-without-api-url.yml")
10771130
if err == nil {
1078-
t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.slack-update-message-and-webhook", err)
1131+
t.Fatal("Expected an error when message_strategy: thread has no api_url at any level")
1132+
}
1133+
expectedErrMsg := `no Slack API URL nor App token set either inline or in a file`
1134+
if err.Error() != expectedErrMsg {
1135+
t.Errorf("Expected: %s\nGot: %s", expectedErrMsg, err.Error())
1136+
}
1137+
}
1138+
1139+
func TestSlackThreadedOptionsValidation(t *testing.T) {
1140+
t.Parallel()
1141+
1142+
tests := []struct {
1143+
name string
1144+
file string
1145+
expectedErrMsg string
1146+
}{
1147+
{
1148+
name: "threaded options without thread strategy (resolve emoji)",
1149+
file: "testdata/conf.slack-thread-resolve-emoji-without-thread-message.yml",
1150+
expectedErrMsg: `threaded_options requires message_strategy to be "thread"`,
1151+
},
1152+
{
1153+
name: "threaded options without thread strategy (update parent)",
1154+
file: "testdata/conf.slack-thread-update-parent-without-thread-message.yml",
1155+
expectedErrMsg: `threaded_options requires message_strategy to be "thread"`,
1156+
},
1157+
{
1158+
name: "resolve color without summary header",
1159+
file: "testdata/conf.slack-resolve-color-without-summary-header.yml",
1160+
expectedErrMsg: `threaded_options.summary_header requires use_summary_header to be enabled`,
1161+
},
10791162
}
1080-
if err.Error() != "update_message can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage" {
1081-
t.Errorf("Expected: %s\nGot: %s", "update_message can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage", err.Error())
1163+
1164+
for _, tt := range tests {
1165+
tt := tt
1166+
t.Run(tt.name, func(t *testing.T) {
1167+
t.Parallel()
1168+
1169+
_, err := LoadFile(tt.file)
1170+
if err == nil {
1171+
t.Fatal("Expected an error")
1172+
}
1173+
if err.Error() != tt.expectedErrMsg {
1174+
t.Errorf("Expected: %s\nGot: %s", tt.expectedErrMsg, err.Error())
1175+
}
1176+
})
10821177
}
10831178
}
10841179

config/notifiers.go

Lines changed: 116 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,45 @@ func (c *SlackField) UnmarshalYAML(unmarshal func(any) error) error {
470470
return nil
471471
}
472472

473+
// SlackMessageStrategy controls how subsequent notifications for the same
474+
// alert group are delivered to Slack.
475+
type SlackMessageStrategy string
476+
477+
const (
478+
// SlackMessageStrategyNew sends each notification as a separate message (default).
479+
SlackMessageStrategyNew SlackMessageStrategy = "new"
480+
// SlackMessageStrategyUpdate updates the original message in-place.
481+
SlackMessageStrategyUpdate SlackMessageStrategy = "update"
482+
// SlackMessageStrategyThread sends subsequent notifications as threaded replies.
483+
SlackMessageStrategyThread SlackMessageStrategy = "thread"
484+
)
485+
486+
// SlackThreadedOptions configures thread-specific behavior when message_strategy is "thread".
487+
type SlackThreadedOptions struct {
488+
// ResolveEmoji is the emoji name (without colons) to react with on the
489+
// original thread message when all alerts in the group are resolved.
490+
// Requires the bot token to have reactions:write scope.
491+
ResolveEmoji string `yaml:"resolve_emoji,omitempty" json:"resolve_emoji,omitempty"`
492+
493+
// UseSummaryHeader controls whether the thread parent is a lightweight
494+
// auto-updated summary (true, default) or the first actual alert message
495+
// (false). When true, all alert content is posted as replies and the parent
496+
// is continuously updated with the transition title and color.
497+
UseSummaryHeader *bool `yaml:"use_summary_header,omitempty" json:"use_summary_header,omitempty"`
498+
499+
// SummaryHeader holds options for summary-header mode only (see UseSummaryHeader).
500+
SummaryHeader *SlackThreadSummaryHeaderOptions `yaml:"summary_header,omitempty" json:"summary_header,omitempty"`
501+
}
502+
503+
// SlackThreadSummaryHeaderOptions configures fields that only apply when
504+
// message_strategy is "thread" and use_summary_header is true (the lightweight
505+
// parent summary mode).
506+
type SlackThreadSummaryHeaderOptions struct {
507+
// ResolveColor overrides the parent summary attachment color when the alert
508+
// group resolves. Supports Go templates.
509+
ResolveColor string `yaml:"resolve_color,omitempty" json:"resolve_color,omitempty"`
510+
}
511+
473512
// SlackConfig configures notifications via Slack.
474513
type SlackConfig struct {
475514
amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"`
@@ -505,10 +544,19 @@ type SlackConfig struct {
505544
MrkdwnIn []string `yaml:"mrkdwn_in,omitempty" json:"mrkdwn_in,omitempty"`
506545
Actions []*SlackAction `yaml:"actions,omitempty" json:"actions,omitempty"`
507546

547+
// MessageStrategy controls how subsequent notifications for the same alert
548+
// group are delivered: "new" (default), "update" (edit in-place), or "thread"
549+
// (threaded replies). "update" and "thread" require a bot token.
550+
MessageStrategy SlackMessageStrategy `yaml:"message_strategy,omitempty" json:"message_strategy,omitempty"`
551+
508552
// UpdateMessage enables updating existing Slack messages instead of creating new ones.
509-
// Requires bot token with chat:write scope. Webhook URLs do not support updates.
553+
// Deprecated: use message_strategy: update instead. If true, message_strategy must
554+
// be unset or "update"; when message_strategy is unset it is treated as "update".
555+
UpdateMessage bool `yaml:"update_message,omitempty" json:"update_message,omitempty"`
510556

511-
UpdateMessage bool `yaml:"update_message" json:"update_message,omitempty"`
557+
// ThreadedOptions configures thread-specific behavior.
558+
// Only valid when message_strategy is "thread".
559+
ThreadedOptions *SlackThreadedOptions `yaml:"threaded_options,omitempty" json:"threaded_options,omitempty"`
512560
// Timeout is the maximum time allowed to invoke the slack. Setting this to 0
513561
// does not impose a timeout.
514562
Timeout time.Duration `yaml:"timeout" json:"timeout"`
@@ -532,13 +580,77 @@ func (c *SlackConfig) UnmarshalYAML(unmarshal func(any) error) error {
532580
return errors.New("at most one of api_url/api_url_file & app_token/app_token_file must be configured")
533581
}
534582

535-
if c.UpdateMessage && c.APIURL.String() != "https://slack.com/api/chat.postMessage" {
536-
return errors.New("update_message can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage")
583+
// Deprecated: remove this block when update_message is deleted.
584+
if c.UpdateMessage {
585+
if c.MessageStrategy != "" && c.MessageStrategy != SlackMessageStrategyUpdate {
586+
return fmt.Errorf("update_message: true is incompatible with message_strategy %q; omit message_strategy or set message_strategy: \"update\"", c.MessageStrategy)
587+
}
588+
if c.MessageStrategy == "" {
589+
c.MessageStrategy = SlackMessageStrategyUpdate
590+
}
591+
}
592+
if c.MessageStrategy == "" {
593+
c.MessageStrategy = SlackMessageStrategyNew
594+
}
595+
596+
if c.MessageStrategy != SlackMessageStrategyNew &&
597+
c.MessageStrategy != SlackMessageStrategyUpdate &&
598+
c.MessageStrategy != SlackMessageStrategyThread {
599+
return fmt.Errorf("unknown message_strategy %q; must be \"new\", \"update\", or \"thread\"", c.MessageStrategy)
600+
}
601+
602+
if c.ThreadedOptions != nil {
603+
if c.MessageStrategy != SlackMessageStrategyThread {
604+
return errors.New("threaded_options requires message_strategy to be \"thread\"")
605+
}
606+
if c.ThreadedOptions.UseSummaryHeader != nil && !*c.ThreadedOptions.UseSummaryHeader && c.ThreadedOptions.SummaryHeader != nil {
607+
return errors.New("threaded_options.summary_header requires use_summary_header to be enabled")
608+
}
537609
}
538610

539611
return nil
540612
}
541613

614+
// ValidateMessageStrategy checks that the resolved api_url (after global defaults
615+
// have been applied) satisfies the requirements for update/thread strategies.
616+
func (c *SlackConfig) ValidateMessageStrategy() error {
617+
switch c.MessageStrategy {
618+
case SlackMessageStrategyUpdate, SlackMessageStrategyThread:
619+
if c.APIURL == nil && c.APIURLFile == "" {
620+
return fmt.Errorf("message_strategy %q requires api_url or api_url_file", c.MessageStrategy)
621+
}
622+
if c.APIURL != nil && c.APIURL.String() != "https://slack.com/api/chat.postMessage" {
623+
return fmt.Errorf("message_strategy %q requires a bot token; api_url must be https://slack.com/api/chat.postMessage", c.MessageStrategy)
624+
}
625+
}
626+
return nil
627+
}
628+
629+
// UseSummaryHeaderInThread returns true when the thread parent should be a lightweight
630+
// auto-updated summary. Returns true by default (nil or explicit true).
631+
func (c *SlackConfig) UseSummaryHeaderInThread() bool {
632+
if c.ThreadedOptions == nil || c.ThreadedOptions.UseSummaryHeader == nil {
633+
return true
634+
}
635+
return *c.ThreadedOptions.UseSummaryHeader
636+
}
637+
638+
// HasStrategyThatUpdatesParent reports whether message_strategy uses nflog to tie
639+
// multiple notifications to one Slack message or thread.
640+
func (c *SlackConfig) HasStrategyThatUpdatesParent() bool {
641+
return c.HasUpdateStrategy() || c.HasThreadStrategy()
642+
}
643+
644+
// HasUpdateStrategy is true when message_strategy is "update" (chat.update in place).
645+
func (c *SlackConfig) HasUpdateStrategy() bool {
646+
return c.MessageStrategy == SlackMessageStrategyUpdate
647+
}
648+
649+
// HasThreadStrategy is true when message_strategy is "thread" (threaded replies).
650+
func (c *SlackConfig) HasThreadStrategy() bool {
651+
return c.MessageStrategy == SlackMessageStrategyThread
652+
}
653+
542654
// IncidentioConfig configures notifications via incident.io.
543655
type IncidentioConfig struct {
544656
amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"`
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
route:
2+
receiver: 'slack-notifications'
3+
group_by: [alertname]
4+
receivers:
5+
- name: 'slack-notifications'
6+
slack_configs:
7+
- channel: '#alerts1'
8+
text: 'test'
9+
send_resolved: true
10+
api_url: 'https://slack.com/api/chat.postMessage'
11+
http_config:
12+
authorization:
13+
credentials: 'xoxb-XXXXXXXXXXXX-XXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXX'
14+
message_strategy: thread
15+
threaded_options:
16+
use_summary_header: false
17+
summary_header:
18+
resolve_color: '#AAAAAA'
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
route:
2+
receiver: 'slack-notifications'
3+
group_by: [alertname]
4+
receivers:
5+
- name: 'slack-notifications'
6+
slack_configs:
7+
- channel: '#alerts1'
8+
text: 'test'
9+
send_resolved: true
10+
api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'
11+
message_strategy: thread
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
route:
2+
receiver: 'slack-notifications'
3+
group_by: [alertname]
4+
receivers:
5+
- name: 'slack-notifications'
6+
slack_configs:
7+
- channel: '#alerts1'
8+
text: 'test'
9+
send_resolved: true
10+
api_url: 'https://slack.com/api/chat.postMessage'
11+
http_config:
12+
authorization:
13+
credentials: 'xoxb-XXXXXXXXXXXX-XXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXX'
14+
threaded_options:
15+
resolve_emoji: 'white_check_mark'
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
global:
2+
slack_api_url: 'https://slack.com/api/chat.postMessage'
3+
route:
4+
receiver: 'slack-notifications'
5+
group_by: [alertname]
6+
receivers:
7+
- name: 'slack-notifications'
8+
slack_configs:
9+
- channel: '#alerts'
10+
text: 'test'
11+
http_config:
12+
authorization:
13+
credentials: 'xoxb-test-token'
14+
message_strategy: thread
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
route:
2+
receiver: 'slack-notifications'
3+
group_by: [alertname]
4+
receivers:
5+
- name: 'slack-notifications'
6+
slack_configs:
7+
- channel: '#alerts'
8+
text: 'test'
9+
message_strategy: thread
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
route:
2+
receiver: 'slack-notifications'
3+
group_by: [alertname]
4+
receivers:
5+
- name: 'slack-notifications'
6+
slack_configs:
7+
- channel: '#alerts1'
8+
text: 'test'
9+
send_resolved: true
10+
api_url: 'https://slack.com/api/chat.postMessage'
11+
http_config:
12+
authorization:
13+
credentials: 'xoxb-XXXXXXXXXXXX-XXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXX'
14+
threaded_options:
15+
resolve_emoji: 'white_check_mark'
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
route:
2+
receiver: 'slack-notifications'
3+
group_by: [alertname]
4+
receivers:
5+
- name: 'slack-notifications'
6+
slack_configs:
7+
# use global
8+
- channel: '#alerts1'
9+
text: 'test'
10+
send_resolved: true
11+
# trying to use webhook urls with update_message
12+
api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'
13+
message_strategy: update

0 commit comments

Comments
 (0)