Files
terdut-server/internal/api/alertmanager.go
T
Niklas Ye 3183e7e5c5
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 16s
CI / test (pull_request) Successful in 2m21s
Page the next person when nobody answers
Closes #6, and closes the thing this whole line of work was opened for.
Until now an unacknowledged incident re-paged the same topic every
notify_repeat forever, which is a louder version of the same silence: if
the person on call is asleep, out of signal or has left the company,
nothing else happened.

A team can now configure an ordered ladder. Each level has a timeout and
a set of targets; a target is a named person or whoever the team's rota
says is on call today. That second kind is the one that keeps working
when the rota changes and nobody remembers to edit the policy. When a
level's timeout passes with the incident still triggered, the next level
is paged; off the end the chain repeats repeat_count times and then the
team's fallback topic is paged once. The incident stays open throughout,
because running out of people to wake is not somebody answering.

Escalation rides the notifier's existing 30-second tick and its outbox
rather than adding a second scheduler, and runs before delivery so a
level that comes due on a tick is paged on that tick. Each target gets
its own outbox row and therefore its own Acknowledge token: the button in
a notification must acknowledge as the person holding the phone, not as
whoever was paged first.

Acknowledging or resolving takes the incident off the ladder. Snoozing
pauses it -- a deliberate "not now" holds the ladder where it is and it
resumes when the snooze runs out, rather than carrying on without the
person who asked for quiet.

Reminders and escalation never both run. A team with a ladder gets
escalation; a team without keeps today's behaviour exactly. Both would
mean two pages for one silence, which is how a tool gets muted.

A level whose targets cannot be reached -- no topic, a disabled account,
an empty rota -- is entered anyway, recorded as "nobody reachable", and
the ladder moves on. Stalling on a rung that cannot ring would be the
failure this feature exists to prevent, wearing the feature's clothes. A
policy with such a level cannot be created, but an older row could hold
one.

The API replaces the ladder wholesale rather than patching a rung,
because the levels are an order: editing one has to answer what happens
to the numbering of the others, and a whole-ladder PUT makes that the
client's decision and the edit atomic.

Verified against a live server as well as in tests: alice paged, nobody
answers, bob paged, nobody answers, the fallback topic paged once and the
timeline reading "level 2: bob" then "escalation exhausted: paged
terdut-oncall-all" -- and a second incident acknowledged before its
timeout, which woke nobody else.

No UI yet. The team-settings screens for escalation, integrations and
dead man's switches are all still missing, and they are one piece of work
rather than three.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-20 18:37:54 +02:00

436 lines
15 KiB
Go

