From c50833f5d205d989678be70d306c03a593988273 Mon Sep 17 00:00:00 2001 From: chernistry Date: Mon, 6 Apr 2026 21:15:20 +0300 Subject: [PATCH 1/2] fix(Discord): Move message content to embed description field The Discord notifier was placing the alert message in the webhook's top-level content field, which renders above the embed. This caused the message text to appear above the title instead of below it. Move the message to the embed's description field so it renders below the title within the embed, matching the behavior of other notification channels like Slack. Also increases the truncation limit from 2000 (content limit) to 4096 (embed description limit) per Discord API documentation. Fixes grafana/grafana#86565 --- receivers/discord/v1/discord.go | 20 +++--- receivers/discord/v1/discord_test.go | 91 ++++++++++++++++++---------- 2 files changed, 69 insertions(+), 42 deletions(-) diff --git a/receivers/discord/v1/discord.go b/receivers/discord/v1/discord.go index ceac25aa2..ba867260d 100644 --- a/receivers/discord/v1/discord.go +++ b/receivers/discord/v1/discord.go @@ -32,7 +32,8 @@ const ( discordMaxEmbeds = 10 discordMaxMessageLen = 2000 // https://discord.com/developers/docs/resources/message#embed-object-embed-limits - discordMaxTitleLen = 256 + discordMaxTitleLen = 256 + discordMaxDescriptionLen = 4096 ) type discordMessage struct { @@ -44,10 +45,11 @@ type discordMessage struct { // discordLinkEmbed implements https://discord.com/developers/docs/resources/channel#embed-object type discordLinkEmbed struct { - Title string `json:"title,omitempty"` - Type discordEmbedType `json:"type,omitempty"` - URL string `json:"url,omitempty"` - Color int64 `json:"color,omitempty"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Type discordEmbedType `json:"type,omitempty"` + URL string `json:"url,omitempty"` + Color int64 `json:"color,omitempty"` Footer *discordFooter `json:"footer,omitempty"` @@ -117,16 +119,15 @@ func (d Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) var tmplErr error tmpl, _ := templates.TmplText(ctx, d.tmpl, as, l, &tmplErr) - msg.Content = tmpl(d.settings.Message) + messageText := tmpl(d.settings.Message) if tmplErr != nil { level.Warn(l).Log("msg", "failed to template Discord notification content", "err", tmplErr.Error()) // Reset tmplErr for templating other fields. tmplErr = nil } - truncatedMsg, truncated := receivers.TruncateInRunes(msg.Content, discordMaxMessageLen) + messageText, truncated := receivers.TruncateInRunes(messageText, discordMaxDescriptionLen) if truncated { - level.Warn(l).Log("msg", "Truncated content", "key", key, "max_runes", discordMaxMessageLen) - msg.Content = truncatedMsg + level.Warn(l).Log("msg", "Truncated content", "key", key, "max_runes", discordMaxDescriptionLen) } if d.settings.AvatarURL != "" { @@ -156,6 +157,7 @@ func (d Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) level.Warn(l).Log("msg", "Truncated title", "key", key, "max_runes", discordMaxTitleLen) } + linkEmbed.Description = messageText linkEmbed.Footer = footer linkEmbed.Type = discordRichEmbed diff --git a/receivers/discord/v1/discord_test.go b/receivers/discord/v1/discord_test.go index 88d943b53..6eeaff8f8 100644 --- a/receivers/discord/v1/discord_test.go +++ b/receivers/discord/v1/discord_test.go @@ -61,9 +61,10 @@ func TestNotify(t *testing.T) { }, }, expMsg: map[string]interface{}{ - "content": "**Firing**\n\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", + "content": "", "embeds": []interface{}{map[string]interface{}{ - "color": 1.4037554e+07, + "color": 1.4037554e+07, + "description": "**Firing**\n\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", "footer": map[string]interface{}{ "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + appVersion, @@ -94,9 +95,10 @@ func TestNotify(t *testing.T) { }, }, expMsg: map[string]interface{}{ - "content": "**Firing**\n\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", + "content": "", "embeds": []interface{}{map[string]interface{}{ - "color": 1.4037554e+07, + "color": 1.4037554e+07, + "description": "**Firing**\n\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", "footer": map[string]interface{}{ "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + appVersion, @@ -127,9 +129,10 @@ func TestNotify(t *testing.T) { }, }, expMsg: map[string]any{ - "content": "**Firing**\n\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", + "content": "", "embeds": []any{map[string]any{ - "color": 1.4037554e+07, + "color": 1.4037554e+07, + "description": "**Firing**\n\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", "footer": map[string]any{ "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + appVersion, @@ -161,9 +164,10 @@ func TestNotify(t *testing.T) { }, expMsg: map[string]interface{}{ "avatar_url": "https://grafana.com/static/assets/img/fav32.png", - "content": "I'm a custom template ", + "content": "", "embeds": []interface{}{map[string]interface{}{ - "color": 1.4037554e+07, + "color": 1.4037554e+07, + "description": "I'm a custom template ", "footer": map[string]interface{}{ "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + appVersion, @@ -229,9 +233,10 @@ func TestNotify(t *testing.T) { }, expMsg: map[string]interface{}{ "avatar_url": "{{ invalid } }}", - "content": "valid message", + "content": "", "embeds": []interface{}{map[string]interface{}{ - "color": 1.4037554e+07, + "color": 1.4037554e+07, + "description": "valid message", "footer": map[string]interface{}{ "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + appVersion, @@ -263,9 +268,10 @@ func TestNotify(t *testing.T) { }, expMsg: map[string]interface{}{ "avatar_url": "https://grafana.com/static/assets/img/fav32.png", - "content": "valid message", + "content": "", "embeds": []interface{}{map[string]interface{}{ - "color": 1.4037554e+07, + "color": 1.4037554e+07, + "description": "valid message", "footer": map[string]interface{}{ "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + appVersion, @@ -302,9 +308,10 @@ func TestNotify(t *testing.T) { }, expMsg: map[string]interface{}{ "avatar_url": "https://grafana.com/static/assets/img/fav32.png", - "content": "2 alerts are firing, 0 are resolved", + "content": "", "embeds": []interface{}{map[string]interface{}{ - "color": 1.4037554e+07, + "color": 1.4037554e+07, + "description": "2 alerts are firing, 0 are resolved", "footer": map[string]interface{}{ "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + appVersion, @@ -335,9 +342,10 @@ func TestNotify(t *testing.T) { }, }, expMsg: map[string]interface{}{ - "content": "**Firing**\n\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", + "content": "", "embeds": []interface{}{map[string]interface{}{ - "color": 1.4037554e+07, + "color": 1.4037554e+07, + "description": "**Firing**\n\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", "footer": map[string]interface{}{ "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + appVersion, @@ -353,7 +361,7 @@ func TestNotify(t *testing.T) { name: "Should truncate too long messages", settings: Config{ Title: templates.DefaultMessageTitleEmbed, - Message: strings.Repeat("Y", discordMaxMessageLen+rand.Intn(100)+1), + Message: strings.Repeat("Y", discordMaxDescriptionLen+rand.Intn(100)+1), AvatarURL: "", WebhookURL: "http://localhost", UseDiscordUsername: true, @@ -367,9 +375,10 @@ func TestNotify(t *testing.T) { }, }, expMsg: map[string]interface{}{ - "content": strings.Repeat("Y", discordMaxMessageLen-1) + "…", + "content": "", "embeds": []interface{}{map[string]interface{}{ - "color": 1.4037554e+07, + "color": 1.4037554e+07, + "description": strings.Repeat("Y", discordMaxDescriptionLen-1) + "…", "footer": map[string]interface{}{ "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + appVersion, @@ -471,9 +480,10 @@ func TestNotify_WithImages(t *testing.T) { }, }, expMsg: map[string]interface{}{ - "content": "**Firing**\n\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", + "content": "", "embeds": []interface{}{map[string]interface{}{ - "color": 1.4037554e+07, + "color": 1.4037554e+07, + "description": "**Firing**\n\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", "footer": map[string]interface{}{ "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + appVersion, @@ -511,9 +521,10 @@ func TestNotify_WithImages(t *testing.T) { }, }, expMsg: map[string]interface{}{ - "content": "**Firing**\n\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", + "content": "", "embeds": []interface{}{map[string]interface{}{ - "color": 1.4037554e+07, + "color": 1.4037554e+07, + "description": "**Firing**\n\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", "footer": map[string]interface{}{ "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + appVersion, @@ -559,7 +570,11 @@ func TestNotify_WithImages(t *testing.T) { }, }, expMsg: map[string]interface{}{ - "content": `**Firing** + "content": "", + "embeds": []interface{}{ + map[string]interface{}{ + "color": 1.4037554e+07, + "description": `**Firing** Value: [no value] Labels: @@ -578,9 +593,6 @@ Labels: Annotations: Silence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1 `, - "embeds": []interface{}{ - map[string]interface{}{ - "color": 1.4037554e+07, "footer": map[string]interface{}{ "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + appVersion, @@ -625,7 +637,11 @@ Silence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=aler }, }, expMsg: map[string]interface{}{ - "content": `**Firing** + "content": "", + "embeds": []interface{}{ + map[string]interface{}{ + "color": 1.4037554e+07, + "description": `**Firing** Value: [no value] Labels: @@ -642,9 +658,6 @@ Labels: Annotations: Silence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert2&matcher=lbl1%3Dval2 `, - "embeds": []interface{}{ - map[string]interface{}{ - "color": 1.4037554e+07, "footer": map[string]interface{}{ "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + appVersion, @@ -690,10 +703,11 @@ Silence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=aler }, }, expMsg: map[string]interface{}{ - "content": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = " + strings.Repeat("B", discordMaxTitleLen+1) + "\n - lbl1 = val1\nAnnotations:\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3D" + strings.Repeat("B", discordMaxTitleLen+1) + "&matcher=lbl1%3Dval1\n", + "content": "", "embeds": []interface{}{ map[string]interface{}{ - "color": 1.4037554e+07, + "color": 1.4037554e+07, + "description": "**Firing**\n\nValue: [no value]\nLabels:\n - alertname = " + strings.Repeat("B", discordMaxTitleLen+1) + "\n - lbl1 = val1\nAnnotations:\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3D" + strings.Repeat("B", discordMaxTitleLen+1) + "&matcher=lbl1%3Dval1\n", "footer": map[string]interface{}{ "icon_url": "https://grafana.com/static/assets/img/fav32.png", "text": "Grafana v" + appVersion, @@ -797,6 +811,9 @@ Silence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=aler return strings.Compare(a.Name(), b.Name()) }) + // The description will contain the rendered default message template for all 15 alerts. + // We don't check the exact content here, just that the embed structure is correct. + // Extract the description from the actual payload later for comparison. expEmbeds := []interface{}{ map[string]interface{}{ "color": 1.4037554e+07, @@ -854,6 +871,14 @@ Silence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=aler embeds, ok := payloadMap["embeds"] require.True(tt, ok) require.Len(tt, embeds, 10) + + // The first embed contains the description (message text). Remove it for structural comparison + // since the exact content depends on dynamically generated alert data. + embedsList := embeds.([]interface{}) + firstEmbed := embedsList[0].(map[string]interface{}) + require.NotEmpty(tt, firstEmbed["description"], "first embed should have a description") + delete(firstEmbed, "description") + require.Equal(tt, expEmbeds, embeds) }) } From 41901d4dd272f8d54c433d970691a7645494daa0 Mon Sep 17 00:00:00 2001 From: chernistry Date: Tue, 7 Apr 2026 07:17:26 +0300 Subject: [PATCH 2/2] fix: remove unused discordMaxMessageLen, note embed limit safety - Remove dead constant discordMaxMessageLen (no longer referenced after truncation switched to discordMaxDescriptionLen) - Add comment explaining why the 6000-char total embed limit is safe: title (256) + description (4096) + footer (~30) < 6000 --- receivers/discord/v1/discord.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/receivers/discord/v1/discord.go b/receivers/discord/v1/discord.go index ba867260d..26acf2e19 100644 --- a/receivers/discord/v1/discord.go +++ b/receivers/discord/v1/discord.go @@ -29,9 +29,10 @@ type discordEmbedType string const ( discordRichEmbed discordEmbedType = "rich" - discordMaxEmbeds = 10 - discordMaxMessageLen = 2000 + discordMaxEmbeds = 10 // https://discord.com/developers/docs/resources/message#embed-object-embed-limits + // Note: Discord's 6000-char total embed limit is not enforced here because + // title (256) + description (4096) + footer (~30) stays well under 6000. discordMaxTitleLen = 256 discordMaxDescriptionLen = 4096 )