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 }