Skip to content

Commit 80d863a

Browse files
committed
feat(anomalies): rewrite summaries for human-scannable text
Replace the mechanical anomaly template with natural-language summaries. Volume shifts show a multiplier ("3× normal traffic"), dimension spikes name the slice ("from country=DE jumped to 47"), and sigma is demoted to a parenthetical. Pitch status flipped to Shipped — pending PR. - Volume shift: "GET /search saw 3× normal traffic (240 vs ~80/day) (12.0σ above baseline)" - Dimension spike: "GET /items/:id from country=DE jumped to 47 (normally ~3/day) (8.1σ above baseline)" - Zero-mean fallback: "X saw N events (no prior baseline)" - 3 tests pinning both formats and the cold-start edge case
1 parent 7ca6ce9 commit 80d863a

3 files changed

Lines changed: 122 additions & 4 deletions

File tree

.doc/pitches/01a-noise-and-anomaly-curation.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*Make Beacon's data trustworthy by killing noise at the source and making anomalies actionable. Follow-up to pitch 01, cards 3 (anomaly detector), 4 (anomaly read endpoint + MCP tool), and 7 (anomaly dashboard page).*
44

55
**Appetite:** small batch (~3 days)
6-
**Status:** Building
6+
**Status:** Shipped — pending PR
77
**Owner:** Solo founder + AI producers
88
**Predecessor:** `pitches/01-ambient-anomalies.md` (shipped the anomaly detector, `beacon.anomalies` MCP tool, and `/anomalies` dashboard page)
99
**Related:** `definition/06-http-api.md` (MCP server contract, `/api/anomalies` endpoint), `definition/03-data-model.md` (anomaly records in `beacon_metrics`), `decisions/0002-maket-first-integration.md` (Maket as proof point)
@@ -133,4 +133,4 @@ Cards are in dependency order. Cards 1-3 are P0. Cards 4-5 are P1.
133133

134134
- [x] **Anomaly dismiss** — add `dismissed_at` column to anomaly records in `beacon_metrics`. Dashboard: "×" button on anomaly cards, htmx DELETE to `/anomalies/:id/dismiss`. HTTP API: `DELETE /api/anomalies/:id`. `GetAnomalies` excludes dismissed records. *Done when:* dismissing an anomaly in the dashboard removes it from the list, a dismissed anomaly stays gone across page refreshes and API queries, and `DELETE /api/anomalies/:id` returns 200 with subsequent `GET /api/anomalies` excluding it.
135135

136-
- [ ] **Smarter anomaly summaries** — rewrite the summary template in the anomaly detector to produce human-scannable text. Volume shifts: "X saw N× normal traffic (current vs ~baseline/day)". Dimension spikes: "X from dimension=value jumped to N (normally ~baseline/day)". σ as parenthetical. *Done when:* new summaries appear in both dashboard and MCP responses, and a test asserts the new format against a known anomaly record.
136+
- [x] **Smarter anomaly summaries** — rewrite the summary template in the anomaly detector to produce human-scannable text. Volume shifts: "X saw N× normal traffic (current vs ~baseline/day)". Dimension spikes: "X from dimension=value jumped to N (normally ~baseline/day)". σ as parenthetical. *Done when:* new summaries appear in both dashboard and MCP responses, and a test asserts the new format against a known anomaly record.

internal/reads/reads.go

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -765,8 +765,7 @@ func (h *Handler) GetAnomalies(ctx context.Context, req GetAnomaliesRequest) (*A
765765
stddev = *m.P95
766766
}
767767

768-
summary := fmt.Sprintf("%s %s: %d in 24h vs baseline of %.0f",
769-
m.Name, m.Fingerprint, m.Count, mean)
768+
summary := anomalySummary(m.Fingerprint, m.Name, m.Dimensions, m.Count, mean, deviation)
770769

