Files
terdut-server/internal/models/alert.go
T
Niklas Ye a602ff3efc Document received_at and resolution_source as public contract
The API reference listed endpoints but never the alert object's fields, so
two of them were load-bearing for clients while being described nowhere.
received_at appeared only in passing, as a stats filter; resolution_source
only inside the stale-expiry prose.

Both carry meaning a client cannot derive on its own. starts_at comes from
Prometheus and never changes for an alert instance, so received_at is the
only signal that a firing alert is still being refreshed — it advances on
every accepted webhook, including the unchanged notifications Alertmanager
re-sends every repeat_interval. resolution_source then says how much to
trust ends_at: under 'alertmanager' it is an end time somebody reported,
but under 'expiry' nothing ever reported one, so it is either a stale
watermark or the sweep timestamp, and only an upper bound.

README gains an alert object field table plus a contract section for each,
including the nullability rules and the advice to tolerate unrecognised
resolution_source values. The field comments in models.Alert now say these
are public API rather than ingest details, and the upsert carries a note at
the received_at line, which is where a regression would be introduced.

Three tests lock the newly documented behaviour, none of which was covered
before — the whole suite passed with the received_at bump deleted from the
upsert, because the expiry tests only ever set that column via SQL:

  - a re-send advances received_at and leaves starts_at alone
  - a discarded out-of-order retry does not count as a heartbeat
  - an expiry resolve preserves a reported ends_at watermark and stamps
    sweep time only when none was known
2026-07-30 09:02:44 +02:00

50 lines
2.3 KiB
Go

package models
import "time"
type Alert struct {
ID int64 `json:"id"`
Fingerprint string `json:"fingerprint"`
Name string `json:"name"`
Status string `json:"status"` // "firing" or "resolved"
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
StartsAt time.Time `json:"starts_at"`
EndsAt *time.Time `json:"ends_at,omitempty"`
GeneratorURL string `json:"generator_url"`
// ReceivedAt is when the server last accepted a webhook for this
// fingerprint, including the unchanged firing notifications Alertmanager
// re-sends every repeat_interval.
//
// This is a documented part of the public API, not an internal ingest
// detail: StartsAt never changes for an alert instance, so ReceivedAt is
// the only signal a client has that a firing alert is still being
// refreshed. The sweeper stale-dates against it (see expireStale), API
// clients render it, and GET /api/alerts is ordered by it. Anything that
// stops the webhook handler from advancing it on a re-send is a breaking
// change — see "received_at is a liveness heartbeat" in the README and
// TestWebhook_ResendBumpsReceivedAt.
ReceivedAt time.Time `json:"received_at"`
// Populated when the alert has been acknowledged.
AcknowledgedByID *int64 `json:"acknowledged_by_id,omitempty"`
AcknowledgedByUser *string `json:"acknowledged_by,omitempty"`
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
// ResolutionSource records why a resolved alert left the firing state:
// "alertmanager" for a real resolved webhook, "expiry" when the sweeper
// inferred it after the alert stopped being refreshed. Nil while firing, and
// cleared again by a re-fire under the same fingerprint.
//
// Also public API: it is how a client knows whether EndsAt was observed or
// inferred. Under "expiry" nothing ever reported an end, so EndsAt is only
// an upper bound (see expireStale) and ReceivedAt is the more truthful
// signal. Treat the value set as open — see "resolution_source says how much
// to trust ends_at" in the README, and TestWebhook_ResolvedSetsSource /
// TestExpiry_StaleFiringAlert.
ResolutionSource *string `json:"resolution_source,omitempty"`
ArchivedAt *time.Time `json:"archived_at,omitempty"`
}