display notifications
This commit is contained in:
1 parent
0052822e49
commit
5d8bbcd756
5 files changed
+143
-1
No files matched your search
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user