Files
karma/internal/models/alert.go
2026-03-11 13:10:15 +00:00

325 lines
8.6 KiB
Go

package models
import (
"cmp"
"slices"
"strconv"
"time"
"github.com/cespare/xxhash/v2"
"github.com/fvbommel/sortorder"
"github.com/go-json-experiment/json/jsontext"
"github.com/prometheus/prometheus/model/labels"
"github.com/prymitive/karma/internal/config"
)
// AlertState encodes the state of an alert as a compact uint8.
type AlertState uint8
const (
// AlertStateUnprocessed means that Alertmanager notify didn't yet process it
// and AM doesn't know if alert is active or suppressed
AlertStateUnprocessed AlertState = iota
// AlertStateActive is the state in which we know that the alert should fire
AlertStateActive
// AlertStateSuppressed means that we know that alert is silenced or inhibited
AlertStateSuppressed
)
// AlertStateList exports all alert states so other packages can get this list
var AlertStateList = []AlertState{
AlertStateUnprocessed,
AlertStateActive,
AlertStateSuppressed,
}
var alertStateToString = [3]string{"unprocessed", "active", "suppressed"}
var alertStateFromString = map[string]AlertState{
"unprocessed": AlertStateUnprocessed,
"active": AlertStateActive,
"suppressed": AlertStateSuppressed,
}
func (s AlertState) String() string {
if int(s) < len(alertStateToString) {
return alertStateToString[s]
}
return "unprocessed"
}
func (s AlertState) MarshalJSON() ([]byte, error) {
return jsontext.AppendQuote(nil, s.String())
}
func (s *AlertState) UnmarshalJSON(data []byte) error {
unquoted, err := jsontext.AppendUnquote(nil, data)
if err != nil {
return err
}
*s = ParseAlertState(string(unquoted))
return nil
}
// MarshalText implements encoding.TextMarshaler so AlertState can be used as
// a JSON map key.
func (s AlertState) MarshalText() ([]byte, error) {
return []byte(s.String()), nil
}
// UnmarshalText implements encoding.TextUnmarshaler so AlertState can be used
// as a JSON map key.
func (s *AlertState) UnmarshalText(data []byte) error {
*s = ParseAlertState(string(data))
return nil
}
// AlertStateFromString looks up an AlertState by its string representation.
// Returns the state and true if found, or (AlertStateUnprocessed, false) otherwise.
func AlertStateFromString(s string) (AlertState, bool) {
v, ok := alertStateFromString[s]
return v, ok
}
// ParseAlertState converts a string to an AlertState.
func ParseAlertState(s string) AlertState {
if v, ok := alertStateFromString[s]; ok {
return v
}
return AlertStateUnprocessed
}
// OrderedLabel mirrors labels.Label for JSON serialization in the
// [{"name":"...","value":"..."}] format expected by the frontend.
type OrderedLabel struct {
Name string `json:"name"`
Value string `json:"value"`
}
// OrderedLabels is a slice of OrderedLabel used for JSON serialization.
// It preserves the display order configured via config.Config.Labels.Order.
type OrderedLabels []OrderedLabel
func (ol OrderedLabels) Get(name string) string {
for _, l := range ol {
if l.Name == name {
return l.Value
}
}
return ""
}
func (ol OrderedLabels) MarshalJSONTo(enc *jsontext.Encoder) error {
w := jsonWriter{enc: enc}
w.beginArray()
for _, l := range ol {
w.beginObject()
w.key("name")
w.str(l.Name)
w.key("value")
w.str(l.Value)
w.endObject()
}
w.endArray()
return w.err
}
// LabelsToOrderedLabels converts prometheus Labels to OrderedLabels sorted
// by the configured display order.
func LabelsToOrderedLabels(ls labels.Labels) OrderedLabels {
dl := make(OrderedLabels, 0, ls.Len())
ls.Range(func(l labels.Label) {
dl = append(dl, OrderedLabel{Name: l.Name, Value: l.Value})
})
slices.SortFunc(dl, CompareOrderedLabels)
return dl
}
// CompareOrderedLabels sorts display labels by the configured label order,
// then by name, then by value using natural sort.
func CompareOrderedLabels(a, b OrderedLabel) int {
ai, bi := -1, -1
for index, name := range config.Config.Labels.Order {
if a.Name == name {
ai = index
} else if b.Name == name {
bi = index
}
if ai >= 0 && bi >= 0 {
return cmp.Compare(ai, bi)
}
}
if ai != bi {
return cmp.Compare(bi, ai)
}
if a.Name == b.Name {
if sortorder.NaturalLess(a.Value, b.Value) {
return -1
}
if sortorder.NaturalLess(b.Value, a.Value) {
return 1
}
return 0
}
if sortorder.NaturalLess(a.Name, b.Name) {
return -1
}
if sortorder.NaturalLess(b.Name, a.Name) {
return 1
}
return 0
}
// LabelsFromMap creates a Labels from a map, adding only keys not already present.
func LabelsFromMap(m map[string]string) labels.Labels {
return labels.FromMap(m)
}
// LabelsSetIfMissing returns a new Labels with the given name/value added only if
// the name is not already present.
func LabelsSetIfMissing(ls labels.Labels, name, value string) labels.Labels {
if ls.Has(name) {
return ls
}
b := labels.NewBuilder(ls)
b.Set(name, value)
return b.Labels()
}
// Alert is vanilla alert + some additional attributes
// karma extends an alert object with:
// - Links map, it's generated from annotations if annotation value is an url
// it's pulled out of annotation map and returned under links field,
// karma UI used this to show links differently than other annotations
type Alert struct {
StartsAt time.Time `json:"startsAt"`
Fingerprint string `json:"-"`
GeneratorURL string `json:"-"`
Receiver string `json:"receiver"`
LabelsFP string `json:"id"`
contentFP string
Labels labels.Labels `json:"-"`
Annotations Annotations `json:"annotations"`
SilencedBy []string `json:"-"`
InhibitedBy []string `json:"-"`
Alertmanager []AlertmanagerInstance `json:"alertmanager"`
State AlertState `json:"state"`
}
// APIAlert is the JSON-serializable representation of Alert.
// Labels are converted to OrderedLabels for the frontend.
type APIAlert struct {
StartsAt time.Time `json:"startsAt"`
State string `json:"state"`
Receiver string `json:"receiver"`
LabelsFP string `json:"id"`
Annotations Annotations `json:"annotations"`
Labels OrderedLabels `json:"labels"`
Alertmanager []AlertmanagerInstance `json:"alertmanager"`
}
func (a APIAlert) MarshalJSONTo(enc *jsontext.Encoder) error {
w := jsonWriter{enc: enc}
a.marshalTo(&w)
return w.err
}
func (a *APIAlert) marshalTo(w *jsonWriter) {
w.beginObject()
w.key("startsAt")
w.time(a.StartsAt)
w.key("state")
w.str(a.State)
w.key("receiver")
w.str(a.Receiver)
w.key("id")
w.str(a.LabelsFP)
w.key("annotations")
w.beginArray()
for i := range a.Annotations {
a.Annotations[i].marshalTo(w)
}
w.endArray()
w.key("labels")
w.beginArray()
for _, l := range a.Labels {
w.beginObject()
w.key("name")
w.str(l.Name)
w.key("value")
w.str(l.Value)
w.endObject()
}
w.endArray()
w.key("alertmanager")
w.beginArray()
for i := range a.Alertmanager {
a.Alertmanager[i].marshalTo(w)
}
w.endArray()
w.endObject()
}
var seps = []byte{'\xff'}
// UpdateFingerprints will generate a new set of fingerprints for this alert
func (a *Alert) UpdateFingerprints() {
labelsHash := a.Labels.Hash()
a.LabelsFP = strconv.FormatUint(labelsHash, 16)
h := xxhash.New()
for _, a := range a.Annotations {
_, _ = h.WriteString(a.Name)
_, _ = h.Write(seps)
_, _ = h.WriteString(a.Value)
_, _ = h.Write(seps)
_, _ = h.WriteString(strconv.FormatBool(a.IsAction))
_, _ = h.Write(seps)
_, _ = h.WriteString(strconv.FormatBool(a.IsLink))
_, _ = h.Write(seps)
_, _ = h.WriteString(strconv.FormatBool(a.Visible))
_, _ = h.Write(seps)
}
_, _ = h.WriteString(strconv.FormatUint(labelsHash, 16))
_, _ = h.Write(seps)
_, _ = h.WriteString(a.StartsAt.Format(time.RFC3339))
_, _ = h.WriteString(a.State.String())
for _, am := range a.Alertmanager {
_, _ = h.WriteString(am.Fingerprint)
_, _ = h.Write(seps)
_, _ = h.WriteString(am.Name)
_, _ = h.Write(seps)
_, _ = h.WriteString(am.Cluster)
_, _ = h.Write(seps)
_, _ = h.WriteString(am.State.String())
_, _ = h.Write(seps)
_, _ = h.WriteString(am.StartsAt.Format(time.RFC3339))
_, _ = h.Write(seps)
_, _ = h.WriteString(am.Source)
_, _ = h.Write(seps)
for _, s := range am.SilencedBy {
_, _ = h.WriteString(s)
_, _ = h.Write(seps)
}
for _, s := range am.InhibitedBy {
_, _ = h.WriteString(s)
_, _ = h.Write(seps)
}
}
_, _ = h.WriteString(a.Receiver)
a.contentFP = strconv.FormatUint(h.Sum64(), 16)
}
// LabelsFingerprint is a checksum computed only from labels which should be
// unique for every alert
func (a *Alert) LabelsFingerprint() string {
return a.LabelsFP
}
// ContentFingerprint is a checksum computed from entire alert object
func (a *Alert) ContentFingerprint() string {
return a.contentFP
}