package api
import (
"context"
"database/sql"
"encoding/json"
"errors"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
// Values for alerts.resolution_source, recording why an alert left the firing
// state: a real Alertmanager notification, or inference by the sweeper.
const (
resolutionAlertmanager = "alertmanager"
resolutionExpiry = "expiry"
// resolutionDeadman marks a heartbeat the dead man's switch sweeper declared
// dead. Distinct from expiry because it is load-bearing, not just
// descriptive: it is the one resolution the ingest upsert will let a
// same-instance re-fire undo, so a switch that comes back can be heard.
resolutionDeadman = "deadman"
)
// amPayload mirrors the Alertmanager webhook v4 payload.
type amPayload struct {
Version string `json:"version"`
Status string `json:"status"`
// GroupKey and GroupLabels are how alerts get correlated into incidents.
// Alertmanager has already done the grouping work according to the group_by
// routing tree the operator configured, so we adopt its answer instead of
// inventing a second grouping scheme here.
GroupKey string `json:"groupKey"`
GroupLabels map[string]string `json:"groupLabels"`
Alerts []amAlert `json:"alerts"`
}
type amAlert struct {
Status string `json:"status"`
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"`
GeneratorURL string `json:"generatorURL"`
Fingerprint string `json:"fingerprint"`
}
// ingested records what actually happened to one alert of a payload, which is
// what decides whether an incident opens.
type ingested struct {
id int64
name string
firing bool
// newOccurrence marks an alert that transitioned *into* firing: a
// fingerprint we had never seen, a newer startsAt, or a resolved alert that
// started again. A repeat_interval re-send of an already-firing alert is
// none of these, which is what keeps a manually resolved incident closed.
newOccurrence bool
// justResolved marks the firing → resolved edge, worth a timeline entry.
justResolved bool
// deadman marks a heartbeat: an alert whose arrival means everything is
// fine. It is stored like any other alert — received_at is the heartbeat —
// but it never reaches an incident. Its absence is what opens one, which
// sweepDeadman decides later and elsewhere.
deadman bool
}
// handleIntegrationWebhook receives alerts on a team's own integration key.
// The key in the path is both the credential and the routing: it says who may
// post, and which team the alerts belong to.
func handleIntegrationWebhook(db *sql.DB, notify NotifyConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, err := teamIDForKey(r.Context(), db, chi.URLParam(r, "key"))
if err != nil {
if errors.Is(err, errUnknownIntegration) {
// 401 and not 404: the path is real, the key is not, and a
// sender misconfigured this way should say so in its own logs
// rather than believe it is delivering.
respond(w, http.StatusUnauthorized, errResp("unknown integration key"))
return
}
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
receiveWebhook(w, r, db, notify, teamID)
}
}
func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify NotifyConfig, teamID int64) {
var payload amPayload
if err := decodeJSON(r, &payload); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid payload"))
return
}
// Alertmanager retries anything that is not 2xx, and a retry of a payload
// we failed to store is more useful than an error it cannot act on — so
// failures are logged, not surfaced.
if err := ingest(r.Context(), db, notify, teamID, payload); err != nil {
log.Printf("webhook ingest (team %d, group %q): %v", teamID, payload.GroupKey, err)
}
w.WriteHeader(http.StatusOK)
}
// ingest stores a payload's alerts and reconciles the incident for its group.
// The whole payload is one transaction: an incident that opened but whose alerts
// failed to link would be a work item nobody could act on.
func ingest(ctx context.Context, db *sql.DB, notify NotifyConfig, teamID int64, payload amPayload) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck
// Which arriving alerts are heartbeats is the team's own answer, read
// inside the transaction so an owner editing it mid-payload cannot split
// one webhook across two interpretations.
deadman, err := deadmanConfigForTeam(ctx, tx, teamID)
if err != nil {
return err
}
accepted, err := upsertAlerts(ctx, tx, deadman, teamID, payload.Alerts)
if err != nil {
return err
}
// touched collects every incident this payload affected, so severity and the
// resolution cascade are recomputed once per incident at the end.
touched := map[int64]bool{}
incidentID, err := incidentForGroup(ctx, tx, notify, teamID, payload, accepted)
if err != nil {
return err
}
if incidentID != 0 {
touched[incidentID] = true
for _, a := range accepted {
if !a.firing || a.deadman {
continue
}
if err := linkAlert(ctx, tx, incidentID, a.id); err != nil {
return err
}
}
}
for _, a := range accepted {
if !a.justResolved || a.deadman {
continue
}
id, err := openIncidentForAlert(ctx, tx, a.id)
if err != nil {
return err
}
if id == 0 {
continue
}
touched[id] = true
alertID := a.id
if err := logEvent(ctx, tx, id, evAlertResolved, nil, &alertID, nil); err != nil {
return err
}
}
for id := range touched {
if err := refreshSeverity(ctx, tx, id); err != nil {
return err
}
if _, err := resolveIfSettled(ctx, tx, id); err != nil {
return err
}
}
return tx.Commit()
}
// upsertAlerts stores each alert of a payload and reports what changed. Payloads
// the ordering guard rejected are left out entirely.
func upsertAlerts(ctx context.Context, tx *sql.Tx, deadman DeadmanConfig, teamID int64, alerts []amAlert) ([]ingested, error) {
now := time.Now().Unix()
accepted := make([]ingested, 0, len(alerts))
for _, a := range alerts {
name := a.Labels["alertname"]
labelsJSON, _ := json.Marshal(a.Labels)
annotationsJSON, _ := json.Marshal(a.Annotations)
// The stored state has to be read before the upsert overwrites it: it is
// the only way to tell a genuine new occurrence from a re-send.
var prevStatus string
var prevStartsAt int64
existed := true
switch err := tx.QueryRowContext(ctx,
"SELECT status, starts_at FROM alerts WHERE team_id = $1 AND fingerprint = $2",
teamID, a.Fingerprint,
).Scan(&prevStatus, &prevStartsAt); {
case err == sql.ErrNoRows:
existed = false
case err != nil:
return nil, err
}
// Zero time ("0001-01-01T00:00:00Z") means "no end known" — that is the
// convention of Alertmanager's ingest API. Outgoing notifications
// normally carry a real future endsAt instead, which is the watermark
// the sweeper uses to expire alerts that stop being refreshed.
var endsAtUnix *int64
if a.EndsAt.Year() > 1 {
t := a.EndsAt.Unix()
endsAtUnix = &t
}
var resolutionSource *string
if a.Status == "resolved" {
s := resolutionAlertmanager
resolutionSource = &s
}
// The WHERE clause discards payloads that describe an alert instance
// older than the stored one. Alertmanager retries failed notifications,
// so a stale firing retry can arrive after the resolved one; it carries
// the same startsAt, whereas a genuine re-fire carries a newer one.
// Within a single instance, resolution is terminal — with one exception.
//
// A resolution this server synthesised for a dead man's switch is not
// Alertmanager's word that the instance ended; it is our inference from
// silence. The heartbeat that proves us wrong carries the unchanged
// startsAt of an alert that never stopped firing, so without the
// exemption a switch could go dead exactly once and never be heard from
// again. Scoped to 'deadman' so no resolution anybody else wrote can be
// undone by a stale retry.
if _, err := tx.ExecContext(ctx, `
INSERT INTO alerts
(team_id, fingerprint, name, status, labels, annotations, starts_at, ends_at,
generator_url, received_at, resolution_source)
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11)
ON CONFLICT (team_id, fingerprint) DO UPDATE SET
status = excluded.status,
labels = excluded.labels,
annotations = excluded.annotations,
starts_at = excluded.starts_at,
ends_at = excluded.ends_at,
generator_url = excluded.generator_url,
-- Load-bearing: advancing received_at on every accepted
-- payload, re-sends included, is the documented liveness
-- heartbeat clients and the sweeper both read. Removing it
-- is a breaking API change — see models.Alert.ReceivedAt.
received_at = excluded.received_at,
resolution_source = excluded.resolution_source,
-- A re-fire makes the alert current again, so it leaves the archive.
archived_at = CASE WHEN excluded.status = 'firing'
THEN NULL ELSE alerts.archived_at END
WHERE excluded.starts_at > alerts.starts_at
OR (excluded.starts_at = alerts.starts_at
AND (alerts.resolution_source = '`+resolutionDeadman+`'
OR NOT (alerts.status = 'resolved' AND excluded.status = 'firing')))`,
teamID, a.Fingerprint, name, a.Status,
string(labelsJSON), string(annotationsJSON),
a.StartsAt.Unix(), endsAtUnix,
a.GeneratorURL, now, resolutionSource,
); err != nil {
return nil, err
}
var id int64
var curStatus string
var curStartsAt int64
if err := tx.QueryRowContext(ctx,
"SELECT id, status, starts_at FROM alerts WHERE team_id = $1 AND fingerprint = $2",
teamID, a.Fingerprint,
).Scan(&id, &curStatus, &curStartsAt); err != nil {
return nil, err
}
// The upsert copies status and starts_at straight from the payload, so a
// row that does not match it is one the ordering guard rejected. A
// discarded payload describes a past instance and must not touch the
// incident state either.
if existed && (curStatus != a.Status || curStartsAt != a.StartsAt.Unix()) {
continue
}
firing := a.Status == "firing"
accepted = append(accepted, ingested{
id: id,
name: name,
firing: firing,
newOccurrence: firing && (!existed || a.StartsAt.Unix() > prevStartsAt || prevStatus == "resolved"),
justResolved: !firing && existed && prevStatus == "firing",
deadman: deadman.isDeadman(a.Labels),
})
}
return accepted, nil
}
// incidentForGroup returns the open incident that this payload's firing alerts
// belong to, opening one if the group has none. It returns 0 when the payload
// warrants no incident at all.
//
// The rule that matters: a group with no open incident gets a new one only if
// something actually started firing. Without that, a manually resolved incident
// would reappear on the next repeat_interval re-send of an alert that never
// stopped, and manual resolution would be meaningless.
//
// Heartbeats do not count as anything here. A group of nothing but dead man's
// switch alerts opens no incident at all, and a mixed group gets an incident for
// its real alerts only.
func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, teamID int64, payload amPayload, accepted []ingested) (int64, error) {
var firstName string
anyFiring, anyNew := false, false
for _, a := range accepted {
if a.deadman {
continue
}
if a.firing {
if !anyFiring {
firstName = a.name
}
anyFiring = true
}
if a.newOccurrence {
anyNew = true
}
}
if !anyFiring {
// A payload of nothing but resolutions never opens an incident.
return 0, nil
}
groupKey := payload.GroupKey
if groupKey == "" {
// Alertmanager always sends groupKey; a sender that does not still gets
// one incident per alert name rather than one giant shared incident.
groupKey = "groupless:" + firstName
}
var id int64
switch err := tx.QueryRowContext(ctx,
"SELECT id FROM incidents WHERE team_id = $1 AND group_key = $2 AND resolved_at IS NULL",
teamID, groupKey,
).Scan(&id); {
case err == nil:
return id, nil
case err != sql.ErrNoRows:
return 0, err
}
if !anyNew {
return 0, nil
}
return openIncident(ctx, tx, notify, teamID, groupKey,
incidentTitle(payload.GroupLabels, firstName), payload.GroupLabels, nil)
}
// openIncident creates an incident and assigns it to whoever is on call today,
// which is the point at which the schedule stops being decorative.
//
// The one place an incident is born, for both of the things that can raise one:
// the webhook, inside its transaction, and the dead man's switch sweeper, inside
// its own. Hence the querier rather than a *sql.Tx. A nil severity leaves the
// column for refreshSeverity to fill from the member alerts; the sweeper passes
// one because its incidents have no members to derive it from.
func openIncident(ctx context.Context, q querier, notify NotifyConfig, teamID int64, groupKey, title string, groupLabels map[string]string, severity *string) (int64, error) {
onCall, err := currentOnCall(ctx, q, teamID)
if err != nil {
return 0, err
}
labelsJSON, _ := json.Marshal(groupLabels)
if groupLabels == nil {
labelsJSON = []byte("{}")
}
var id int64
err = q.QueryRowContext(ctx, `
INSERT INTO incidents (team_id, group_key, title, group_labels, status, severity, triggered_at, assigned_to)
VALUES ($1, $2, $3, $4::jsonb, 'triggered', $5, $6, $7)
RETURNING id`,
teamID, groupKey, title, string(labelsJSON), severity,
time.Now().Unix(), onCall).Scan(&id)
if err != nil {
return 0, err
}
if err := logEvent(ctx, q, id, evTriggered, nil, nil, nil); err != nil {
return 0, err
}
if onCall != nil {
// On an "assigned" event user_id is the assignee, not the actor.
if err := logEvent(ctx, q, id, evAssigned, onCall, nil, nil); err != nil {
return 0, err
}
}
// Queue the page, but do not send it here: this runs inside the webhook's
// transaction, and an HTTP call would hold a connection open across a
// network round trip. The notifier picks the row up within a tick.
if err := enqueueOpened(ctx, q, notify, id, onCall); err != nil {
return 0, err
}
// And start the escalation clock, if the team keeps one. In the same
// transaction, so an incident is never briefly open with nobody counting.
if err := startEscalation(ctx, q, id, teamID); err != nil {
return 0, err
}
return id, nil
}
// linkAlert adds an alert to an incident, emitting a timeline entry only the
// first time. Re-sends of an already-linked alert are silent.
func linkAlert(ctx context.Context, tx *sql.Tx, incidentID, alertID int64) error {
res, err := tx.ExecContext(ctx, `
INSERT INTO incident_alerts (incident_id, alert_id, added_at)
VALUES ($1, $2, $3)
ON CONFLICT (incident_id, alert_id) DO NOTHING`, incidentID, alertID, time.Now().Unix())
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil
}
return logEvent(ctx, tx, incidentID, evAlertAdded, nil, &alertID, nil)
}