Files
terdut-server/internal/api/notifier.go
T
Niklas Ye b114251f45 Add notification channels and a provider abstraction
Users get a notification_channels table (replacing users.ntfy_topic); pages fan
out to every enabled channel through a notify.Provider registry, with ntfy as
the first provider. Fallbacks are typed {type, address}. Schema edited in place:
existing databases must be recreated.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-12 21:00:40 +02:00

674 lines
24 KiB
Go

package api
import (
"context"
"database/sql"
"fmt"
"log"
"regexp"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"git.ryuvia.com/niklas/terdut-server/internal/notify"
)
const (
// notifyInterval is how often the notifier looks for work. The archiver's
// 15 minute tick is far too coarse for something that has to wake a person.
notifyInterval = 30 * time.Second
// notifyRetryBase and notifyRetryMax bound the delivery backoff. A provider
// being briefly unreachable should not lose the page.
notifyRetryBase = 30 * time.Second
notifyRetryMax = 15 * time.Minute
// notifyMaxAttempts stops a permanently undeliverable row from being retried
// forever. It keeps last_error so the reason survives.
notifyMaxAttempts = 8
// notifyBatch caps one delivery pass, so a large backlog cannot hold the
// single database connection for an unbounded stretch.
notifyBatch = 100
// ackTokenTTL is how long the Acknowledge button in a notification keeps
// working. Past this the notification is stale enough that the responder
// should look at the incident rather than blind-acknowledge it.
ackTokenTTL = 24 * time.Hour
)
// notifierLockKey is the Postgres advisory lock the notifier takes for the
// duration of each pass, so that running more than one replica does not
// deliver (or double-deliver) the same notification from more than one of
// them at once. Its value has no meaning beyond being distinct from
// archiverLockKey.
const notifierLockKey int64 = 7265_0002
// Notification kinds, recording why a push was sent.
const (
notifyTriggered = notify.KindTriggered
notifyReminder = notify.KindReminder
notifyResolved = notify.KindResolved
// notifyEscalated is a page that went out because nobody answered the last
// one. Told apart from a reminder because it goes to somebody else.
notifyEscalated = notify.KindEscalated
)
// Timeline event types the notifier writes, so an incident's history says who
// was paged and whether the page landed. Written from the delivery result
// rather than at enqueue: a queued notification is an intention, and claiming
// somebody was told before the provider accepted it would be a lie the timeline
// keeps.
//
// The channel's address is deliberately absent from both; only its type is
// named. An address can be a shared secret (an ntfy topic lets anyone holding it
// publish to it) and the timeline is readable by every API key.
const (
eventNotified = "notified"
eventNotifyFailed = "notify_failed"
)
// NotifyConfig is everything the notifier needs to reach people and to build
// URLs they can follow back to this server.
type NotifyConfig struct {
// Providers are the ways this server can deliver a notification. Empty
// disables notifications entirely: no goroutine, and nothing is ever enqueued.
Providers *notify.Registry
// Fallback receives incidents that open with nobody on call who can be
// reached. Those notifications carry no Acknowledge button — there is no user
// to attribute the acknowledgement to, and putting one on a shared target
// would let any recipient acknowledge as somebody else.
Fallback notify.Target
// PublicURL is the base URL a phone uses to reach this server, for the
// notification's click target and its Acknowledge action. Without it a
// notification is informational only.
PublicURL string
// RepeatEvery is how long an incident may sit unacknowledged before it is
// notified again. Zero disables reminders.
RepeatEvery time.Duration
}
// enabled reports whether notifications are configured at all.
func (c NotifyConfig) enabled() bool { return !c.Providers.Empty() }
// StartNotifier delivers queued notifications until ctx is cancelled, starting
// with an immediate pass so a restart flushes whatever the last one left behind.
//
// Each pass runs under notifierLockKey (see withAdvisoryLock), so that on more
// than one replica only whichever instance's tick takes the lock first actually
// delivers; the rest skip that tick rather than racing the same pass.
func StartNotifier(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
if !cfg.enabled() {
log.Print("notifier: disabled (no notification provider configured)")
return
}
var types []string
for _, p := range cfg.Providers.List() {
types = append(types, p.Type())
}
log.Printf("notifier: delivering through %s", strings.Join(types, ", "))
ticker := time.NewTicker(notifyInterval)
defer ticker.Stop()
sweep := func() {
withAdvisoryLock(ctx, db, notifierLockKey, "notifier", func() {
NotifySweep(ctx, db, cfg)
})
}
sweep()
for {
select {
case <-ticker.C:
sweep()
case <-ctx.Done():
return
}
}
}
// NotifySweep runs a single pass: queue reminders for incidents nobody has
// picked up, then deliver everything that is due. Reminders are queued first so
// a freshly due one goes out in the same pass rather than a tick later.
// Exported so tests can drive a pass without waiting on the ticker.
func NotifySweep(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
enqueueReminders(ctx, db, cfg)
// Escalation before delivery, so a level that comes due on this tick is
// paged on this tick rather than waiting for the next one.
escalate(ctx, db, cfg)
deliverPending(ctx, db, cfg)
}
// enqueueReminders re-notifies incidents that are still sitting untouched.
//
// The stop conditions are the incident states that already mean "somebody has
// this": acknowledged, snoozed, resolved, archived. Snooze in particular is the
// mute button — a deliberate "not now" that should not keep buzzing — which is
// why there is no separate reminder cap.
//
// The previous notification must have actually been sent before another is
// queued, so a provider outage produces a retry backlog rather than a reminder
// backlog that all lands at once when it comes back. "Previous notification"
// is the incident's latest batch: everything one fan-out queued shares a
// created_at, and the batch counts as sent once any of its rows has landed.
//
// A channel the user has since disabled or removed is not reminded: that is the
// off switch for a channel, and a reminder that ignored it would defeat it.
func enqueueReminders(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
// cfg.RepeatEvery is what the server started with; the settings table is
// what it runs on. Read per tick, so an administrator lengthening the
// interval at 02:00 is obeyed at 02:00 and not at the next restart.
repeat := NewSettings(db).Duration(ctx, SettingNotifyRepeat, cfg.RepeatEvery)
if repeat <= 0 {
return
}
now := time.Now()
rows, err := db.QueryContext(ctx, `
SELECT n.incident_id, n.user_id, n.channel_id, n.channel_type, n.address
FROM notifications n
JOIN incidents i ON i.id = n.incident_id
LEFT JOIN notification_channels c ON c.id = n.channel_id
WHERE n.created_at = (SELECT MAX(created_at) FROM notifications WHERE incident_id = n.incident_id)
AND EXISTS (SELECT 1 FROM notifications s
WHERE s.incident_id = n.incident_id
AND s.created_at = n.created_at
AND s.sent_at IS NOT NULL)
AND n.created_at <= $1
AND i.resolved_at IS NULL
AND i.archived_at IS NULL
AND i.status = 'triggered'
AND (i.snoozed_until IS NULL OR i.snoozed_until <= $2)
-- A shared fallback target has no user and no channel row; everything
-- else must still point at an enabled channel.
AND (n.user_id IS NULL OR c.enabled)
-- A team with an escalation ladder gets escalation instead. Both
-- would mean two pages for one silence, which is how people learn to
-- mute a tool.
AND NOT EXISTS (
SELECT 1 FROM escalation_levels el WHERE el.team_id = i.team_id)
ORDER BY n.id`,
now.Add(-repeat).Unix(), now.Unix())
if err != nil {
log.Printf("notifier: find reminders: %v", err)
return
}
// Collected before inserting, rather than written while walking the cursor:
// the inserts below are what this query selects on, and a cursor reading its
// own writes is a hazard whatever the pool size.
byIncident := map[int64][]recipient{}
var order []int64
for rows.Next() {
var incidentID int64
var r recipient
if err := rows.Scan(&incidentID, &r.userID, &r.channelID, &r.typ, &r.address); err != nil {
rows.Close()
log.Printf("notifier: scan reminder: %v", err)
return
}
if _, seen := byIncident[incidentID]; !seen {
order = append(order, incidentID)
}
byIncident[incidentID] = append(byIncident[incidentID], r)
}
if err := rows.Err(); err != nil {
rows.Close()
log.Printf("notifier: find reminders: %v", err)
return
}
rows.Close()
// One batch per incident, so the reminder is itself a batch the next one
// can be measured from.
for _, id := range order {
if err := enqueueNotifications(ctx, db, id, byIncident[id], notifyReminder); err != nil {
log.Printf("notifier: queue reminder for incident %d: %v", id, err)
}
}
if len(order) > 0 {
log.Printf("notifier: queued reminders for %d incident(s)", len(order))
}
}
// outboxRow is one queued notification, read before any HTTP happens.
type outboxRow struct {
id int64
incidentID int64
userID *int64
channel string // channel type; the provider that delivers it
address string
kind string
attempts int
}
// deliverPending sends everything that is due and records the outcome.
func deliverPending(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
batch, err := pendingNotifications(ctx, db)
if err != nil {
log.Printf("notifier: find pending: %v", err)
return
}
sent := 0
for _, n := range batch {
if err := deliver(ctx, db, cfg, n); err != nil {
log.Printf("notifier: deliver %d (incident %d): %v", n.id, n.incidentID, err)
markFailed(ctx, db, n, err)
continue
}
if _, err := db.ExecContext(ctx,
"UPDATE notifications SET sent_at = $1, attempts = attempts + 1, last_error = NULL WHERE id = $2",
time.Now().Unix(), n.id); err != nil {
log.Printf("notifier: mark sent %d: %v", n.id, err)
}
// Logged, not returned: the page has already gone out, and treating a
// failed timeline write as a failed delivery would send it again.
detail := deliveryDetail(n)
if err := logEvent(ctx, db, n.incidentID, eventNotified, n.userID, nil, nil, &detail); err != nil {
log.Printf("notifier: log delivery of %d: %v", n.id, err)
}
sent++
}
if sent > 0 {
log.Printf("notifier: delivered %d notification(s)", sent)
}
}
// pendingNotifications reads the due rows and closes the cursor before the
// caller writes, for the same single-connection reason as staleAlertIDs.
func pendingNotifications(ctx context.Context, db *sql.DB) ([]outboxRow, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, incident_id, user_id, channel_type, address, kind, attempts
FROM notifications
WHERE sent_at IS NULL
AND send_after <= $1
AND attempts < $2
ORDER BY id
LIMIT $3`, time.Now().Unix(), notifyMaxAttempts, notifyBatch)
if err != nil {
return nil, err
}
defer rows.Close()
var batch []outboxRow
for rows.Next() {
var n outboxRow
if err := rows.Scan(&n.id, &n.incidentID, &n.userID, &n.channel, &n.address, &n.kind, &n.attempts); err != nil {
return nil, err
}
batch = append(batch, n)
}
return batch, rows.Err()
}
// markFailed bumps the attempt count and pushes the row out to its next retry.
//
// The attempt that exhausts the budget also writes a timeline event. Without it
// a page that never landed leaves the incident's history identical to one that
// did, which is the failure most worth seeing: nobody was told, and nothing
// says so.
func markFailed(ctx context.Context, db *sql.DB, n outboxRow, cause error) {
next := time.Now().Add(retryDelay(n.attempts)).Unix()
if _, err := db.ExecContext(ctx,
"UPDATE notifications SET attempts = attempts + 1, send_after = $1, last_error = $2 WHERE id = $3",
next, cause.Error(), n.id); err != nil {
log.Printf("notifier: mark failed %d: %v", n.id, err)
}
if n.attempts+1 < notifyMaxAttempts {
return
}
detail := fmt.Sprintf("%s: %s", deliveryDetail(n), cause)
if err := logEvent(ctx, db, n.incidentID, eventNotifyFailed, n.userID, nil, nil, &detail); err != nil {
log.Printf("notifier: log failure of %d: %v", n.id, err)
}
}
// deliveryDetail is what a timeline event says about a delivery: the kind and
// the channel type, "triggered via ntfy". Never the address.
func deliveryDetail(n outboxRow) string {
return n.kind + " via " + n.channel
}
// retryDelay doubles the wait per attempt, up to notifyRetryMax.
func retryDelay(attempts int) time.Duration {
d := notifyRetryBase << attempts
if d > notifyRetryMax || d <= 0 {
return notifyRetryMax
}
return d
}
// deliver renders one notification against the incident's *current* state and
// hands it to the provider its channel names. Rendering happens here rather than
// at enqueue time so a message that waited in the queue while its incident
// escalated goes out at the severity the incident has now.
func deliver(ctx context.Context, db *sql.DB, cfg NotifyConfig, n outboxRow) error {
// Looked up first: a row for a provider this server no longer has can never
// succeed, and there is no point rendering it.
provider, ok := cfg.Providers.Get(n.channel)
if !ok {
return fmt.Errorf("no %q provider is configured", n.channel)
}
inc, err := fetchIncident(ctx, db, n.incidentID)
if err != nil {
return fmt.Errorf("load incident: %w", err)
}
var firing int
if err := db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM incident_alerts ia
JOIN alerts a ON a.id = ia.alert_id
WHERE ia.incident_id = $1 AND a.status = 'firing'`, n.incidentID).Scan(&firing); err != nil {
return fmt.Errorf("count firing: %w", err)
}
msg := renderNotification(inc, n, firing, cfg)
// The page that opens an incident carries what fixed it last time, so the
// person woken up starts from that. Best effort: a failed lookup must not
// hold back the page itself.
if n.kind == notifyTriggered {
if sim, err := similarIncidents(ctx, db, n.incidentID, 1); err == nil && len(sim) > 0 && len(sim[0].ResolutionNotes) > 0 {
notes := sim[0].ResolutionNotes
msg.Body += "\nLast time: " + shorten(derefString(notes[len(notes)-1].Detail), 160)
}
}
// An Acknowledge control needs both a user to attribute the acknowledgement
// to and a URL the recipient can reach.
if n.kind != notifyResolved && n.userID != nil && cfg.PublicURL != "" {
base := strings.TrimSuffix(cfg.PublicURL, "/")
if provider.Capabilities().ActionButtons {
// Minted per delivery, so every push carries its own short-lived
// token rather than reusing one.
raw, err := issueAckToken(ctx, db, n.incidentID, *n.userID)
if err != nil {
return fmt.Errorf("issue ack token: %w", err)
}
msg.AckURL = base + "/api/notify/ack/" + raw
} else {
// No button to put it on, so the way to acknowledge travels as text.
// The token URL is POST-only and a link to it would do nothing, and
// minting one nobody can use is waste: this is the incident page,
// which has the Acknowledge control.
msg.Body += fmt.Sprintf("\nAcknowledge: %s/incidents/%d", base, n.incidentID)
}
}
return provider.Send(ctx, msg)
}
// renderNotification builds the provider-neutral message for one queued
// notification.
func renderNotification(inc models.Incident, n outboxRow, firing int, cfg NotifyConfig) notify.Message {
msg := notify.Message{Address: n.address, Kind: n.kind}
if cfg.PublicURL != "" {
// The web UI's page for the incident, so tapping the notification
// opens something a browser can use.
msg.ClickURL = fmt.Sprintf("%s/incidents/%d",
strings.TrimSuffix(cfg.PublicURL, "/"), inc.ID)
}
title := pageTitle(inc)
switch n.kind {
case notifyResolved:
msg.Title = "Resolved: " + title
msg.Body = "All alerts stopped firing after " +
humanDuration(time.Since(inc.TriggeredAt))
return msg
case notifyReminder:
msg.Title = "Still unacknowledged: " + title
default:
msg.Title = title
}
severity := derefString(inc.Severity)
parts := []string{fmt.Sprintf("%d alert%s firing", firing, plural(firing))}
if severity != "" {
parts = append(parts, "severity "+severity)
}
if assignee := derefString(inc.AssignedToUser); assignee != "" {
parts = append(parts, "on call: "+assignee)
}
if n.kind == notifyReminder {
parts = append(parts, "open "+humanDuration(time.Since(inc.TriggeredAt)))
}
msg.Body = strings.Join(parts, " · ")
msg.Level = severityRank(severity)
return msg
}
// originLabel is the label that says where an alert came from, for a team with
// several Kubernetes clusters behind it. It comes from Prometheus's
// externalLabels and reaches an incident through Alertmanager's group_by; the
// web UI reads the same label, and docs/incidents.md ("Several clusters, one team")
// explains how to set it up.
const originLabel = "cluster"
// pageTitle is the incident's title for a notification. A phone's lock screen
// cuts a long title off at the end, and the incident title puts the grouping
// labels there, so the cluster would be the first thing lost. When the incident
// has an origin it leads instead, "[prod-eu] PodRestarting (namespace=foo)", and
// is dropped from the parenthesis so it is not said twice. A title that is not
// in incidentTitle's "name (k=v, k=v)" shape keeps its text and gains the prefix.
func pageTitle(inc models.Incident) string {
origin := inc.GroupLabels[originLabel]
if origin == "" {
return inc.Title
}
return "[" + origin + "] " + titleWithoutLabel(inc.Title, originLabel, origin)
}
var titleShape = regexp.MustCompile(`(?s)^(.*?) \((.*)\)$`)
// titleWithoutLabel removes "key=value" from the parenthesised tail of a title
// built by incidentTitle, and the parentheses with it if nothing else is left.
func titleWithoutLabel(title, key, value string) string {
m := titleShape.FindStringSubmatch(title)
if m == nil {
return title
}
var rest []string
for _, part := range strings.Split(m[2], ", ") {
if part != key+"="+value {
rest = append(rest, part)
}
}
if len(rest) == 0 {
return m[1]
}
return m[1] + " (" + strings.Join(rest, ", ") + ")"
}
// recipient is one place a notification goes: a user's channel, or the shared
// fallback target. channelID and userID are nil for the fallback, which belongs
// to nobody. A batch may hold several users (an escalation level with more than
// one target), which is why the user is here and not on the batch.
type recipient struct {
userID *int64
channelID *int64
typ string
address string
}
// enqueueNotifications queues one row per recipient for a single notification.
//
// The rows share one created_at. That is what makes them a batch: the reminder
// and all-clear paths find "who was told last" as the rows carrying an
// incident's newest created_at, so a fan-out to three channels is reminded and
// resolved as one unit rather than as whichever row happened to land last.
func enqueueNotifications(ctx context.Context, q querier, incidentID int64, recipients []recipient, kind string) error {
now := time.Now().Unix()
for _, r := range recipients {
if _, err := q.ExecContext(ctx, `
INSERT INTO notifications (incident_id, user_id, channel_id, channel_type, address, kind, created_at, send_after)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)`,
incidentID, r.userID, r.channelID, r.typ, r.address, kind, now); err != nil {
return err
}
}
return nil
}
// userRecipients is the channels a notification to this user should go to: the
// ones they have enabled, of a type this server can deliver. A channel whose
// provider is not configured is skipped rather than queued, since it could only
// fail eight times and then sit in the table.
func userRecipients(ctx context.Context, q querier, cfg NotifyConfig, userID int64) ([]recipient, error) {
uid := userID
rows, err := q.QueryContext(ctx, `
SELECT id, type, address FROM notification_channels
WHERE user_id = $1 AND enabled
ORDER BY id`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []recipient
for rows.Next() {
var id int64
var r recipient
if err := rows.Scan(&id, &r.typ, &r.address); err != nil {
return nil, err
}
if !cfg.Providers.Has(r.typ) {
continue
}
r.channelID, r.userID = &id, &uid
out = append(out, r)
}
return out, rows.Err()
}
// notifyTarget decides where a newly opened incident's notification goes.
//
// The on-call user's own channels when they have any, otherwise the fallback
// target with no user attached. Deliberately not "the fallback, attributed to
// the on-call user": the fallback is shared, and an Acknowledge button on a
// shared target would let any recipient acknowledge as somebody else.
func notifyTarget(ctx context.Context, q querier, cfg NotifyConfig, onCall *int64) []recipient {
if onCall != nil {
if rs, err := userRecipients(ctx, q, cfg, *onCall); err == nil && len(rs) > 0 {
return rs
}
}
return fallbackRecipients(cfg, cfg.Fallback)
}
// fallbackRecipients turns a fallback target into the zero or one recipients it
// stands for. A target whose provider this server does not have is nobody.
func fallbackRecipients(cfg NotifyConfig, t notify.Target) []recipient {
if t.Address == "" || !cfg.Providers.Has(t.Type) {
return nil
}
return []recipient{{typ: t.Type, address: t.Address}}
}
// enqueueOpened queues the notification for a freshly opened incident. It is the
// only enqueue point that has to resolve recipients from scratch; every later
// notification for the incident reuses what this one chose.
func enqueueOpened(ctx context.Context, q querier, cfg NotifyConfig, incidentID int64, onCall *int64) error {
if !cfg.enabled() {
return nil
}
recipients := notifyTarget(ctx, q, cfg, onCall)
if len(recipients) == 0 {
// Nobody on call has a channel and there is no fallback: there is nowhere
// to send this, and queueing it would only accumulate undeliverable rows.
return nil
}
return enqueueNotifications(ctx, q, incidentID, recipients, notifyTriggered)
}
// enqueueResolved queues the all-clear, reusing the recipients of the incident's
// last batch. That needs no configuration to reach this function, and it gives
// the right rule for free: you only hear that something resolved if you were
// told it started. A channel since disabled or removed is not told.
func enqueueResolved(ctx context.Context, q querier, incidentID int64) error {
rows, err := q.QueryContext(ctx, `
SELECT n.user_id, n.channel_id, n.channel_type, n.address
FROM notifications n
LEFT JOIN notification_channels c ON c.id = n.channel_id
WHERE n.incident_id = $1
AND n.created_at = (SELECT MAX(created_at) FROM notifications WHERE incident_id = $1)
AND (n.user_id IS NULL OR c.enabled)
ORDER BY n.id`, incidentID)
if err != nil {
return err
}
var recipients []recipient
for rows.Next() {
var r recipient
if err := rows.Scan(&r.userID, &r.channelID, &r.typ, &r.address); err != nil {
rows.Close()
return err
}
recipients = append(recipients, r)
}
err = rows.Err()
rows.Close()
if err != nil || len(recipients) == 0 {
return err
}
return enqueueNotifications(ctx, q, incidentID, recipients, notifyResolved)
}
// humanDuration renders an age the way a person reads it at 3am: coarse, and
// never more than two units.
func humanDuration(d time.Duration) string {
if d < time.Minute {
return "less than a minute"
}
if d < time.Hour {
return fmt.Sprintf("%dm", int(d.Minutes()))
}
h := int(d.Hours())
m := int(d.Minutes()) - h*60
if m == 0 {
return fmt.Sprintf("%dh", h)
}
return fmt.Sprintf("%dh%dm", h, m)
}
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}
// shorten cuts s to at most n runes, marking the cut, and flattens newlines so
// a multi-line note stays one line in a push.
func shorten(s string, n int) string {
s = strings.Join(strings.Fields(s), " ")
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n-1]) + "…"
}
// derefString reads a nullable text column as a plain string.
func derefString(s *string) string {
if s == nil {
return ""
}
return *s
}