6235a2f737
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
89 lines
2.6 KiB
Go
89 lines
2.6 KiB
Go
package api_test
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
func TestStatsTop_CountsIncidentsWithSparkAndMeans(t *testing.T) {
|
|
s := newTS(t)
|
|
red := newTeam(t, s, "red")
|
|
postToIntegration(t, s, red.key, "fp-1", "DiskFull")
|
|
postToIntegration(t, s, red.key, "fp-2", "DiskFull") // same group key: one incident
|
|
postToIntegration(t, s, red.key, "fp-3", "Memory")
|
|
|
|
top := list(t, red.call(http.MethodGet, "/api/stats/alerts/top", nil))
|
|
if len(top) != 2 {
|
|
t.Fatalf("expected 2 names, got %d", len(top))
|
|
}
|
|
for _, row := range top {
|
|
if row["count"].(float64) != 1 {
|
|
t.Errorf("%v: count %v, want 1 incident", row["name"], row["count"])
|
|
}
|
|
daily := row["daily"].([]any)
|
|
if len(daily) != 30 {
|
|
t.Fatalf("daily has %d days, want 30", len(daily))
|
|
}
|
|
if row["mean_resolve_seconds"] != nil {
|
|
t.Errorf("unresolved incident must have null mean, got %v", row["mean_resolve_seconds"])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStatsHeatmap_ShapeAndScope(t *testing.T) {
|
|
s := newTS(t)
|
|
red := newTeam(t, s, "red")
|
|
blue := newTeam(t, s, "blue")
|
|
postToIntegration(t, s, red.key, "fp-r", "RedOnly")
|
|
postToIntegration(t, s, blue.key, "fp-b", "BlueOnly")
|
|
|
|
var out struct {
|
|
Grid [][]int64 `json:"grid"`
|
|
}
|
|
decode(t, red.call(http.MethodGet, "/api/stats/alerts/heatmap", nil), &out)
|
|
if len(out.Grid) != 7 || len(out.Grid[0]) != 24 {
|
|
t.Fatalf("grid is %dx?, want 7x24", len(out.Grid))
|
|
}
|
|
var sum int64
|
|
for _, row := range out.Grid {
|
|
for _, c := range row {
|
|
sum += c
|
|
}
|
|
}
|
|
if sum != 1 {
|
|
t.Errorf("red's heatmap counted %d incidents, want 1", sum)
|
|
}
|
|
}
|
|
|
|
func TestStatsAlert_DrilldownAndScope(t *testing.T) {
|
|
s := newTS(t)
|
|
red := newTeam(t, s, "red")
|
|
blue := newTeam(t, s, "blue")
|
|
postToIntegration(t, s, red.key, "fp-r", "DiskFull")
|
|
postToIntegration(t, s, blue.key, "fp-b", "BlueOnly")
|
|
|
|
var d map[string]any
|
|
decode(t, red.call(http.MethodGet, "/api/stats/alerts/DiskFull", nil), &d)
|
|
if d["count"].(float64) != 1 {
|
|
t.Errorf("count = %v, want 1", d["count"])
|
|
}
|
|
if len(d["by_hour"].([]any)) != 24 || len(d["by_weekday"].([]any)) != 7 {
|
|
t.Errorf("by_hour/by_weekday have wrong lengths")
|
|
}
|
|
if occ := d["occurrences"].([]any); len(occ) != 1 {
|
|
t.Errorf("occurrences = %d, want 1", len(occ))
|
|
}
|
|
if d["mean_ack_seconds"] != nil || d["longest_open_seconds"] != nil {
|
|
t.Errorf("nothing acked or resolved yet, means must be null: %v", d)
|
|
}
|
|
|
|
// Another team's alert, and a name nobody has, are both a plain 404.
|
|
for _, name := range []string{"BlueOnly", "Nope"} {
|
|
resp := red.call(http.MethodGet, "/api/stats/alerts/"+name, nil)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Errorf("%s: expected 404, got %d", name, resp.StatusCode)
|
|
}
|
|
}
|
|
}
|