Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions internal/notifier/opsgenie.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package notifier

import (
"context"
"crypto/sha256"
"crypto/tls"
"errors"
"fmt"
Expand All @@ -36,6 +37,7 @@ type Opsgenie struct {

type OpsgenieAlert struct {
Message string `json:"message"`
Alias string `json:"alias,omitempty"`
Description string `json:"description"`
Details map[string]string `json:"details"`
}
Expand Down Expand Up @@ -67,8 +69,15 @@ func (s *Opsgenie) Post(ctx context.Context, event eventv1.Event) error {
}
details["severity"] = event.Severity

// Construct a stable alias for deduplication in Opsgenie.
// The alias is derived from the involved object's kind, namespace,
// name, and the event reason so that repeated alerts for the same
// source are deduplicated while different reasons create separate alerts.
alias := generateOpsgenieAlias(event)

payload := OpsgenieAlert{
Message: event.InvolvedObject.Kind + "/" + event.InvolvedObject.Name,
Alias: alias,
Description: event.Message,
Details: details,
}
Expand All @@ -91,3 +100,20 @@ func (s *Opsgenie) Post(ctx context.Context, event eventv1.Event) error {

return nil
}

// generateOpsgenieAlias creates a stable, deterministic alias string from
// the event's involved object and reason. Opsgenie uses the alias field to
// deduplicate alerts — alerts with the same alias are treated as the same
// incident instead of creating new pages. The alias is a SHA-256 hash
// (truncated to 64 chars) to stay within Opsgenie's 512-char alias limit
// while remaining collision-resistant.
func generateOpsgenieAlias(event eventv1.Event) string {
key := fmt.Sprintf("%s/%s/%s/%s",
event.InvolvedObject.Kind,
event.InvolvedObject.Namespace,
event.InvolvedObject.Name,
event.Reason,
Comment on lines +112 to +115
Copy link
Copy Markdown
Member

@stefanprodan stefanprodan Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will cause alerts from multiple clusters to aggregate under the same incident since all clusters have Kustomization/flux-system/flux-system, which is a major breaking change. Adding the Alert Provider UID to the checksum would ensure each cluster gets a dedicated incident.

)
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(key)))
return hash[:64]
}
100 changes: 100 additions & 0 deletions internal/notifier/opsgenie_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ package notifier

import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -69,3 +71,101 @@ func TestOpsgenie_Post(t *testing.T) {
})
}
}

func TestOpsgenie_PostAlias(t *testing.T) {
var receivedPayload OpsgenieAlert
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
json.Unmarshal(b, &receivedPayload)
}))
defer ts.Close()

tests := []struct {
name string
event func() v1beta1.Event
expectedAlias string
}{
{
name: "alias is set from involved object and reason",
event: testEvent,
expectedAlias: fmt.Sprintf("%x",
sha256.Sum256([]byte("GitRepository/gitops-system/webapp/reason")))[:64],
},
{
name: "alias is stable for same event",
event: func() v1beta1.Event {
e := testEvent()
e.Message = "different message should not change alias"
return e
},
expectedAlias: fmt.Sprintf("%x",
sha256.Sum256([]byte("GitRepository/gitops-system/webapp/reason")))[:64],
},
{
name: "alias differs for different reason",
event: func() v1beta1.Event {
e := testEvent()
e.Reason = "HealthCheckFailed"
return e
},
expectedAlias: fmt.Sprintf("%x",
sha256.Sum256([]byte("GitRepository/gitops-system/webapp/HealthCheckFailed")))[:64],
},
{
name: "alias differs for different namespace",
event: func() v1beta1.Event {
e := testEvent()
e.InvolvedObject.Namespace = "production"
return e
},
expectedAlias: fmt.Sprintf("%x",
sha256.Sum256([]byte("GitRepository/production/webapp/reason")))[:64],
},
{
name: "alias with empty metadata",
event: func() v1beta1.Event {
e := testEvent()
e.Metadata = nil
return e
},
expectedAlias: fmt.Sprintf("%x",
sha256.Sum256([]byte("GitRepository/gitops-system/webapp/reason")))[:64],
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
opsgenie, err := NewOpsgenie(ts.URL, "", nil, "token")
g.Expect(err).ToNot(HaveOccurred())

err = opsgenie.Post(context.TODO(), tt.event())
g.Expect(err).ToNot(HaveOccurred())
g.Expect(receivedPayload.Alias).To(Equal(tt.expectedAlias))
g.Expect(receivedPayload.Alias).ToNot(BeEmpty())
})
}
}

func TestGenerateOpsgenieAlias(t *testing.T) {
g := NewWithT(t)
event := testEvent()

// Alias should be deterministic
alias1 := generateOpsgenieAlias(event)
alias2 := generateOpsgenieAlias(event)
g.Expect(alias1).To(Equal(alias2))

// Alias should be 64 chars (hex-encoded SHA-256 truncated)
g.Expect(alias1).To(HaveLen(64))

// Different reason should produce different alias
event2 := testEvent()
event2.Reason = "DifferentReason"
alias3 := generateOpsgenieAlias(event2)
g.Expect(alias1).ToNot(Equal(alias3))
}