display notifications
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 2m2s

This commit is contained in:
mats committed 2026-09-23 10:58:42 +02:00
1 parent 0052822e49
commit 5d8bbcd756
5 files changed
+143 -1

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ func main() {
cfg := alerts.ConfigFromEnv() cfg := alerts.ConfigFromEnv()
notifiers := cfg.Notifiers() notifiers := cfg.Notifiers()
if len(notifiers) == 0 { if len(notifiers) == 0 {
fmt.Fprintln(os.Stderr, "no channel configured (TELEGRAM_KEY + TELEGRAM_CHATID, or OTEL_ALERT_WEBHOOK_URL)") fmt.Fprintln(os.Stderr, "no channel configured (Telegram, OTEL_ALERT_WEBHOOK_URL, or OTEL_ALERT_AOD_URL)")
os.Exit(2) os.Exit(2)
} }
now := time.Now().UTC() now := time.Now().UTC()
+93
View File
@@ -0,0 +1,93 @@
package alerts
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// AlwaysOnDisplay sends each alert to the session API used by the wall
// display. A session is created for the alert and then given a status and log
// entry so the display can show both the current incident and its details.
type AlwaysOnDisplay struct {
URL string
Client *http.Client
}
func (a *AlwaysOnDisplay) Name() string { return "always-on-display" }
type aodSession struct {
ID string `json:"id"`
}
func (a *AlwaysOnDisplay) Send(ctx context.Context, alert Alert) error {
client := a.Client
if client == nil {
client = &http.Client{Timeout: 15 * time.Second}
}
base := strings.TrimRight(a.URL, "/")
var session aodSession
name := fmt.Sprintf("%s alert: %s", strings.ToUpper(string(alert.Priority())), alert.Spike.Service)
if alert.Test {
name = "TEST alert: " + alert.Spike.Service
}
if err := postJSONDecode(ctx, client, base+"/api/sessions", nil, map[string]string{"name": name}, &session); err != nil {
return err
}
if session.ID == "" {
return fmt.Errorf("always-on-display: session response did not include an id")
}
status := firstLine(alert.Text(""))
if err := postJSON(ctx, client, base+"/api/sessions/"+session.ID+"/status", nil, map[string]string{"status": status}); err != nil {
return err
}
level := "info"
if alert.Priority() == PriorityHigh {
level = "error"
} else if alert.Priority() == PriorityMedium {
level = "warning"
}
return postJSON(ctx, client, base+"/api/sessions/"+session.ID+"/log", nil, map[string]string{
"level": level,
"message": alert.Text(""),
})
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
func postJSONDecode(ctx context.Context, client *http.Client, target string, headers map[string]string, body any, out any) error {
raw, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, strings.NewReader(string(raw)))
if err != nil {
return fmt.Errorf("always-on-display: invalid request")
}
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("always-on-display: request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("always-on-display: HTTP %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("always-on-display: invalid session response: %w", err)
}
return nil
}
+41
View File
@@ -0,0 +1,41 @@
package alerts
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.tornberg.me/go-otel/internal/features"
)
func TestAlwaysOnDisplayCreatesAndUpdatesSession(t *testing.T) {
var paths []string
var bodies []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
bodies = append(bodies, body)
if r.URL.Path == "/api/sessions" {
_, _ = w.Write([]byte(`{"id":"session-1"}`))
}
}))
defer srv.Close()
a := NewAlert(features.Spike{Service: "orders", ErrorCount: 5}, time.Now())
if err := (&AlwaysOnDisplay{URL: srv.URL}).Send(context.Background(), a); err != nil {
t.Fatal(err)
}
if strings.Join(paths, ",") != "/api/sessions,/api/sessions/session-1/status,/api/sessions/session-1/log" {
t.Fatalf("paths = %v", paths)
}
if bodies[0]["name"] != "LOW alert: orders" || bodies[1]["status"] == "" || bodies[2]["level"] != "info" {
t.Fatalf("bodies = %+v", bodies)
}
}
+5
View File
@@ -33,6 +33,7 @@ type Config struct {
// Muted, when set, reports issues a person ignored; their spikes never alert. // Muted, when set, reports issues a person ignored; their spikes never alert.
Muted func(issueID string) bool Muted func(issueID string) bool
WebhookURL, WebhookSecret string WebhookURL, WebhookSecret string
AODURL string
} }
func ConfigFromEnv() Config { func ConfigFromEnv() Config {
@@ -52,6 +53,7 @@ func ConfigFromEnv() Config {
TelegramThreadID: os.Getenv("OTEL_ALERT_TELEGRAM_THREAD_ID"), TelegramThreadID: os.Getenv("OTEL_ALERT_TELEGRAM_THREAD_ID"),
WebhookURL: os.Getenv("OTEL_ALERT_WEBHOOK_URL"), WebhookURL: os.Getenv("OTEL_ALERT_WEBHOOK_URL"),
WebhookSecret: os.Getenv("OTEL_ALERT_WEBHOOK_SECRET"), WebhookSecret: os.Getenv("OTEL_ALERT_WEBHOOK_SECRET"),
AODURL: os.Getenv("OTEL_ALERT_AOD_URL"),
} }
} }
@@ -88,6 +90,9 @@ func (c Config) Notifiers() []Notifier {
if c.WebhookURL != "" { if c.WebhookURL != "" {
out = append(out, &Webhook{URL: c.WebhookURL, Secret: c.WebhookSecret, BaseURL: c.BaseURL}) out = append(out, &Webhook{URL: c.WebhookURL, Secret: c.WebhookSecret, BaseURL: c.BaseURL})
} }
if c.AODURL != "" {
out = append(out, &AlwaysOnDisplay{URL: c.AODURL})
}
return out return out
} }
+3
View File
@@ -135,6 +135,9 @@ spec:
optional: true optional: true
- name: OTEL_ALERT_BASE_URL - name: OTEL_ALERT_BASE_URL
value: "https://otel.k6n.net" value: "https://otel.k6n.net"
# Optional Always-On Display session API.
- name: OTEL_ALERT_AOD_URL
value: "http://10.10.11.117:35851"
- name: OTEL_STORE_WORKERS - name: OTEL_STORE_WORKERS
value: "4" value: "4"
# Optional rollout observation. The watcher is read-only and uses the # Optional rollout observation. The watcher is read-only and uses the