feat(alert): summarize Grafana and Alertmanager payloads
Extract a readable title and severity from webhook bodies and keep the raw JSON available for the raw view.
This commit is contained in:
@@ -0,0 +1,139 @@
|
|||||||
|
// Package alert extracts a readable title and severity from Grafana and
|
||||||
|
// Alertmanager webhook bodies without hiding the raw payload.
|
||||||
|
package alert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TitleSeverity returns the list title and a severity/status string.
|
||||||
|
// An ntfy title wins; otherwise Grafana's title or alertname is used.
|
||||||
|
func TitleSeverity(ntfyTitle, body string) (string, string) {
|
||||||
|
title := strings.TrimSpace(ntfyTitle)
|
||||||
|
obj, ok := object(body)
|
||||||
|
sev := ""
|
||||||
|
if ok {
|
||||||
|
labels, _ := obj["commonLabels"].(map[string]any)
|
||||||
|
if title == "" {
|
||||||
|
title = strings.TrimSpace(str(obj["title"]))
|
||||||
|
if title == "" {
|
||||||
|
title = strings.TrimSpace(str(labels["alertname"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sev = strings.TrimSpace(str(obj["status"]))
|
||||||
|
if sev == "" {
|
||||||
|
sev = strings.TrimSpace(str(labels["severity"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
body = strings.TrimSpace(body)
|
||||||
|
if body == "" {
|
||||||
|
title = "(empty)"
|
||||||
|
} else {
|
||||||
|
title = strings.Split(body, "\n")[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return title, sev
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tone picks a color role. An explicit severity wins over ntfy priority.
|
||||||
|
func Tone(sev string, priority int) string {
|
||||||
|
s := strings.ToLower(sev)
|
||||||
|
switch {
|
||||||
|
case s == "firing" || s == "critical" || s == "error" || strings.Contains(s, "critical") || strings.Contains(s, "error"):
|
||||||
|
return "bad"
|
||||||
|
case s == "resolved" || s == "ok" || s == "success" || s == "info" || strings.Contains(s, "resolved"):
|
||||||
|
return "ok"
|
||||||
|
case s != "":
|
||||||
|
return "warn"
|
||||||
|
case priority >= 5:
|
||||||
|
return "bad"
|
||||||
|
case priority == 4:
|
||||||
|
return "warn"
|
||||||
|
case priority > 0 && priority < 3:
|
||||||
|
return "dim"
|
||||||
|
default:
|
||||||
|
return "base"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BodyView renders an alert webhook as text, or pretty JSON for other objects.
|
||||||
|
// raw forces pretty JSON when the body is JSON.
|
||||||
|
func BodyView(body string, raw bool) string {
|
||||||
|
obj, ok := object(body)
|
||||||
|
if !ok {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
if !raw && isAlert(obj) {
|
||||||
|
return readable(obj)
|
||||||
|
}
|
||||||
|
enc, err := json.MarshalIndent(obj, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return string(enc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func object(body string) (map[string]any, bool) {
|
||||||
|
var v any
|
||||||
|
if err := json.Unmarshal([]byte(body), &v); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
obj, ok := v.(map[string]any)
|
||||||
|
return obj, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAlert(obj map[string]any) bool {
|
||||||
|
_, labels := obj["commonLabels"]
|
||||||
|
_, alerts := obj["alerts"]
|
||||||
|
_, ann := obj["commonAnnotations"]
|
||||||
|
return labels || alerts || ann
|
||||||
|
}
|
||||||
|
|
||||||
|
func readable(obj map[string]any) string {
|
||||||
|
labels, _ := obj["commonLabels"].(map[string]any)
|
||||||
|
ann, _ := obj["commonAnnotations"].(map[string]any)
|
||||||
|
var b strings.Builder
|
||||||
|
writeKV(&b, "status", str(obj["status"]))
|
||||||
|
writeKV(&b, "alert", str(labels["alertname"]))
|
||||||
|
writeKV(&b, "severity", str(labels["severity"]))
|
||||||
|
writeKV(&b, "summary", str(ann["summary"]))
|
||||||
|
writeKV(&b, "description", str(ann["description"]))
|
||||||
|
alerts, _ := obj["alerts"].([]any)
|
||||||
|
if len(alerts) > 0 {
|
||||||
|
if b.Len() > 0 {
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
b.WriteString("alerts:\n")
|
||||||
|
for _, item := range alerts {
|
||||||
|
alert, _ := item.(map[string]any)
|
||||||
|
al, _ := alert["labels"].(map[string]any)
|
||||||
|
aa, _ := alert["annotations"].(map[string]any)
|
||||||
|
name := str(al["alertname"])
|
||||||
|
if name == "" {
|
||||||
|
name = "(alert)"
|
||||||
|
}
|
||||||
|
b.WriteString("- [" + str(alert["status"]) + "] " + name + "\n")
|
||||||
|
if s := str(aa["summary"]); s != "" {
|
||||||
|
b.WriteString(" summary: " + s + "\n")
|
||||||
|
}
|
||||||
|
if s := str(aa["description"]); s != "" {
|
||||||
|
b.WriteString(" description: " + s + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeKV(b *strings.Builder, k, v string) {
|
||||||
|
if v == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.WriteString(k + ": " + v + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func str(v any) string {
|
||||||
|
s, _ := v.(string)
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package alert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const grafana = `{
|
||||||
|
"status": "firing",
|
||||||
|
"title": "DiskFull",
|
||||||
|
"commonLabels": {"alertname": "DiskFull", "severity": "critical"},
|
||||||
|
"commonAnnotations": {"summary": "disk > 90", "description": "host a"},
|
||||||
|
"alerts": [
|
||||||
|
{"status": "firing", "labels": {"alertname": "DiskFull", "severity": "critical"}, "annotations": {"summary": "disk > 90"}}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
func TestTitleSeverityPrefersNtfyTitle(t *testing.T) {
|
||||||
|
title, sev := TitleSeverity("from ntfy", grafana)
|
||||||
|
if title != "from ntfy" || sev != "firing" {
|
||||||
|
t.Fatalf("%q %q", title, sev)
|
||||||
|
}
|
||||||
|
title, sev = TitleSeverity("", grafana)
|
||||||
|
if title != "DiskFull" || sev != "firing" {
|
||||||
|
t.Fatalf("%q %q", title, sev)
|
||||||
|
}
|
||||||
|
title, sev = TitleSeverity("", "plain body")
|
||||||
|
if title != "plain body" || sev != "" {
|
||||||
|
t.Fatalf("%q %q", title, sev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTone(t *testing.T) {
|
||||||
|
if Tone("firing", 3) != "bad" || Tone("resolved", 5) != "ok" || Tone("", 5) != "bad" || Tone("", 4) != "warn" || Tone("", 1) != "dim" {
|
||||||
|
t.Fatal(Tone("firing", 3), Tone("resolved", 5), Tone("", 5), Tone("", 4), Tone("", 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBodyView(t *testing.T) {
|
||||||
|
view := BodyView(grafana, false)
|
||||||
|
if !containsAll(view, "status: firing", "alert: DiskFull", "summary: disk > 90", "alerts:") {
|
||||||
|
t.Fatalf("%s", view)
|
||||||
|
}
|
||||||
|
raw := BodyView(grafana, true)
|
||||||
|
if !containsAll(raw, "\"commonLabels\"", "DiskFull") {
|
||||||
|
t.Fatalf("%s", raw)
|
||||||
|
}
|
||||||
|
pretty := BodyView(`{"a":1}`, false)
|
||||||
|
if !strings.Contains(pretty, "\"a\": 1") {
|
||||||
|
t.Fatalf("%s", pretty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAll(s string, parts ...string) bool {
|
||||||
|
for _, p := range parts {
|
||||||
|
if !strings.Contains(s, p) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user