14c24f8fda
Release / release (push) Has been skipped
Release / build (amd64, linux) (push) Has been skipped
Release / build (arm64, darwin) (push) Has been skipped
Release / test (push) Failing after 5s
Release / build (arm64, linux) (push) Has been skipped
Release / docker (push) Has been skipped
Release / chart (push) Has been skipped
Release / build (amd64, darwin) (push) Has been skipped
Everything this server does assumes alerts arrive. If Prometheus stops evaluating, or Alertmanager cannot reach us, nothing arrives — and silence is indistinguishable from everything being fine. The cluster has shipped the alert for exactly this case all along: Watchdog is expr: vector(1), so it fires permanently and is re-sent forever, and it is worth nothing unless something downstream notices it stop. Nothing did. It arrived, opened no incident because a repeat_interval re-send is not a new occurrence, and when the monitoring stack died the sweeper quietly expired it and paged nobody. So the handling is inverted for a configurable set of alerts: receiving one opens no incident, and the absence of one does. TERDUT_DEADMAN_MATCHERS selects them as label matchers, defaulting to alertname=Watchdog. The unit of monitoring is the fingerprint rather than the alert name. Two clusters sending the same Watchdog are two independent switches, so a healthy one can never mask a dead one. Every matcher must name an alertname, which keeps the sweeper's candidate query on alerts_name_idx instead of JSON-extracting labels from every row, and leaves matching with a single implementation. A switch is dormant until its first heartbeat: a matcher nothing has ever sent opens nothing, so a fresh deploy or a restored database does not page. Resolving the incident by hand sticks, exactly as it does for an alert-backed one, so a decommissioned source is a one-time page rather than a nag; the switch re-arms only when the heartbeat comes back, and dying again is a new incident. The incident has no member alerts on purpose. Linking the heartbeat would have the settled-incident cascade close it on the very sweep that opened it, and there is no alert describing the problem anyway — the problem is that no alert arrived. What happened is on the timeline instead, and recovery is the only automatic way out. One narrow exemption in the ingest guard makes recovery possible at all. A heartbeat we declared dead is marked resolved, and the one that proves us wrong carries the unchanged startsAt of an alert that never stopped firing — so "resolution is terminal within an instance" would discard it forever and a switch could die exactly once. The exemption is scoped to resolution_source = 'deadman', which is the only resolution this server infers from silence on a timeout of its own, so nothing another writer set can be undone by a stale retry. Matched alerts are also held back from the generic staleness expiry, which would otherwise resolve a heartbeat as 'expiry' long before its own tighter deadline. The timeout points the opposite way to TERDUT_STALE_AFTER: staleness is a generous grace period around a repeat_interval you do not control, while this is a deadline you set deliberately and configure the heartbeat's route to beat. Inheriting a 4h or 12h repeat_interval gives a dead man's switch with a twelve hour fuse, so the README spells out the route the heartbeat needs.
301 lines
9.7 KiB
Go
301 lines
9.7 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/yeniklas/terdut-server/internal/models"
|
|
)
|
|
|
|
// Values for incidents.resolution_source, recording who closed the incident:
|
|
// every member alert stopped firing, or a person decided it was done.
|
|
const (
|
|
incidentResolutionAlerts = "alerts"
|
|
incidentResolutionManual = "manual"
|
|
|
|
// incidentResolutionRecovered closes a dead man's switch incident whose
|
|
// heartbeat started arriving again. It cannot be "alerts": these incidents
|
|
// have no member alerts for the cascade to work from.
|
|
incidentResolutionRecovered = "recovered"
|
|
)
|
|
|
|
// Incident timeline event types. Stored as free text so adding one later is not
|
|
// a migration, but these are the ones the server writes.
|
|
const (
|
|
evTriggered = "triggered"
|
|
evAlertAdded = "alert_added"
|
|
evAlertResolved = "alert_resolved"
|
|
evAcknowledged = "acknowledged"
|
|
evUnacknowledged = "unacknowledged"
|
|
evAssigned = "assigned"
|
|
evSnoozed = "snoozed"
|
|
evUnsnoozed = "unsnoozed"
|
|
evResolved = "resolved"
|
|
evNote = "note"
|
|
evDeadmanSilent = "deadman_silent"
|
|
)
|
|
|
|
// severityLabel is the Alertmanager label an incident's severity is derived from.
|
|
const severityLabel = "severity"
|
|
|
|
// querier is satisfied by both *sql.DB and *sql.Tx, so the helpers below work
|
|
// inside the webhook's transaction and standalone from handlers and the sweeper.
|
|
type querier interface {
|
|
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
|
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
|
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
|
}
|
|
|
|
const incidentSelectFrom = `
|
|
SELECT i.id, i.group_key, i.title, i.group_labels, i.status, i.severity,
|
|
i.triggered_at,
|
|
i.acknowledged_by, i.acknowledged_at, ack.username,
|
|
i.assigned_to, asg.username, i.snoozed_until,
|
|
i.resolved_at, i.resolution_source, i.archived_at
|
|
FROM incidents i
|
|
LEFT JOIN users ack ON ack.id = i.acknowledged_by
|
|
LEFT JOIN users asg ON asg.id = i.assigned_to`
|
|
|
|
func scanIncident(s scanner) (models.Incident, error) {
|
|
var i models.Incident
|
|
var groupLabelsJSON string
|
|
var triggeredAt int64
|
|
var ackAt, snoozedUntil, resolvedAt, archivedAt *int64
|
|
|
|
if err := s.Scan(
|
|
&i.ID, &i.GroupKey, &i.Title, &groupLabelsJSON, &i.Status, &i.Severity,
|
|
&triggeredAt,
|
|
&i.AcknowledgedByID, &ackAt, &i.AcknowledgedByUser,
|
|
&i.AssignedToID, &i.AssignedToUser, &snoozedUntil,
|
|
&resolvedAt, &i.ResolutionSource, &archivedAt,
|
|
); err != nil {
|
|
return i, err
|
|
}
|
|
|
|
json.Unmarshal([]byte(groupLabelsJSON), &i.GroupLabels) //nolint:errcheck
|
|
i.TriggeredAt = time.Unix(triggeredAt, 0).UTC()
|
|
i.AcknowledgedAt = unixPtr(ackAt)
|
|
i.SnoozedUntil = unixPtr(snoozedUntil)
|
|
i.ResolvedAt = unixPtr(resolvedAt)
|
|
i.ArchivedAt = unixPtr(archivedAt)
|
|
return i, nil
|
|
}
|
|
|
|
// unixPtr converts a nullable Unix-second column to a nullable UTC time.
|
|
func unixPtr(sec *int64) *time.Time {
|
|
if sec == nil {
|
|
return nil
|
|
}
|
|
t := time.Unix(*sec, 0).UTC()
|
|
return &t
|
|
}
|
|
|
|
func fetchIncident(ctx context.Context, q querier, id int64) (models.Incident, error) {
|
|
return scanIncident(q.QueryRowContext(ctx, incidentSelectFrom+" WHERE i.id = ?", id))
|
|
}
|
|
|
|
// logEvent appends one entry to an incident's timeline. A nil userID means the
|
|
// server acted rather than a person.
|
|
func logEvent(ctx context.Context, q querier, incidentID int64, evType string, userID, alertID *int64, detail *string) error {
|
|
_, err := q.ExecContext(ctx, `
|
|
INSERT INTO incident_events (incident_id, type, user_id, alert_id, detail, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
incidentID, evType, userID, alertID, detail, time.Now().Unix())
|
|
return err
|
|
}
|
|
|
|
// todayUTC is the schedule's day key. The schedule's smallest unit is one UTC day.
|
|
func todayUTC() string {
|
|
return time.Now().UTC().Format("2006-01-02")
|
|
}
|
|
|
|
// currentOnCall returns today's on-call user, or nil when nobody is scheduled.
|
|
// A missing schedule entry is not an error — incidents just open unassigned.
|
|
func currentOnCall(ctx context.Context, q querier) (*int64, error) {
|
|
var userID int64
|
|
err := q.QueryRowContext(ctx,
|
|
"SELECT user_id FROM schedule_entries WHERE date = ?", todayUTC()).Scan(&userID)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &userID, nil
|
|
}
|
|
|
|
// severityRank orders the conventional Alertmanager severity label values.
|
|
// Anything unrecognised sorts below all of them rather than being dropped.
|
|
func severityRank(s string) int {
|
|
switch strings.ToLower(s) {
|
|
case "critical":
|
|
return 4
|
|
case "error":
|
|
return 3
|
|
case "warning":
|
|
return 2
|
|
case "info":
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// refreshSeverity raises an incident's severity to the highest `severity` label
|
|
// seen across its alerts.
|
|
//
|
|
// It is a high-water mark, never lowered: an incident that hit critical was a
|
|
// critical incident, even after the critical alert clears and a warning is all
|
|
// that is left firing. Downgrading a live incident would also quietly demote it
|
|
// in the queue while the work is still open.
|
|
func refreshSeverity(ctx context.Context, q querier, incidentID int64) error {
|
|
rows, err := q.QueryContext(ctx, `
|
|
SELECT json_extract(a.labels, '$.'||?)
|
|
FROM incident_alerts ia
|
|
JOIN alerts a ON a.id = ia.alert_id
|
|
WHERE ia.incident_id = ?`, severityLabel, incidentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
best := ""
|
|
for rows.Next() {
|
|
var sev *string
|
|
if err := rows.Scan(&sev); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
if sev != nil && severityRank(*sev) > severityRank(best) {
|
|
best = *sev
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return err
|
|
}
|
|
rows.Close()
|
|
|
|
if best == "" {
|
|
return nil
|
|
}
|
|
// The comparison lives in SQL so an unrelated concurrent update cannot be
|
|
// clobbered by a stale read.
|
|
_, err = q.ExecContext(ctx, `
|
|
UPDATE incidents SET severity = ?
|
|
WHERE id = ?
|
|
AND (severity IS NULL OR `+severityRankSQL("severity")+` < ?)`,
|
|
best, incidentID, severityRank(best))
|
|
return err
|
|
}
|
|
|
|
// severityRankSQL mirrors severityRank for use inside a statement. SQL cannot
|
|
// order these strings meaningfully on its own.
|
|
func severityRankSQL(col string) string {
|
|
return `CASE lower(COALESCE(` + col + `, ''))
|
|
WHEN 'critical' THEN 4
|
|
WHEN 'error' THEN 3
|
|
WHEN 'warning' THEN 2
|
|
WHEN 'info' THEN 1
|
|
ELSE 0 END`
|
|
}
|
|
|
|
// resolveIfSettled closes an incident once every alert under it has stopped
|
|
// firing — PagerDuty's cascade, and the only automatic route out of the open
|
|
// state. Reports whether it actually resolved anything.
|
|
func resolveIfSettled(ctx context.Context, q querier, incidentID int64) (bool, error) {
|
|
res, err := q.ExecContext(ctx, `
|
|
UPDATE incidents
|
|
SET status = 'resolved',
|
|
resolved_at = ?,
|
|
resolution_source = ?
|
|
WHERE id = ?
|
|
AND resolved_at IS NULL
|
|
-- An incident with no members yet is mid-creation, not settled.
|
|
AND EXISTS (SELECT 1 FROM incident_alerts ia WHERE ia.incident_id = incidents.id)
|
|
AND NOT EXISTS (SELECT 1
|
|
FROM incident_alerts ia
|
|
JOIN alerts a ON a.id = ia.alert_id
|
|
WHERE ia.incident_id = incidents.id
|
|
AND a.status = 'firing')`,
|
|
time.Now().Unix(), incidentResolutionAlerts, incidentID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return false, nil
|
|
}
|
|
if err := logEvent(ctx, q, incidentID, evResolved, nil, nil, nil); err != nil {
|
|
return false, err
|
|
}
|
|
// The all-clear goes only to whoever was paged in the first place, which
|
|
// enqueueResolved works out from the incident's own notification history.
|
|
// Manual resolution sends nothing: the person who closed it already knows.
|
|
return true, enqueueResolved(ctx, q, incidentID)
|
|
}
|
|
|
|
// acknowledgeIncident records that userID has picked an incident up, and reports
|
|
// whether it changed anything — an already-resolved incident is left alone.
|
|
// Shared by the authenticated handler and the Acknowledge button in a push
|
|
// notification, so both write the same state and the same timeline entry.
|
|
func acknowledgeIncident(ctx context.Context, q querier, incidentID, userID int64) (bool, error) {
|
|
res, err := q.ExecContext(ctx, `
|
|
UPDATE incidents
|
|
SET status = 'acknowledged', acknowledged_by = ?, acknowledged_at = ?
|
|
WHERE id = ? AND resolved_at IS NULL`,
|
|
userID, time.Now().Unix(), incidentID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return false, nil
|
|
}
|
|
return true, logEvent(ctx, q, incidentID, evAcknowledged, &userID, nil, nil)
|
|
}
|
|
|
|
// openIncidentForAlert returns the open incident an alert currently belongs to,
|
|
// or 0 when it has none. Used when an alert resolves or expires so the event
|
|
// lands on the right timeline.
|
|
func openIncidentForAlert(ctx context.Context, q querier, alertID int64) (int64, error) {
|
|
var id int64
|
|
err := q.QueryRowContext(ctx, `
|
|
SELECT i.id
|
|
FROM incident_alerts ia
|
|
JOIN incidents i ON i.id = ia.incident_id
|
|
WHERE ia.alert_id = ? AND i.resolved_at IS NULL`, alertID).Scan(&id)
|
|
if err == sql.ErrNoRows {
|
|
return 0, nil
|
|
}
|
|
return id, err
|
|
}
|
|
|
|
// incidentTitle renders a human-readable title from Alertmanager's groupLabels,
|
|
// leading with the alert name and appending whatever else the operator grouped
|
|
// by. Falls back to the alert's own name when the payload carried no groupLabels.
|
|
func incidentTitle(groupLabels map[string]string, fallback string) string {
|
|
name := groupLabels["alertname"]
|
|
if name == "" {
|
|
name = fallback
|
|
}
|
|
if name == "" {
|
|
name = "Incident"
|
|
}
|
|
|
|
rest := make([]string, 0, len(groupLabels))
|
|
for k, v := range groupLabels {
|
|
if k == "alertname" {
|
|
continue
|
|
}
|
|
rest = append(rest, k+"="+v)
|
|
}
|
|
if len(rest) == 0 {
|
|
return name
|
|
}
|
|
sort.Strings(rest)
|
|
return name + " (" + strings.Join(rest, ", ") + ")"
|
|
}
|