771770
entries = append(entries, AnomalyEntry{
772771
ID: m.ID,
@@ -830,6 +829,39 @@ func (h *Handler) handleDismissAnomaly(w http.ResponseWriter, r *http.Request) {
830829
}
831830

832831
// ---------------------------------------------------------------------------
832+
// anomalySummary builds a human-scannable summary for an anomaly record.
833+
//
834+
// volume_shift: "GET /search saw 3× normal traffic (240 vs ~80/day) (12.0σ above baseline)"
835+
// dimension_spike: "GET /items/:id from country=DE jumped to 47 (normally ~3/day) (8.1σ above baseline)"
836+
func anomalySummary(anomalyKind, name string, dims map[string]any, current int64, mean, sigma float64) string {
837+
sigmaStr := fmt.Sprintf("(%.1fσ above baseline)", sigma)
838+
839+
if anomalyKind == "dimension_spike" && len(dims) > 0 {
840+
// Build "dim1=val1, dim2=val2" string.
841+
keys := make([]string, 0, len(dims))
842+
for k := range dims {
843+
keys = append(keys, k)
844+
}
845+
sort.Strings(keys)
846+
parts := make([]string, 0, len(keys))
847+
for _, k := range keys {
848+
parts = append(parts, fmt.Sprintf("%s=%v", k, dims[k]))
849+
}
850+
dimStr := strings.Join(parts, ", ")
851+
return fmt.Sprintf("%s from %s jumped to %d (normally ~%.0f/day) %s",
852+
name, dimStr, current, mean, sigmaStr)
853+
}
854+
855+
// Volume shift.
856+
if mean > 0 {
857+
multiplier := float64(current) / mean
858+
return fmt.Sprintf("%s saw %.0f× normal traffic (%d vs ~%.0f/day) %s",
859+
name, multiplier, current, mean, sigmaStr)
860+
}
861+
return fmt.Sprintf("%s saw %d events (no prior baseline) %s",
862+
name, current, sigmaStr)
863+
}
864+
833865
// helpers
834866
// ---------------------------------------------------------------------------
835867

internal/reads/reads_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,92 @@ func TestHandleDismissAnomaly_HTTP(t *testing.T) {
705705
}
706706
}
707707

708+
func TestAnomalySummary_volumeShift(t *testing.T) {
709+
h, fake := newTestHandler(t, Config{})
710+
seedMetric(t, fake, beacondb.Metric{
711+
Kind: beacondb.KindPerf, Name: "GET /search",
712+
PeriodKind: beacondb.PeriodAnomaly, PeriodWindow: "24h",
713+
PeriodStart: fixedNow.Add(-1 * time.Hour),
714+
Count: 240, Sum: fp(12.0), P50: fp(80), P95: fp(13.3),
715+
Fingerprint: "volume_shift",
716+
})
717+
718+
resp, err := h.GetAnomalies(context.Background(), GetAnomaliesRequest{Since: 24 * time.Hour})
719+
if err != nil {
720+
t.Fatal(err)
721+
}
722+
s := resp.Anomalies[0].Summary
723+
if !strings.Contains(s, "GET /search") {
724+
t.Errorf("summary missing name: %q", s)
725+
}
726+
if !strings.Contains(s, "normal traffic") {
727+
t.Errorf("summary missing 'normal traffic': %q", s)
728+
}
729+
if !strings.Contains(s, "240 vs ~80/day") {
730+
t.Errorf("summary missing current vs baseline: %q", s)
731+
}
732+
if !strings.Contains(s, "12.0σ above baseline") {
733+
t.Errorf("summary missing sigma parenthetical: %q", s)
734+
}
735+
}
736+
737+
func TestAnomalySummary_dimensionSpike(t *testing.T) {
738+
h, fake := newTestHandler(t, Config{})
739+
seedMetric(t, fake, beacondb.Metric{
740+
Kind: beacondb.KindAmbient, Name: "GET /items/:id",
741+
PeriodKind: beacondb.PeriodAnomaly, PeriodWindow: "24h",
742+
PeriodStart: fixedNow.Add(-1 * time.Hour),
743+
Count: 47,
744+
Sum: fp(8.1),
745+
P50: fp(3),
746+
P95: fp(5.4),
747+
Fingerprint: "dimension_spike",
748+
Dimensions: map[string]any{"country": "DE"},
749+
DimensionHash: "abc",
750+
})
751+
752+
resp, err := h.GetAnomalies(context.Background(), GetAnomaliesRequest{Since: 24 * time.Hour})
753+
if err != nil {
754+
t.Fatal(err)
755+
}
756+
s := resp.Anomalies[0].Summary
757+
if !strings.Contains(s, "country=DE") {
758+
t.Errorf("summary missing dimension: %q", s)
759+
}
760+
if !strings.Contains(s, "jumped to 47") {
761+
t.Errorf("summary missing 'jumped to': %q", s)
762+
}
763+
if !strings.Contains(s, "normally ~3/day") {
764+
t.Errorf("summary missing baseline: %q", s)
765+
}
766+
if !strings.Contains(s, "8.1σ above baseline") {
767+
t.Errorf("summary missing sigma: %q", s)
768+
}
769+
}
770+
771+
func TestAnomalySummary_zeroMeanFallback(t *testing.T) {
772+
h, fake := newTestHandler(t, Config{})
773+
seedMetric(t, fake, beacondb.Metric{
774+
Kind: beacondb.KindPerf, Name: "GET /new-endpoint",
775+
PeriodKind: beacondb.PeriodAnomaly, PeriodWindow: "24h",
776+
PeriodStart: fixedNow.Add(-1 * time.Hour),
777+
Count: 25, Sum: fp(5.0), P50: fp(0), P95: fp(0),
778+
Fingerprint: "volume_shift",
779+
})
780+
781+
resp, err := h.GetAnomalies(context.Background(), GetAnomaliesRequest{Since: 24 * time.Hour})
782+
if err != nil {
783+
t.Fatal(err)
784+
}
785+
s := resp.Anomalies[0].Summary
786+
if !strings.Contains(s, "no prior baseline") {
787+
t.Errorf("summary missing fallback text: %q", s)
788+
}
789+
if !strings.Contains(s, "25 events") {
790+
t.Errorf("summary missing event count: %q", s)
791+
}
792+
}
793+
708794
func TestHandleAnomalies_emptyResponse(t *testing.T) {
709795
h, _ := newTestHandler(t, Config{})
710796
m := mux(h)

0 commit comments

Comments
 (0)