From 376eefe8ff697026583752a100c1e9415a03488f Mon Sep 17 00:00:00 2001 From: Artur Chopikian Date: Tue, 11 Aug 2026 10:24:57 +0300 Subject: [PATCH] Sort event metadata in notifications Notification fields are built by ranging over event.Metadata. Go randomises map iteration, so the same event produces a different field order on every send and the value being looked for moves between messages. Iterating slices.Sorted(maps.Keys(...)) makes the order stable and alphabetical. slices is already used in this package and the module is on Go 1.26, so no helper is needed. teams.go already does this for its Adaptive Card payload, sorting the built facts with slices.SortFunc. The guarantee existed but was not applied elsewhere, including to the deprecated connector payload in the same file. Applied to the notifiers whose output is read in order: slack, discord, rocket, teams, google_chat, zoom, webex, grafana, lark, matrix, telegram. The iterate-sorted-keys form is used throughout because five of them concatenate strings and cannot be sorted after the fact. Not applied to incidentio and sentry, where metadata goes into a map and order cannot be observed, nor to datadog and otel, where tags and attributes are sets. Signed-off-by: Artur Chopikian Assisted-by: claude-code/claude-opus-5 --- internal/notifier/discord.go | 5 +++- internal/notifier/google_chat.go | 5 +++- internal/notifier/grafana.go | 5 +++- internal/notifier/lark.go | 5 +++- internal/notifier/matrix.go | 5 +++- internal/notifier/rocket.go | 5 +++- internal/notifier/slack.go | 5 +++- internal/notifier/slack_test.go | 43 ++++++++++++++++++++++++++++++++ internal/notifier/teams.go | 4 ++- internal/notifier/telegram.go | 5 +++- internal/notifier/webex.go | 5 +++- internal/notifier/zoom.go | 5 +++- 12 files changed, 86 insertions(+), 11 deletions(-) diff --git a/internal/notifier/discord.go b/internal/notifier/discord.go index 932d07ec9..0651d5d56 100644 --- a/internal/notifier/discord.go +++ b/internal/notifier/discord.go @@ -19,8 +19,10 @@ package notifier import ( "context" "fmt" + "maps" "net/url" "path" + "slices" "strings" eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" @@ -71,7 +73,8 @@ func (s *Discord) Post(ctx context.Context, event eventv1.Event) error { } sfields := make([]SlackField, 0, len(event.Metadata)) - for k, v := range event.Metadata { + for _, k := range slices.Sorted(maps.Keys(event.Metadata)) { + v := event.Metadata[k] sfields = append(sfields, SlackField{k, v, false}) } diff --git a/internal/notifier/google_chat.go b/internal/notifier/google_chat.go index 74b33f5b4..69c2783ed 100644 --- a/internal/notifier/google_chat.go +++ b/internal/notifier/google_chat.go @@ -19,7 +19,9 @@ package notifier import ( "context" "fmt" + "maps" "net/url" + "slices" "strings" eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" @@ -114,7 +116,8 @@ func (s *GoogleChat) Post(ctx context.Context, event eventv1.Event) error { // Meta-Data if len(event.Metadata) > 0 { kvfields := make([]GoogleChatCardWidget, 0, len(event.Metadata)) - for k, v := range event.Metadata { + for _, k := range slices.Sorted(maps.Keys(event.Metadata)) { + v := event.Metadata[k] kvfields = append(kvfields, GoogleChatCardWidget{ KeyValue: &GoogleChatCardWidgetKeyValue{ TopLabel: k, diff --git a/internal/notifier/grafana.go b/internal/notifier/grafana.go index 279fa4876..d5080b20a 100644 --- a/internal/notifier/grafana.go +++ b/internal/notifier/grafana.go @@ -20,7 +20,9 @@ import ( "context" "crypto/tls" "fmt" + "maps" "net/url" + "slices" "strings" eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" @@ -65,7 +67,8 @@ func (g *Grafana) Post(ctx context.Context, event eventv1.Event) error { sfields := make([]string, 0, len(event.Metadata)) // add tag to filter on grafana sfields = append(sfields, "flux", event.ReportingController) - for k, v := range event.Metadata { + for _, k := range slices.Sorted(maps.Keys(event.Metadata)) { + v := event.Metadata[k] key := strings.ReplaceAll(k, ":", "|") value := strings.ReplaceAll(v, ":", "|") sfields = append(sfields, fmt.Sprintf("%s: %s", key, value)) diff --git a/internal/notifier/lark.go b/internal/notifier/lark.go index 0ba723051..4a499929b 100644 --- a/internal/notifier/lark.go +++ b/internal/notifier/lark.go @@ -3,7 +3,9 @@ package notifier import ( "context" "fmt" + "maps" "net/url" + "slices" "strings" eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" @@ -70,7 +72,8 @@ func (l *Lark) Post(ctx context.Context, event eventv1.Event) error { } message := fmt.Sprintf("**%s**\n\n", event.Message) - for k, v := range event.Metadata { + for _, k := range slices.Sorted(maps.Keys(event.Metadata)) { + v := event.Metadata[k] message = message + fmt.Sprintf("%s: %s\n", k, v) } diff --git a/internal/notifier/matrix.go b/internal/notifier/matrix.go index 07eabc098..b1ae5d298 100644 --- a/internal/notifier/matrix.go +++ b/internal/notifier/matrix.go @@ -6,8 +6,10 @@ import ( "crypto/tls" "encoding/json" "fmt" + "maps" "net/http" "net/url" + "slices" "strings" eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" @@ -53,7 +55,8 @@ func (m *Matrix) Post(ctx context.Context, event eventv1.Event) error { emoji = "🚨" } var metadata string - for k, v := range event.Metadata { + for _, k := range slices.Sorted(maps.Keys(event.Metadata)) { + v := event.Metadata[k] metadata = metadata + fmt.Sprintf("- %s: %s\n", k, v) } heading := fmt.Sprintf("%s %s/%s.%s", emoji, strings.ToLower(event.InvolvedObject.Kind), diff --git a/internal/notifier/rocket.go b/internal/notifier/rocket.go index 08d46944e..b8609b55b 100644 --- a/internal/notifier/rocket.go +++ b/internal/notifier/rocket.go @@ -20,7 +20,9 @@ import ( "context" "crypto/tls" "fmt" + "maps" "net/url" + "slices" "strings" eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" @@ -64,7 +66,8 @@ func (s *Rocket) Post(ctx context.Context, event eventv1.Event) error { } sfields := make([]SlackField, 0, len(event.Metadata)) - for k, v := range event.Metadata { + for _, k := range slices.Sorted(maps.Keys(event.Metadata)) { + v := event.Metadata[k] sfields = append(sfields, SlackField{k, v, false}) } diff --git a/internal/notifier/slack.go b/internal/notifier/slack.go index b235f16bb..fc3c42456 100644 --- a/internal/notifier/slack.go +++ b/internal/notifier/slack.go @@ -21,8 +21,10 @@ import ( "crypto/tls" "encoding/json" "fmt" + "maps" "net/http" "net/url" + "slices" "strings" eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" @@ -102,7 +104,8 @@ func (s *Slack) Post(ctx context.Context, event eventv1.Event) error { } sfields := make([]SlackField, 0, len(event.Metadata)) - for k, v := range event.Metadata { + for _, k := range slices.Sorted(maps.Keys(event.Metadata)) { + v := event.Metadata[k] sfields = append(sfields, SlackField{k, v, false}) } diff --git a/internal/notifier/slack_test.go b/internal/notifier/slack_test.go index f73abe66d..64e9d655a 100644 --- a/internal/notifier/slack_test.go +++ b/internal/notifier/slack_test.go @@ -66,3 +66,46 @@ func TestSlack_ValidateResponse(t *testing.T) { err = validateSlackResponse(resp.Result()) g.Expect(err).To(MatchError(ContainSubstring("Slack responded with error: too_many_attachments"))) } + +// Go randomises map iteration, so the fields shuffled between messages and the +// same four keys arrived in a different order every time. Ten posts of one event +// is enough for an unsorted map to disagree with itself. +func TestSlack_PostFieldsAreSorted(t *testing.T) { + g := NewWithT(t) + + var got [][]string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, err := io.ReadAll(r.Body) + g.Expect(err).ToNot(HaveOccurred()) + + var payload SlackPayload + g.Expect(json.Unmarshal(b, &payload)).To(Succeed()) + + keys := make([]string, 0, len(payload.Attachments[0].Fields)) + for _, f := range payload.Attachments[0].Fields { + keys = append(keys, f.Title) + } + got = append(got, keys) + })) + defer ts.Close() + + slack, err := NewSlack(ts.URL, "", "", nil, "", "test") + g.Expect(err).ToNot(HaveOccurred()) + + event := testEvent() + event.Metadata = map[string]string{ + "revision": "main/1234", + "cluster": "staging", + "image-tag": "v1.2.3", + "env": "staging", + } + + for i := 0; i < 10; i++ { + g.Expect(slack.Post(context.TODO(), event)).To(Succeed()) + } + + want := []string{"cluster", "env", "image-tag", "revision"} + for i, keys := range got { + g.Expect(keys).To(Equal(want), "post %d returned fields out of order", i) + } +} diff --git a/internal/notifier/teams.go b/internal/notifier/teams.go index 43ad99495..8af55db8f 100644 --- a/internal/notifier/teams.go +++ b/internal/notifier/teams.go @@ -20,6 +20,7 @@ import ( "context" "crypto/tls" "fmt" + "maps" "net/url" "slices" "strings" @@ -173,7 +174,8 @@ func (s *MSTeams) Post(ctx context.Context, event eventv1.Event) error { func buildMSTeamsDeprecatedConnectorPayload(event *eventv1.Event, objName string) *MSTeamsPayload { facts := make([]MSTeamsField, 0, len(event.Metadata)) - for k, v := range event.Metadata { + for _, k := range slices.Sorted(maps.Keys(event.Metadata)) { + v := event.Metadata[k] facts = append(facts, MSTeamsField{ Name: k, Value: v, diff --git a/internal/notifier/telegram.go b/internal/notifier/telegram.go index cad10e270..76be5e74c 100644 --- a/internal/notifier/telegram.go +++ b/internal/notifier/telegram.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "maps" "net/url" + "slices" "strings" eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" @@ -57,7 +59,8 @@ func (t *Telegram) Post(ctx context.Context, event eventv1.Event) error { heading := fmt.Sprintf("%s %s/%s/%s", emoji, strings.ToLower(event.InvolvedObject.Kind), event.InvolvedObject.Name, event.InvolvedObject.Namespace) var metadata string - for k, v := range event.Metadata { + for _, k := range slices.Sorted(maps.Keys(event.Metadata)) { + v := event.Metadata[k] metadata = metadata + fmt.Sprintf("\\- *%s*: %s\n", escapeString(k), escapeString(v)) } message := fmt.Sprintf("*%s*\n%s\n%s", escapeString(heading), escapeString(event.Message), metadata) diff --git a/internal/notifier/webex.go b/internal/notifier/webex.go index 65dd57893..92bb23374 100644 --- a/internal/notifier/webex.go +++ b/internal/notifier/webex.go @@ -20,7 +20,9 @@ import ( "context" "crypto/tls" "fmt" + "maps" "net/url" + "slices" "strings" eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" @@ -88,7 +90,8 @@ func (s *Webex) CreateMarkdown(event *eventv1.Event) string { fmt.Fprintf(&b, "%s\n", event.Message) if len(event.Metadata) > 0 { - for k, v := range event.Metadata { + for _, k := range slices.Sorted(maps.Keys(event.Metadata)) { + v := event.Metadata[k] fmt.Fprintf(&b, ">**%s**: %s\n", k, v) } } diff --git a/internal/notifier/zoom.go b/internal/notifier/zoom.go index 097614376..792bf9302 100644 --- a/internal/notifier/zoom.go +++ b/internal/notifier/zoom.go @@ -21,7 +21,9 @@ import ( "crypto/tls" "errors" "fmt" + "maps" "net/url" + "slices" "strings" eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" @@ -111,7 +113,8 @@ func (s *Zoom) Post(ctx context.Context, event eventv1.Event) error { if len(event.Metadata) > 0 { fields := make([]ZoomField, 0, len(event.Metadata)) - for k, v := range event.Metadata { + for _, k := range slices.Sorted(maps.Keys(event.Metadata)) { + v := event.Metadata[k] fields = append(fields, ZoomField{ Key: k, Value: v,