List dead man's switches with their status on Team -> Switches

The page was a bare form: it did not say which switches existed or
whether they were alive. It now lists them, each with a Healthy, Dead or
Dormant badge, when its heartbeat was last heard and when it last opened
an incident (linked while that incident is open). A matcher that several
clusters satisfy is broken down per cluster, since a live cluster must
not hide a dead one. The form moved into a "New switch" sheet, and each
row has a Remove with a confirm.

That needed a switch to be a thing, so switches are rows now
(migration 009) with their own name, matcher, timeout and severity,
instead of one string with one team-wide timeout in deadman_configs.
Existing configuration is split into one row per matcher; a team whose
timeout was zero simply has none. The sweeper and the status endpoint
share one death rule (deadmanAlert.dead), so the page cannot disagree
with the pager. Incident group keys are unchanged, so incidents that
are open across the upgrade keep working.

The environment defaults (TERDUT_DEADMAN_*) are seeded into teams once
per install, recorded in settings, so a team that deletes its last
switch does not get it back on the next restart. Installs that already
had per-team rows are marked as seeded by the migration.

Removing a switch stops the watching but leaves an incident it already
opened open until someone resolves it.

API: GET/PUT /api/teams/{id}/deadman are replaced by
GET/POST /deadman/switches and DELETE /deadman/switches/{switchID}.
terdut-tui does not call them, so nothing to mirror there.
This commit is contained in:
Niklas Ye
2026-09-25 23:40:55 +02:00
parent 3ee8583f6f
commit f3918b863c
11 changed files with 868 additions and 313 deletions
+354 -174
View File
@@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"sort"
"strings"
@@ -39,6 +41,17 @@ func (m DeadmanMatcher) String() string {
return m.Name + " (" + strings.Join(parts, ", ") + ")"
}
// config renders the matcher in the form parseDeadmanMatcher reads, which is
// what a switch row stores: `alertname=Watchdog,cluster=prod`.
func (m DeadmanMatcher) config() string {
parts := make([]string, 0, len(m.Labels))
for k, v := range m.Labels {
parts = append(parts, k+"="+v)
}
sort.Strings(parts)
return strings.Join(append([]string{"alertname=" + m.Name}, parts...), ",")
}
// matches reports whether an alert's labels satisfy every condition.
func (m DeadmanMatcher) matches(labels map[string]string) bool {
if labels["alertname"] != m.Name {
@@ -52,12 +65,10 @@ func (m DeadmanMatcher) matches(labels map[string]string) bool {
return true
}
// DeadmanConfig inverts the handling of the alerts it matches: receiving one
// opens nothing, and the absence of one opens an incident.
//
// The unit of monitoring is the fingerprint, not the matcher — two clusters
// sending the same heartbeat alertname are two independent switches, so one
// healthy cluster cannot mask a dead one.
// DeadmanConfig is the server-wide default a team's switches are seeded from:
// the environment's matchers, timeout and severity. Switches themselves are rows
// of a team's own — see DeadmanSwitch — and this is only how a fresh install
// starts out.
type DeadmanConfig struct {
Matchers []DeadmanMatcher
@@ -76,41 +87,81 @@ type DeadmanConfig struct {
// enabled reports whether there is anything to watch.
func (c DeadmanConfig) enabled() bool { return c.Timeout > 0 && len(c.Matchers) > 0 }
// match returns the first matcher an alert satisfies.
func (c DeadmanConfig) match(labels map[string]string) (DeadmanMatcher, bool) {
if !c.enabled() {
return DeadmanMatcher{}, false
}
for _, m := range c.Matchers {
if m.matches(labels) {
return m, true
}
}
return DeadmanMatcher{}, false
// DeadmanSwitch inverts the handling of the alerts it matches: receiving one
// opens nothing, and the absence of one opens an incident.
//
// The unit of monitoring is the fingerprint, not the switch — two clusters
// sending the same heartbeat alertname are two independent heartbeats under one
// switch, so one healthy cluster cannot mask a dead one.
type DeadmanSwitch struct {
ID int64
Name string
Matcher DeadmanMatcher
// Timeout is how long a heartbeat may go unheard before it is declared dead.
Timeout time.Duration
// Severity is what the incident opens at.
Severity string
}
// isDeadman is match without the matcher, for the ingest path.
func (c DeadmanConfig) isDeadman(labels map[string]string) bool {
_, ok := c.match(labels)
// deadmanSet is one team's switches.
type deadmanSet []DeadmanSwitch
// match returns the first switch an alert satisfies.
func (d deadmanSet) match(labels map[string]string) (DeadmanSwitch, bool) {
for _, sw := range d {
if sw.Matcher.matches(labels) {
return sw, true
}
}
return DeadmanSwitch{}, false
}
// isDeadman is match without the switch, for the ingest path.
func (d deadmanSet) isDeadman(labels map[string]string) bool {
_, ok := d.match(labels)
return ok
}
// names lists the distinct alertnames worth loading from the database.
func (c DeadmanConfig) names() []string {
func (d deadmanSet) names() []string {
seen := map[string]bool{}
out := make([]string, 0, len(c.Matchers))
for _, m := range c.Matchers {
if !seen[m.Name] {
seen[m.Name] = true
out = append(out, m.Name)
out := make([]string, 0, len(d))
for _, sw := range d {
if !seen[sw.Matcher.Name] {
seen[sw.Matcher.Name] = true
out = append(out, sw.Matcher.Name)
}
}
return out
}
// parseDeadmanMatcher reads one matcher from its configured form: "," separates
// the conditions and "=" is exact label equality — `alertname=Watchdog,cluster=prod`.
// The error says what is wrong with it, in words a form can show.
func parseDeadmanMatcher(entry string) (DeadmanMatcher, error) {
m := DeadmanMatcher{Labels: map[string]string{}}
for _, cond := range strings.Split(strings.TrimSpace(entry), ",") {
k, v, ok := strings.Cut(cond, "=")
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
if !ok || k == "" || v == "" {
return DeadmanMatcher{}, fmt.Errorf("%q is not label=value", strings.TrimSpace(cond))
}
if k == "alertname" {
m.Name = v
continue
}
m.Labels[k] = v
}
if m.Name == "" {
return DeadmanMatcher{}, errors.New("no alertname condition")
}
return m, nil
}
// ParseDeadmanConfig reads the matcher list from its configured form:
// ";" separates matchers, "," separates the conditions within one, and "=" is
// exact label equality — `alertname=Watchdog,cluster=prod; alertname=Heartbeat`.
// ";" separates matchers, and each is parsed as parseDeadmanMatcher does.
//
// A malformed or alertname-less entry is dropped rather than fatal, following
// config.duration's rule that one bad tuning knob should not take the server
@@ -125,28 +176,9 @@ func ParseDeadmanConfig(matchers string, timeout time.Duration, severity string)
if entry == "" {
continue
}
m := DeadmanMatcher{Labels: map[string]string{}}
malformed := false
for _, cond := range strings.Split(entry, ",") {
k, v, ok := strings.Cut(cond, "=")
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
if !ok || k == "" || v == "" {
log.Printf("deadman: ignoring matcher %q: %q is not label=value", entry, strings.TrimSpace(cond))
malformed = true
break
}
if k == "alertname" {
m.Name = v
continue
}
m.Labels[k] = v
}
if malformed {
continue
}
if m.Name == "" {
log.Printf("deadman: ignoring matcher %q: no alertname condition", entry)
m, err := parseDeadmanMatcher(entry)
if err != nil {
log.Printf("deadman: ignoring matcher %q: %v", entry, err)
continue
}
cfg.Matchers = append(cfg.Matchers, m)
@@ -162,23 +194,35 @@ func ParseDeadmanConfig(matchers string, timeout time.Duration, severity string)
for _, m := range cfg.Matchers {
rendered = append(rendered, m.String())
}
log.Printf("deadman: watching %s, timeout %s, severity %s",
log.Printf("deadman: default for new teams: %s, timeout %s, severity %s",
strings.Join(rendered, "; "), timeout, severity)
}
return cfg
}
// deadmanAlert is one switch: the alert row carrying its last heartbeat.
// deadmanAlert is one heartbeat: the alert row carrying its last sighting, and
// the switch that claimed it.
type deadmanAlert struct {
id int64
teamID int64
fingerprint string
labels map[string]string
matcher DeadmanMatcher
sw DeadmanSwitch
resolved bool
receivedAt int64
}
// dead is the one rule for a silent heartbeat, shared by the sweeper that pages
// on it and the status the Switches page shows, so the page cannot disagree
// with the pager.
//
// An explicit resolved from Alertmanager is a stronger death signal than mere
// absence: the sender is telling us the heartbeat stopped, so there is nothing
// left to wait out.
func (a deadmanAlert) dead(now time.Time) bool {
return a.resolved || a.receivedAt < now.Add(-a.sw.Timeout).Unix()
}
// groupKey is the switch's identity as an incident. Per fingerprint, so each
// source is tracked on its own.
func (a deadmanAlert) groupKey() string { return deadmanGroupPrefix + a.fingerprint }
@@ -189,13 +233,13 @@ func (a deadmanAlert) groupKey() string { return deadmanGroupPrefix + a.fingerpr
// It returns the ids of the alerts it owns, because the generic staleness
// expiry must leave them alone — staleAfter and ends_at would otherwise resolve
// a heartbeat long before its own, much tighter, timeout ever fired.
// Each team is swept against its own configuration: its own matchers, its own
// timeout, its own severity. A team watching nothing is skipped entirely, which
// is most of them.
// Each team is swept against its own switches, each with its own matcher,
// timeout and severity. A team watching nothing is skipped entirely, which is
// most of them.
func sweepDeadman(ctx context.Context, db *sql.DB, notify NotifyConfig) map[int64]bool {
owned := map[int64]bool{}
configs, err := deadmanConfigs(ctx, db)
configs, err := deadmanSets(ctx, db)
if err != nil {
log.Printf("deadman: load configs: %v", err)
return owned
@@ -203,39 +247,35 @@ func sweepDeadman(ctx context.Context, db *sql.DB, notify NotifyConfig) map[int6
now := time.Now()
for teamID, cfg := range configs {
switches, err := deadmanAlerts(ctx, db, teamID, cfg)
heartbeats, err := deadmanAlerts(ctx, db, teamID, cfg)
if err != nil {
log.Printf("deadman: load switches for team %d: %v", teamID, err)
log.Printf("deadman: load heartbeats for team %d: %v", teamID, err)
continue
}
cutoff := now.Add(-cfg.Timeout).Unix()
for _, sw := range switches {
owned[sw.id] = true
for _, hb := range heartbeats {
owned[hb.id] = true
// An explicit resolved from Alertmanager is a stronger death signal
// than mere absence: the sender is telling us the heartbeat
// stopped, so there is nothing left to wait out.
if sw.resolved || sw.receivedAt < cutoff {
if err := deadmanDied(ctx, db, cfg, notify, sw, now); err != nil {
log.Printf("deadman: open incident for %s: %v", sw.matcher.Name, err)
if hb.dead(now) {
if err := deadmanDied(ctx, db, notify, hb, now); err != nil {
log.Printf("deadman: open incident for %s: %v", hb.sw.Matcher.Name, err)
}
continue
}
if err := deadmanRecovered(ctx, db, sw); err != nil {
log.Printf("deadman: resolve incident for %s: %v", sw.matcher.Name, err)
if err := deadmanRecovered(ctx, db, hb); err != nil {
log.Printf("deadman: resolve incident for %s: %v", hb.sw.Matcher.Name, err)
}
}
}
return owned
}
// deadmanAlerts loads every alert row that a matcher claims. The candidate query
// deadmanAlerts loads every alert row that one of a team's switches claims. The candidate query
// is narrowed by alertname so it rides alerts_name_idx; the rest of the matching
// happens in Go, which keeps one implementation of the rules. The rows are read
// in full before the caller writes, so the writes do not run against an open
// cursor over the same table.
func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg DeadmanConfig) ([]deadmanAlert, error) {
func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg deadmanSet) ([]deadmanAlert, error) {
names := cfg.names()
args := &sqlArgs{}
nameList := make([]any, len(names))
@@ -263,11 +303,11 @@ func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg DeadmanCon
}
json.Unmarshal([]byte(labelsJSON), &a.labels) //nolint:errcheck
m, ok := cfg.match(a.labels)
sw, ok := cfg.match(a.labels)
if !ok {
continue
}
a.matcher = m
a.sw = sw
a.resolved = status == "resolved"
out = append(out, a)
}
@@ -284,16 +324,16 @@ func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg DeadmanCon
// incidentForGroup), and a source that is gone for good is a one-time page
// rather than a nag. Only a heartbeat that comes back and dies again earns a new
// incident.
func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify NotifyConfig, sw deadmanAlert, now time.Time) error {
func deadmanDied(ctx context.Context, db *sql.DB, notify NotifyConfig, hb deadmanAlert, now time.Time) error {
var lastTriggered, open int64
if err := db.QueryRowContext(ctx, `
SELECT COALESCE(MAX(triggered_at), 0),
COUNT(*) FILTER (WHERE resolved_at IS NULL)
FROM incidents WHERE team_id = $1 AND group_key = $2`,
sw.teamID, sw.groupKey()).Scan(&lastTriggered, &open); err != nil {
hb.teamID, hb.groupKey()).Scan(&lastTriggered, &open); err != nil {
return err
}
if open > 0 || sw.receivedAt <= lastTriggered {
if open > 0 || hb.receivedAt <= lastTriggered {
return nil
}
@@ -306,18 +346,18 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
// A heartbeat nobody has heard from is not firing, and saying otherwise in
// the alert list would be a lie. An Alertmanager-sourced resolution keeps its
// own source: it told us the truth first.
if !sw.resolved {
if !hb.resolved {
if _, err := tx.ExecContext(ctx, `
UPDATE alerts
SET status = 'resolved',
resolution_source = $1,
ends_at = COALESCE(ends_at, `+nowEpoch+`)
WHERE id = $2 AND status = 'firing'`, resolutionDeadman, sw.id); err != nil {
WHERE id = $2 AND status = 'firing'`, resolutionDeadman, hb.id); err != nil {
return err
}
}
severity := cfg.Severity
severity := hb.sw.Severity
var sev *string
if severity != "" {
sev = &severity
@@ -325,14 +365,14 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
// The incident opens in the team whose integration received the heartbeat:
// the switch belongs to whoever is watching that source, not to the install.
incidentID, err := openIncident(ctx, tx, notify, sw.teamID, sw.groupKey(),
"No heartbeat from "+sw.matcher.String(), sw.labels, sev)
incidentID, err := openIncident(ctx, tx, notify, hb.teamID, hb.groupKey(),
"No heartbeat from "+hb.sw.Matcher.String(), hb.labels, sev)
if err != nil {
return err
}
alertID := sw.id
detail := "last heartbeat " + humanDuration(now.Sub(time.Unix(sw.receivedAt, 0))) + " ago"
alertID := hb.id
detail := "last heartbeat " + humanDuration(now.Sub(time.Unix(hb.receivedAt, 0))) + " ago"
if err := logEvent(ctx, tx, incidentID, evDeadmanSilent, nil, &alertID, &detail); err != nil {
return err
}
@@ -340,7 +380,7 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
if err := tx.Commit(); err != nil {
return err
}
log.Printf("deadman: %s went silent, opened incident %d", sw.matcher.String(), incidentID)
log.Printf("deadman: %s went silent, opened incident %d", hb.sw.Matcher.String(), incidentID)
return nil
}
@@ -350,12 +390,12 @@ func deadmanDied(ctx context.Context, db *sql.DB, cfg DeadmanConfig, notify Noti
// member alerts (linking the heartbeat would have the settled-incident cascade
// close it on the very same sweep that opened it), so the alert-driven cascade
// ignores it entirely and recovery is the only automatic way out.
func deadmanRecovered(ctx context.Context, db *sql.DB, sw deadmanAlert) error {
func deadmanRecovered(ctx context.Context, db *sql.DB, hb deadmanAlert) error {
var incidentID int64
switch err := db.QueryRowContext(ctx, `
SELECT id FROM incidents
WHERE team_id = $1 AND group_key = $2 AND resolved_at IS NULL`,
sw.teamID, sw.groupKey()).Scan(&incidentID); {
hb.teamID, hb.groupKey()).Scan(&incidentID); {
case err == sql.ErrNoRows:
return nil
case err != nil:
@@ -387,116 +427,256 @@ func deadmanRecovered(ctx context.Context, db *sql.DB, sw deadmanAlert) error {
if err := tx.Commit(); err != nil {
return err
}
log.Printf("deadman: %s is back, resolved incident %d", sw.matcher.String(), incidentID)
log.Printf("deadman: %s is back, resolved incident %d", hb.sw.Matcher.String(), incidentID)
return nil
}
// ---------------------------------------------------------------------------
// Per-team configuration
// A team's switches
// ---------------------------------------------------------------------------
// deadmanConfigForTeam reads one team's switches. A team with no row, or with
// nothing configured, gets a disabled config — which is the right answer rather
// than an error: most teams watch no heartbeat at all.
func deadmanConfigForTeam(ctx context.Context, q querier, teamID int64) (DeadmanConfig, error) {
var matchers, severity string
var timeout int64
err := q.QueryRowContext(ctx,
"SELECT matchers, timeout_seconds, severity FROM deadman_configs WHERE team_id = $1",
teamID).Scan(&matchers, &timeout, &severity)
if err == sql.ErrNoRows {
return DeadmanConfig{}, nil
}
if err != nil {
return DeadmanConfig{}, err
}
return parseDeadmanQuietly(matchers, time.Duration(timeout)*time.Second, severity), nil
}
const deadmanSwitchColumns = "id, team_id, name, matcher, timeout_seconds, severity"
// deadmanConfigs reads every team's switches in one query, for the sweeper.
func deadmanConfigs(ctx context.Context, db *sql.DB) (map[int64]DeadmanConfig, error) {
rows, err := db.QueryContext(ctx,
"SELECT team_id, matchers, timeout_seconds, severity FROM deadman_configs")
if err != nil {
return nil, err
}
// scanDeadmanSwitches reads switch rows into per-team sets. A row whose matcher
// no longer parses is skipped rather than fatal: the API refuses to store one,
// so it can only mean a hand edit, and one bad row must not stop the others
// from being watched.
func scanDeadmanSwitches(rows *sql.Rows) (map[int64]deadmanSet, error) {
defer rows.Close()
out := map[int64]DeadmanConfig{}
out := map[int64]deadmanSet{}
for rows.Next() {
var sw DeadmanSwitch
var teamID, timeout int64
var matchers, severity string
if err := rows.Scan(&teamID, &matchers, &timeout, &severity); err != nil {
var matcher string
if err := rows.Scan(&sw.ID, &teamID, &sw.Name, &matcher, &timeout, &sw.Severity); err != nil {
return nil, err
}
cfg := parseDeadmanQuietly(matchers, time.Duration(timeout)*time.Second, severity)
if cfg.enabled() {
out[teamID] = cfg
m, err := parseDeadmanMatcher(matcher)
if err != nil {
log.Printf("deadman: switch %d has an unusable matcher %q: %v", sw.ID, matcher, err)
continue
}
sw.Matcher = m
sw.Timeout = time.Duration(timeout) * time.Second
out[teamID] = append(out[teamID], sw)
}
return out, rows.Err()
}
// SeedDeadmanConfigs gives every team without a row the server's environment
// configuration, so the install that upgrades into per-team switches keeps
// watching exactly what it was watching before.
// deadmanSetForTeam reads one team's switches. A team with none gets an empty
// set — which is the right answer rather than an error: most teams watch no
// heartbeat at all.
func deadmanSetForTeam(ctx context.Context, q querier, teamID int64) (deadmanSet, error) {
rows, err := q.QueryContext(ctx,
"SELECT "+deadmanSwitchColumns+" FROM deadman_switches WHERE team_id = $1 ORDER BY id", teamID)
if err != nil {
return nil, err
}
sets, err := scanDeadmanSwitches(rows)
return sets[teamID], err
}
// deadmanSets reads every team's switches in one query, for the sweeper.
func deadmanSets(ctx context.Context, db *sql.DB) (map[int64]deadmanSet, error) {
rows, err := db.QueryContext(ctx,
"SELECT "+deadmanSwitchColumns+" FROM deadman_switches ORDER BY id")
if err != nil {
return nil, err
}
return scanDeadmanSwitches(rows)
}
// deadmanSeededKey is the settings row that records the environment defaults
// were handed out. Without it, a team that deleted its last switch would get
// the default back on the next restart.
const deadmanSeededKey = "deadman_seeded"
// SeedDeadmanConfigs gives every team the server's environment defaults as
// switches, exactly once per install, so a fresh install watches Watchdog
// without anybody setting it up.
//
// Idempotent, and never overwrites: once a team has a row it owns its own
// configuration, and a redeploy must not quietly put the environment's value
// back over an owner's edit.
// Once seeded it never runs again: a team's switches are its own, and a redeploy
// must not quietly put the environment's value back over an owner's edit or
// deletion. Installs that upgraded from per-team configuration were already
// seeded, which migration 009 records.
//
// A team created after startup gets no row and therefore watches nothing until
// its owner says otherwise. That is deliberate: inheriting an install-wide
// heartbeat would page a new team about a source it has never heard of, and a
// switch nobody chose is the kind that gets muted rather than fixed.
// A team created after that gets none and watches nothing until its owner says
// otherwise. That is deliberate: inheriting an install-wide heartbeat would page
// a new team about a source it has never heard of, and a switch nobody chose is
// the kind that gets muted rather than fixed.
func SeedDeadmanConfigs(ctx context.Context, db *sql.DB, cfg DeadmanConfig) error {
matchers := make([]string, 0, len(cfg.Matchers))
if !cfg.enabled() {
return nil
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck
res, err := tx.ExecContext(ctx,
"INSERT INTO settings (key, value) VALUES ($1, '1') ON CONFLICT (key) DO NOTHING",
deadmanSeededKey)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil
}
for _, m := range cfg.Matchers {
parts := []string{"alertname=" + m.Name}
for k, v := range m.Labels {
parts = append(parts, k+"="+v)
if _, err := tx.ExecContext(ctx, `
INSERT INTO deadman_switches (team_id, name, matcher, timeout_seconds, severity)
SELECT id, $1, $1, $2, $3 FROM teams`,
m.config(), int64(cfg.Timeout.Seconds()), cfg.Severity); err != nil {
return err
}
sort.Strings(parts[1:])
matchers = append(matchers, strings.Join(parts, ","))
}
_, err := db.ExecContext(ctx, `
INSERT INTO deadman_configs (team_id, matchers, timeout_seconds, severity)
SELECT id, $1, $2, $3 FROM teams
ON CONFLICT (team_id) DO NOTHING`,
strings.Join(matchers, "; "), int64(cfg.Timeout.Seconds()), cfg.Severity)
return err
return tx.Commit()
}
// parseDeadmanQuietly is ParseDeadmanConfig without the startup logging: a
// team's configuration is read on every sweep and every webhook, and logging it
// each time would bury everything else.
func parseDeadmanQuietly(matchers string, timeout time.Duration, severity string) DeadmanConfig {
cfg := DeadmanConfig{Timeout: timeout, Severity: severity}
for _, entry := range strings.Split(matchers, ";") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
m := DeadmanMatcher{Labels: map[string]string{}}
malformed := false
for _, cond := range strings.Split(entry, ",") {
k, v, ok := strings.Cut(cond, "=")
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
if !ok || k == "" || v == "" {
malformed = true
break
}
if k == "alertname" {
m.Name = v
continue
}
m.Labels[k] = v
}
if malformed || m.Name == "" {
continue
}
cfg.Matchers = append(cfg.Matchers, m)
}
return cfg
// ---------------------------------------------------------------------------
// Status
// ---------------------------------------------------------------------------
const (
switchHealthy = "healthy"
switchDead = "dead"
switchDormant = "dormant"
)
// deadmanSource is one heartbeat under a switch: a fingerprint that matched.
type deadmanSource struct {
Fingerprint string `json:"fingerprint"`
Labels map[string]string `json:"labels"`
Status string `json:"status"`
LastHeartbeatAt time.Time `json:"last_heartbeat_at"`
LastTriggeredAt *time.Time `json:"last_triggered_at"`
IncidentID *int64 `json:"incident_id"`
}
// deadmanSwitchStatus is a switch as the Switches page shows it.
type deadmanSwitchStatus struct {
ID int64 `json:"id"`
Name string `json:"name"`
Matcher string `json:"matcher"`
TimeoutSeconds int64 `json:"timeout_seconds"`
Severity string `json:"severity"`
// Status is dead when any source is, dormant when none has ever been heard
// from, healthy otherwise — a live cluster must not hide a dead one.
Status string `json:"status"`
LastHeartbeatAt *time.Time `json:"last_heartbeat_at"`
LastTriggeredAt *time.Time `json:"last_triggered_at"`
OpenIncidentID *int64 `json:"open_incident_id"`
Sources []deadmanSource `json:"sources"`
}
// deadmanStatuses reports every switch of a team with what its heartbeats are
// doing. The liveness verdict is deadmanAlert.dead, the sweeper's own.
func deadmanStatuses(ctx context.Context, db *sql.DB, teamID int64, set deadmanSet, now time.Time) ([]deadmanSwitchStatus, error) {
out := make([]deadmanSwitchStatus, 0, len(set))
if len(set) == 0 {
return out, nil
}
heartbeats, err := deadmanAlerts(ctx, db, teamID, set)
if err != nil {
return nil, err
}
// One query for every switch's incident history, keyed the way the sweeper
// keys it.
type history struct {
triggeredAt int64
openID int64
}
incidents := map[string]history{}
rows, err := db.QueryContext(ctx, `
SELECT group_key, MAX(triggered_at), COALESCE(MAX(id) FILTER (WHERE resolved_at IS NULL), 0)
FROM incidents
WHERE team_id = $1 AND group_key LIKE $2
GROUP BY group_key`, teamID, deadmanGroupPrefix+"%")
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var key string
var h history
if err := rows.Scan(&key, &h.triggeredAt, &h.openID); err != nil {
return nil, err
}
incidents[key] = h
}
if err := rows.Err(); err != nil {
return nil, err
}
bySwitch := map[int64][]deadmanAlert{}
for _, hb := range heartbeats {
bySwitch[hb.sw.ID] = append(bySwitch[hb.sw.ID], hb)
}
later := func(cur *time.Time, unix int64) *time.Time {
t := time.Unix(unix, 0).UTC()
if cur == nil || t.After(*cur) {
return &t
}
return cur
}
for _, sw := range set {
st := deadmanSwitchStatus{
ID: sw.ID, Name: sw.Name, Matcher: sw.Matcher.config(),
TimeoutSeconds: int64(sw.Timeout.Seconds()), Severity: sw.Severity,
Status: switchDormant, Sources: []deadmanSource{},
}
for _, hb := range bySwitch[sw.ID] {
src := deadmanSource{
Fingerprint: hb.fingerprint,
Labels: hb.labels,
Status: switchHealthy,
LastHeartbeatAt: time.Unix(hb.receivedAt, 0).UTC(),
}
if hb.dead(now) {
src.Status = switchDead
}
if h, ok := incidents[hb.groupKey()]; ok {
t := time.Unix(h.triggeredAt, 0).UTC()
src.LastTriggeredAt = &t
st.LastTriggeredAt = later(st.LastTriggeredAt, h.triggeredAt)
if h.openID != 0 {
id := h.openID
src.IncidentID = &id
if st.OpenIncidentID == nil || id > *st.OpenIncidentID {
st.OpenIncidentID = &id
}
}
}
st.LastHeartbeatAt = later(st.LastHeartbeatAt, hb.receivedAt)
st.Sources = append(st.Sources, src)
switch {
case src.Status == switchDead:
st.Status = switchDead
case st.Status == switchDormant:
st.Status = switchHealthy
}
}
// Dead ones first, then by fingerprint: what needs attention leads, and
// the order does not shuffle between refreshes.
sort.Slice(st.Sources, func(i, j int) bool {
a, b := st.Sources[i], st.Sources[j]
if (a.Status == switchDead) != (b.Status == switchDead) {
return a.Status == switchDead
}
return a.Fingerprint < b.Fingerprint
})
out = append(out, st)
}
return out, nil
}