Files
terdut-server/internal/api/stats.go
T
Niklas Ye 6235a2f737 Drill into a single alert from the stats page
The stats page could only say how many alerts fired. It now answers when
one alert fires, how often, and how fast it is picked up and fixed: each
row of the top-alerts list opens /stats/alert/{name}, with a timeline,
by-hour and by-weekday charts, time-to-resolve per occurrence and a table
of every occurrence linking to its incident. The overview gains a
weekday-by-hour heatmap and a few derived observations.

An occurrence is an incident, not an alert row. Alert rows are reused per
fingerprint and mutated in place, so they carry no history; incidents are
the only record of each time something fired and was answered. That means
/api/stats/alerts/top now counts incidents opened (filtered on
triggered_at) so the list and the drilldown agree. The response keeps name
and count and only adds fields, so existing clients keep working, but the
counts can differ from before.

No migration: the lookups use incidents_signature_idx. Label breakdown,
team and severity filters and period-over-period deltas from the design
draft are left for later.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-18 21:55:16 +02:00

476 lines
15 KiB
Go

package api
import (
"database/sql"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
func handleStatsAlerts(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "received_at", callerTeamIDs(r.Context()))
// COALESCE because SUM over zero rows is NULL, not 0, and a count of
// nothing is 0 — without it an empty window is a 500 rather than a
// legitimately empty report.
var total, firing, resolved int64
err := db.QueryRowContext(r.Context(), fmt.Sprintf(`
SELECT COUNT(*),
COALESCE(SUM(CASE WHEN status = 'firing' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END), 0)
FROM alerts WHERE %s`, where), args.all()...,
).Scan(&total, &firing, &resolved)
if err != nil {
serverError(w, r, err)
return
}
respond(w, http.StatusOK, map[string]int64{
"total": total,
"firing": firing,
"resolved": resolved,
})
}
}
// alertNameSQL is the alert-name half of an incident's signature (see
// incidentSignature: "name|stable,labels"). An occurrence of an alert is an
// incident, because alert rows are reused per fingerprint and carry no history.
const alertNameSQL = "split_part(signature, '|', 1)"
const sparkDays = 30
// handleStatsTop ranks alert names by how many incidents they opened in the
// window, with what a list row needs besides the count: when it last fired, how
// long it takes to fix, and a per-day series for the last sparkDays days.
func handleStatsTop(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
teams := callerTeamIDs(r.Context())
where, args := statsFilter(q, "triggered_at", teams)
limit := 10
if l := q.Get("limit"); l != "" {
var n int
fmt.Sscanf(l, "%d", &n)
if n > 0 && n <= 100 {
limit = n
}
}
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
SELECT %[1]s AS name, COUNT(*) AS cnt, MAX(triggered_at),
AVG(CASE WHEN resolved_at IS NOT NULL THEN resolved_at - triggered_at END)
FROM incidents
WHERE %[2]s
GROUP BY name
ORDER BY cnt DESC, name ASC
LIMIT %[3]s`, alertNameSQL, where, args.add(limit)), args.all()...)
if err != nil {
serverError(w, r, err)
return
}
defer rows.Close()
type entry struct {
Name string `json:"name"`
Count int64 `json:"count"`
LastSeen int64 `json:"last_seen"`
MeanResolve *float64 `json:"mean_resolve_seconds"`
Daily []int64 `json:"daily"`
}
result := []entry{}
names := []string{}
for rows.Next() {
var e entry
if err := rows.Scan(&e.Name, &e.Count, &e.LastSeen, &e.MeanResolve); err != nil {
serverError(w, r, err)
return
}
e.Daily = make([]int64, sparkDays)
result = append(result, e)
names = append(names, e.Name)
}
if err := rows.Err(); err != nil {
serverError(w, r, err)
return
}
// The sparkline always covers the last sparkDays days ending at ?to (or
// today), whatever ?from says, so the rows stay comparable.
end := time.Now().UTC().Truncate(24 * time.Hour)
if to, err := time.Parse("2006-01-02", q.Get("to")); err == nil {
end = to.UTC()
}
first := end.AddDate(0, 0, -(sparkDays - 1))
sq := url.Values{}
sq.Set("from", first.Format("2006-01-02"))
sq.Set("to", end.Format("2006-01-02"))
swhere, sargs := statsFilter(sq, "triggered_at", teams)
if len(names) > 0 {
swhere += " AND " + alertNameSQL + " = ANY(" + sargs.add(names) + ")"
srows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
SELECT %s AS name, ((triggered_at - %s) / 86400)::int AS day, COUNT(*)
FROM incidents
WHERE %s
GROUP BY name, day`, alertNameSQL, sargs.add(first.Unix()), swhere), sargs.all()...)
if err != nil {
serverError(w, r, err)
return
}
defer srows.Close()
idx := map[string]int{}
for i, e := range result {
idx[e.Name] = i
}
for srows.Next() {
var name string
var day int
var cnt int64
if err := srows.Scan(&name, &day, &cnt); err != nil {
serverError(w, r, err)
return
}
if day >= 0 && day < sparkDays {
result[idx[name]].Daily[day] = cnt
}
}
if err := srows.Err(); err != nil {
serverError(w, r, err)
return
}
}
respond(w, http.StatusOK, result)
}
}
// handleStatsHeatmap counts incidents by UTC weekday (0=Sunday) and hour:
// always 7 rows of 24 cells, so a client never has to fill gaps.
func handleStatsHeatmap(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "triggered_at", callerTeamIDs(r.Context()))
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
SELECT EXTRACT(DOW FROM to_timestamp(triggered_at) AT TIME ZONE 'UTC')::int,
EXTRACT(HOUR FROM to_timestamp(triggered_at) AT TIME ZONE 'UTC')::int,
COUNT(*)
FROM incidents
WHERE %s
GROUP BY 1, 2`, where), args.all()...)
if err != nil {
serverError(w, r, err)
return
}
defer rows.Close()
grid := make([][]int64, 7)
for d := range grid {
grid[d] = make([]int64, 24)
}
for rows.Next() {
var d, h int
var c int64
if err := rows.Scan(&d, &h, &c); err != nil {
serverError(w, r, err)
return
}
grid[d][h] = c
}
if err := rows.Err(); err != nil {
serverError(w, r, err)
return
}
respond(w, http.StatusOK, map[string]any{"grid": grid})
}
}
// maxOccurrences caps the drilldown's table; the aggregates still cover all.
const maxOccurrences = 200
// handleStatsAlert is the per-alert drilldown. Every figure is computed over
// the incidents that alert opened in the window, so it agrees with the top list.
func handleStatsAlert(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// chi hands back the still-escaped segment when the path has %2F in it.
name, err := url.PathUnescape(chi.URLParam(r, "name"))
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid alert name"))
return
}
where, args := statsFilter(r.URL.Query(), "i.triggered_at", callerTeamIDs(r.Context()))
where = strings.ReplaceAll(where, "archived_at", "i.archived_at")
where = strings.ReplaceAll(where, "team_id =", "i.team_id =")
where += " AND split_part(i.signature, '|', 1) = " + args.add(name)
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
SELECT i.id, i.triggered_at, i.acknowledged_at, i.resolved_at, i.status, i.severity,
COALESCE(u.username, sa.name)
FROM incidents i
LEFT JOIN users u ON u.id = i.acknowledged_by
LEFT JOIN service_accounts sa ON sa.id = i.acknowledged_by_service_account_id
WHERE %s
ORDER BY i.triggered_at DESC`, where), args.all()...)
if err != nil {
serverError(w, r, err)
return
}
defer rows.Close()
type occurrence struct {
IncidentID int64 `json:"incident_id"`
TriggeredAt int64 `json:"triggered_at"`
AckSeconds *int64 `json:"ack_seconds"`
ResolveSeconds *int64 `json:"resolve_seconds"`
Status string `json:"status"`
Severity *string `json:"severity,omitempty"`
AcknowledgedBy *string `json:"acknowledged_by,omitempty"`
}
all := []occurrence{}
for rows.Next() {
var o occurrence
var ack, res *int64
if err := rows.Scan(&o.IncidentID, &o.TriggeredAt, &ack, &res, &o.Status, &o.Severity, &o.AcknowledgedBy); err != nil {
serverError(w, r, err)
return
}
if ack != nil {
v := *ack - o.TriggeredAt
o.AckSeconds = &v
}
if res != nil {
v := *res - o.TriggeredAt
o.ResolveSeconds = &v
}
all = append(all, o)
}
if err := rows.Err(); err != nil {
serverError(w, r, err)
return
}
if len(all) == 0 {
respond(w, http.StatusNotFound, errResp("no incidents for this alert"))
return
}
byHour := make([]int64, 24)
byDay := make([]int64, 7)
var acks, fixes []int64
var longest int64
for _, o := range all {
t := time.Unix(o.TriggeredAt, 0).UTC()
byHour[t.Hour()]++
byDay[int(t.Weekday())]++
if o.AckSeconds != nil {
acks = append(acks, *o.AckSeconds)
}
if o.ResolveSeconds != nil {
fixes = append(fixes, *o.ResolveSeconds)
longest = max(longest, *o.ResolveSeconds)
}
}
// all is newest first, so the gap runs from the last row back to the first.
var meanGap *float64
if n := len(all); n > 1 {
g := float64(all[0].TriggeredAt-all[n-1].TriggeredAt) / float64(n-1)
meanGap = &g
}
stat := func(xs []int64) (mean, median *float64) {
if len(xs) == 0 {
return nil, nil
}
s := append([]int64(nil), xs...)
sort.Slice(s, func(i, j int) bool { return s[i] < s[j] })
var sum int64
for _, x := range s {
sum += x
}
m, md := float64(sum)/float64(len(s)), float64(s[len(s)/2])
return &m, &md
}
meanAck, medAck := stat(acks)
meanFix, medFix := stat(fixes)
occ := all
if len(occ) > maxOccurrences {
occ = occ[:maxOccurrences]
}
resp := map[string]any{
"name": name,
"count": len(all),
"first_seen": all[len(all)-1].TriggeredAt,
"last_seen": all[0].TriggeredAt,
"mean_gap_seconds": meanGap,
"mean_ack_seconds": meanAck,
"median_ack_seconds": medAck,
"mean_resolve_seconds": meanFix,
"median_resolve_seconds": medFix,
"longest_open_seconds": nil,
"by_hour": byHour,
"by_weekday": byDay,
"occurrences": occ,
}
if len(fixes) > 0 {
resp["longest_open_seconds"] = longest
}
respond(w, http.StatusOK, resp)
}
}
func handleStatsByHour(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "received_at", callerTeamIDs(r.Context()))
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
SELECT EXTRACT(HOUR FROM to_timestamp(received_at) AT TIME ZONE 'UTC')::int AS hr,
COUNT(*) AS cnt
FROM alerts
WHERE %s
GROUP BY hr
ORDER BY hr ASC`, where), args.all()...)
if err != nil {
serverError(w, r, err)
return
}
defer rows.Close()
counts := make(map[int]int64, 24)
for rows.Next() {
var hr int
var cnt int64
if err := rows.Scan(&hr, &cnt); err != nil {
serverError(w, r, err)
return
}
counts[hr] = cnt
}
if err := rows.Err(); err != nil {
serverError(w, r, err)
return
}
type entry struct {
Hour int `json:"hour"`
Count int64 `json:"count"`
}
result := make([]entry, 24)
for h := range result {
result[h] = entry{Hour: h, Count: counts[h]}
}
respond(w, http.StatusOK, result)
}
}
func handleStatsByDay(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "received_at", callerTeamIDs(r.Context()))
// Postgres EXTRACT(DOW …) → 0=Sunday … 6=Saturday.
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
SELECT EXTRACT(DOW FROM to_timestamp(received_at) AT TIME ZONE 'UTC')::int AS dow,
COUNT(*) AS cnt
FROM alerts
WHERE %s
GROUP BY dow
ORDER BY dow ASC`, where), args.all()...)
if err != nil {
serverError(w, r, err)
return
}
defer rows.Close()
counts := make(map[int]int64, 7)
for rows.Next() {
var dow int
var cnt int64
if err := rows.Scan(&dow, &cnt); err != nil {
serverError(w, r, err)
return
}
counts[dow] = cnt
}
if err := rows.Err(); err != nil {
serverError(w, r, err)
return
}
dayNames := [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
type entry struct {
Day int `json:"day"`
DayName string `json:"day_name"`
Count int64 `json:"count"`
}
result := make([]entry, 7)
for d := range result {
result[d] = entry{Day: d, DayName: dayNames[d], Count: counts[d]}
}
respond(w, http.StatusOK, result)
}
}
// handleStatsIncidents reports the queue and the two numbers a rota actually
// cares about: how long it takes someone to pick work up, and how long it takes
// to finish. Neither was computable before incidents existed — alert rows are
// mutated in place and carry no acknowledgement or closure time.
func handleStatsIncidents(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "triggered_at", callerTeamIDs(r.Context()))
// The counts are COALESCEd because SUM over zero rows is NULL, not 0.
// The averages are not: mtta and mttr stay null on purpose, since zero
// would read as "instant" rather than "nothing to measure yet".
var total, triggered, acknowledged, resolved int64
var mtta, mttr *float64
err := db.QueryRowContext(r.Context(), fmt.Sprintf(`
SELECT COUNT(*),
COALESCE(SUM(CASE WHEN status = 'triggered' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'acknowledged' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END), 0),
AVG(CASE WHEN acknowledged_at IS NOT NULL
THEN acknowledged_at - triggered_at END),
AVG(CASE WHEN resolved_at IS NOT NULL
THEN resolved_at - triggered_at END)
FROM incidents WHERE %s`, where), args.all()...,
).Scan(&total, &triggered, &acknowledged, &resolved, &mtta, &mttr)
if err != nil {
serverError(w, r, err)
return
}
respond(w, http.StatusOK, map[string]any{
"total": total,
"triggered": triggered,
"acknowledged": acknowledged,
"resolved": resolved,
// Null until something has actually been acknowledged or resolved —
// zero would read as "instant", which is a different claim.
"mtta_seconds": mtta,
"mttr_seconds": mttr,
})
}
}
// statsFilter builds a WHERE clause and args from optional ?from and ?to query
// params, filtering on timeCol. Archived rows are always excluded, matching the
// default list views.
//
// teamIDs scopes every figure to the caller's own teams: a report that counted
// other teams' incidents would leak their volume and their names through the
// top-alerts list, and would not be a number about the reader's work anyway.
func statsFilter(q url.Values, timeCol string, teamIDs []int64) (where string, args *sqlArgs) {
args = &sqlArgs{}
clauses := []string{"archived_at IS NULL", "team_id = ANY(" + args.add(teamIDs) + ")"}
if from := q.Get("from"); from != "" {
if t, err := time.Parse("2006-01-02", from); err == nil {
clauses = append(clauses, timeCol+" >= "+args.add(t.UTC().Unix()))
}
}
if to := q.Get("to"); to != "" {
if t, err := time.Parse("2006-01-02", to); err == nil {
clauses = append(clauses, timeCol+" < "+args.add(t.UTC().AddDate(0, 0, 1).Unix()))
}
}
return strings.Join(clauses, " AND "), args
}