alerts: keep only the newest evidence envelope in history entries (#3096)

## Why
After #3093, the remaining weight of the alert history fold is evidence
nothing reads. On the long-running dogfood install, a resolved snapshot
averages 21 KB, and 83% of that is `evidence`: a trail of up to 64
envelopes (35 on average), across 1,895 occurrences kept for 90 days
(#1803).

Nothing in Pulse reads `Alert.evidence` from history. The incident
panel's evidence comes from the incident timeline endpoint, the AI's
alert history uses recently resolved alerts, and the attention summary
and quality telemetry never touch it.

## What changed
- The fold trims each decoded snapshot to its newest envelope, by
`observedAt` and then position. An updated envelope keeps its slot, so
the last one isn't always the newest. Merging takes the incoming
snapshot's evidence, so the fold never holds a full trail.
- History output applies the same rule to live overlays and the JSON
fallback, so every `GET /api/alerts/history` entry carries at most one
envelope.
- `operationalRecord.evidenceIds` and `transitions[].evidenceIds` keep
their full lists. The event log keeps every snapshot whole, and `GET
/api/alerts/active` still returns an open alert's full evidence.
- The api-contracts history clause, `docs/API.md` (and its shipped
mirror) and the alerts contract say so.

This is an intentional change to a public API response, approved by the
maintainer. A client that read full evidence from history should read
the active alert, the incident timeline or the event log.

## Measured
On a log shaped like the dogfood install's (1,900 occurrences, 35
envelopes each), the history fold retains 2.1 MB of heap, down from 27.2
MB.

## Checks (pulse-dev)
- New tests:
  - newest by time and tie-breaking;
  - the fold holds one envelope while the record keeps all 64 IDs;
  - the event log stays whole;
  - live overlay and JSON fallback;
  - direct output shaping, with its input untouched;
  - `docs/API.md` states the rule.
- Negative controls: keeping full evidence in the fold, in the output,
or choosing by position each fail their test.
- `internal/alerts/...`, `internal/api/alerting/...` and
`docsLinks.test.ts` pass. Prettier, vet, the completion, contract,
docs-mirror and browser-verification guards all pass.

Related to #1803
This commit is contained in:
rcourtman
2026-10-21 17:52:27 +01:00
committed by GitHub
9 changed files with 220 additions and 3 deletions
+1 -1
View File
@@ -1070,7 +1070,7 @@ it, use the snooze and unsnooze endpoints below.
- `GET /api/alerts/active`
- `GET /api/alerts/delivery-diagnosis?alertIdentifier=<alert-id>` (omit `alertIdentifier` to get the diagnosis array for every active alert)
- `GET /api/alerts/events?alertIdentifier=<alert-id>&type=<event-type,...>&since=<RFC3339>&limit=<n>` — append-only alert event log: lifecycle transitions and notification decisions, including suppressions with reasons; all parameters optional, newest first
- `GET /api/alerts/history`
- `GET /api/alerts/history` — each entry carries at most one `evidence` envelope, the newest; `operationalRecord.evidenceIds` keeps the full list, and `GET /api/alerts/active` returns an open alert's full evidence
- `DELETE /api/alerts/history`
- `GET /api/alerts/incidents`
- `POST /api/alerts/incidents/note`
@@ -1041,6 +1041,13 @@ independent walk of only the window, and
`TestWindowedHistoryReusesTheFullHistoryFold` and
`TestHistoryReadResultsDoNotAliasTheSharedFold` pin the copy, reuse and
isolation rules.
The fold keeps only each snapshot's newest evidence envelope
(`newestHistoryEvidence`, by `observedAt` then position), and history output
applies the same rule to live overlays and the JSON fallback. A resolved
alert's trail of up to 64 envelopes was about 83% of its snapshot. The event log
still stores every snapshot whole. `TestHistoryEntriesKeepOnlyTheNewestEvidence`,
`TestHistoryOverlayKeepsOnlyTheNewestEvidence` and
`TestHistoryFallbackKeepsOnlyTheNewestEvidence` pin this.
PBS node-status availability is distinct from connectivity. While a connected
@@ -645,7 +645,14 @@ grouping-key fields while silently ignoring `enabled`.
`warning`, and `critical` (plus omitted/`all` for no severity filter) and
returns only matching rows after the time and resource filters. Alert payloads
use the same closed three-level vocabulary; legacy `error` data is normalized
to `critical` before it crosses the API boundary. Notification configuration
to `critical` before it crosses the API boundary.
Each history entry carries at most one `evidence` envelope, the newest by
`observedAt`, whether it comes from the event-log projection, a live overlay or
the JSON fallback (#1803). `operationalRecord.evidenceIds` and
`transitions[].evidenceIds` keep their full lists, so some identifiers name
envelopes the entry omits. The alert event log keeps every snapshot whole, and
`GET /api/alerts/active` returns an open alert's full evidence.
Notification configuration
round-trips destination minimum severity as `all`, `warning`, or `critical`.
The Relay adapter has a deliberately narrower persisted floor of `all` or
`critical`, but an all-alert mobile payload still preserves canonical `info`
+1 -1
View File
@@ -1070,7 +1070,7 @@ it, use the snooze and unsnooze endpoints below.
- `GET /api/alerts/active`
- `GET /api/alerts/delivery-diagnosis?alertIdentifier=<alert-id>` (omit `alertIdentifier` to get the diagnosis array for every active alert)
- `GET /api/alerts/events?alertIdentifier=<alert-id>&type=<event-type,...>&since=<RFC3339>&limit=<n>` — append-only alert event log: lifecycle transitions and notification decisions, including suppressions with reasons; all parameters optional, newest first
- `GET /api/alerts/history`
- `GET /api/alerts/history` — each entry carries at most one `evidence` envelope, the newest; `operationalRecord.evidenceIds` keeps the full list, and `GET /api/alerts/active` returns an open alert's full evidence
- `DELETE /api/alerts/history`
- `GET /api/alerts/incidents`
- `POST /api/alerts/incidents/note`
@@ -220,6 +220,19 @@ describe('docsLinks', () => {
expect(handler).toContain('agentID != "" && len(*compatibility.ObservationLocationIDs) == 1');
});
it('documents alert history entries carrying only the newest evidence envelope', () => {
const api = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
expect(api).toContain(
'`GET /api/alerts/history` — each entry carries at most one `evidence` envelope, the newest',
);
expect(api).toContain('`operationalRecord.evidenceIds` keeps the full list');
const projection = readFileSync(
path.join(repoRoot, 'internal', 'alerts', 'history_projection.go'),
'utf8',
);
expect(projection).toContain('snapshot.Evidence = newestHistoryEvidence(snapshot.Evidence)');
});
it('documents alert configuration saves as replacing only the keys they send', () => {
const api = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
expect(api).toContain('`PUT /api/alerts/config` — replaces only the top-level keys the body');
+3
View File
@@ -407,6 +407,9 @@ func canonicalizeAlertHistoryForOutput(history []Alert) []Alert {
if exportedAlert == nil {
continue
}
// Live overlays and the JSON fallback follow the projection's rule:
// a history entry carries only its newest evidence envelope.
exportedAlert.Evidence = newestHistoryEvidence(exportedAlert.Evidence)
exported = append(exported, *exportedAlert)
}
return exported
+23
View File
@@ -15,8 +15,28 @@ import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts/eventlog"
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
)
// newestHistoryEvidence keeps the newest evidence envelope, by ObservedAt and
// then position, in a fresh slice. A history entry carries at most this one:
// a resolved alert's full trail of up to maxOperationalEvidencePerAlert
// envelopes was most of each entry's size, and nothing reads it from history
// (#1803). The event log keeps every snapshot whole, and operationalRecord and
// transitions keep their full evidence ID lists.
func newestHistoryEvidence(evidence []operationaltrust.EvidenceEnvelope) []operationaltrust.EvidenceEnvelope {
if len(evidence) == 0 {
return nil
}
newest := 0
for i := range evidence {
if !evidence[i].ObservedAt.Before(evidence[newest].ObservedAt) {
newest = i
}
}
return []operationaltrust.EvidenceEnvelope{evidence[newest].Clone()}
}
// historyEventMark is what one folded lifecycle event contributed to its
// occurrence. The marks let a Since window be answered from the full fold
// instead of decoding every snapshot in the window again (#1803).
@@ -182,6 +202,9 @@ func (p *historyProjection) fold(throughID int64) error {
if err := json.Unmarshal(event.Snapshot, &snapshot); err != nil {
return nil
}
// History keeps only the newest evidence envelope. Merging takes the
// incoming snapshot's evidence, so trimming here is all the fold holds.
snapshot.Evidence = newestHistoryEvidence(snapshot.Evidence)
key := historyOccurrenceKey(event.AlertID, &snapshot)
occ, exists := p.occurrences[key]
if !exists {
@@ -19,6 +19,7 @@ import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts/eventlog"
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
)
func TestHistoryProjectionIncrementalReadersPreserveChronologyAndIsolation(t *testing.T) {
@@ -554,3 +555,130 @@ func TestLimitedHistoryReadCopiesOnlyReturnedRows(t *testing.T) {
t.Fatalf("a 10-row read allocated %d bytes against %d for all 2000 rows; it must not copy the whole fold", ten, everything)
}
}
// historyEvidence builds n envelopes whose ObservedAt order differs from their
// position, so "newest" must be chosen by time, not by slice order.
func historyEvidence(base time.Time, n int, newestIndex int) []operationaltrust.EvidenceEnvelope {
evidence := make([]operationaltrust.EvidenceEnvelope, 0, n)
for i := 0; i < n; i++ {
observed := base.Add(time.Duration(n-i) * time.Second)
if i == newestIndex {
observed = base.Add(time.Hour)
}
evidence = append(evidence, operationaltrust.EvidenceEnvelope{ID: fmt.Sprintf("ev-%02d", i), ObservedAt: observed})
}
return evidence
}
func TestNewestHistoryEvidenceChoosesByObservationTime(t *testing.T) {
base := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
if got := newestHistoryEvidence(nil); got != nil {
t.Fatalf("no evidence must stay nil, got %+v", got)
}
got := newestHistoryEvidence(historyEvidence(base, 64, 17))
if len(got) != 1 || got[0].ID != "ev-17" {
t.Fatalf("newest by ObservedAt = %+v, want ev-17", got)
}
tied := []operationaltrust.EvidenceEnvelope{{ID: "first", ObservedAt: base}, {ID: "second", ObservedAt: base}}
if got := newestHistoryEvidence(tied); len(got) != 1 || got[0].ID != "second" {
t.Fatalf("a tie must keep the later envelope, got %+v", got)
}
}
// A history entry carries only its newest evidence envelope, in the shared
// fold as well as the response; the event log and the record keep everything.
func TestHistoryEntriesKeepOnlyTheNewestEvidence(t *testing.T) {
m := newHistoryParityManager(t)
store := m.eventLogStore()
start := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
ids := make([]string, 0, 64)
for i := 0; i < 64; i++ {
ids = append(ids, fmt.Sprintf("ev-%02d", i))
}
write := func(kind string, occurred time.Time, evidence []operationaltrust.EvidenceEnvelope) {
t.Helper()
snapshot, err := json.Marshal(Alert{ID: "disk-1", ResourceID: "vm-1", StartTime: start, LastSeen: occurred,
Evidence: evidence, OperationalRecord: &operationaltrust.OperationalRecord{ID: "record-1", EvidenceIDs: ids}})
if err != nil {
t.Fatal(err)
}
if err := store.AppendDurable(eventlog.Event{Type: kind, AlertID: "disk-1", OccurredAt: occurred, Snapshot: snapshot}); err != nil {
t.Fatal(err)
}
}
write(eventlog.TypeFired, start, historyEvidence(start, 5, 2))
write(eventlog.TypeResolved, start.Add(time.Minute), historyEvidence(start, 64, 40))
for _, since := range []time.Time{{}, start.Add(30 * time.Second)} {
history, ok := m.AlertHistoryFromEvents(since, 0)
if !ok || len(history) != 1 {
t.Fatalf("since=%s: history = %d entries, ok=%v", since, len(history), ok)
}
if got := history[0].Evidence; len(got) != 1 || got[0].ID != "ev-40" {
t.Fatalf("since=%s: evidence = %+v, want only the newest envelope ev-40", since, got)
}
if got := history[0].OperationalRecord; got == nil || len(got.EvidenceIDs) != 64 {
t.Fatalf("since=%s: the record must keep its full evidence ID list, got %+v", since, got)
}
}
m.historyProjectionMu.Lock()
for _, occ := range m.historyProjection.occurrences {
if len(occ.alert.Evidence) != 1 {
t.Errorf("the shared fold holds %d evidence envelopes; it must hold only the newest", len(occ.alert.Evidence))
}
}
m.historyProjectionMu.Unlock()
var logged []int
if err := store.WalkOldest(eventlog.Filter{AlertID: "disk-1"}, func(event eventlog.Event) error {
var snapshot Alert
if err := json.Unmarshal(event.Snapshot, &snapshot); err != nil {
return err
}
logged = append(logged, len(snapshot.Evidence))
return nil
}); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(logged, []int{5, 64}) {
t.Fatalf("the event log must keep every snapshot whole, evidence lengths = %v", logged)
}
}
func TestHistoryOverlayKeepsOnlyTheNewestEvidence(t *testing.T) {
start := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
m := newHistoryParityManager(t)
m.mu.Lock()
m.activeAlerts["live"] = &Alert{ID: "cpu-1", ResourceID: "vm-2", StartTime: start, LastSeen: start,
Evidence: historyEvidence(start, 12, 3)}
m.mu.Unlock()
history, ok := m.AlertHistoryFromEvents(time.Time{}, 0)
if !ok || len(history) != 1 {
t.Fatalf("overlay history = %d entries, ok=%v", len(history), ok)
}
if got := history[0].Evidence; len(got) != 1 || got[0].ID != "ev-03" {
t.Fatalf("a live overlay entry carries %+v, want only ev-03", got)
}
m.mu.RLock()
live := len(m.activeAlerts["live"].Evidence)
m.mu.RUnlock()
if live != 12 {
t.Fatalf("trimming history output changed the live alert's evidence to %d envelopes", live)
}
}
// A separate test: two newTestManager calls on one t deadlock.
func TestHistoryFallbackKeepsOnlyTheNewestEvidence(t *testing.T) {
start := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
fallback := newTestManager(t)
fallback.SetEventLog(nil) // read the JSON-backed history, not a projection
fallback.historyManager.AddAlert(Alert{ID: "mem-1", ResourceID: "vm-3", StartTime: start, LastSeen: start,
Evidence: historyEvidence(start, 9, 6)})
entries := fallback.GetAlertHistory(0)
if len(entries) != 1 {
t.Fatalf("fallback history = %d entries", len(entries))
}
if got := entries[0].Evidence; len(got) != 1 || got[0].ID != "ev-06" {
t.Fatalf("a JSON-history entry carries %+v, want only ev-06", got)
}
}
@@ -980,3 +980,39 @@ func TestCapacityForecastAlertNamesItsPlatform(t *testing.T) {
t.Fatalf("platformType metadata = %v, want vmware-vsphere", got)
}
}
// History output keeps an alert's operational record and transitions whole but
// only its newest evidence envelope, and never changes the alert it copies
// (#1803).
func TestHistoryOutputKeepsTheRecordAndOnlyTheNewestEvidence(t *testing.T) {
observed := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
alert := Alert{
ID: "disk-1",
ResourceID: "vm-1",
Level: AlertLevelWarning,
StartTime: observed,
LastSeen: observed,
Evidence: []operationaltrust.EvidenceEnvelope{
{ID: "ev-new", ObservedAt: observed.Add(time.Hour)},
{ID: "ev-old", ObservedAt: observed},
},
OperationalRecord: &operationaltrust.OperationalRecord{ID: "record-1", EvidenceIDs: []string{"ev-old", "ev-new"}},
Transitions: []operationaltrust.LifecycleTransition{{ID: "transition-1", EvidenceIDs: []string{"ev-old"}}},
}
output := canonicalizeAlertHistoryForOutput([]Alert{alert})
if len(output) != 1 {
t.Fatalf("output = %d entries", len(output))
}
if got := output[0].Evidence; len(got) != 1 || got[0].ID != "ev-new" {
t.Fatalf("history evidence = %+v, want only ev-new", got)
}
if got := output[0].OperationalRecord; got == nil || len(got.EvidenceIDs) != 2 {
t.Fatalf("the operational record must keep both evidence IDs, got %+v", got)
}
if got := output[0].Transitions; len(got) != 1 || len(got[0].EvidenceIDs) != 1 {
t.Fatalf("transitions must be kept whole, got %+v", got)
}
if len(alert.Evidence) != 2 {
t.Fatalf("shaping history output changed its input alert to %d envelopes", len(alert.Evidence))
}
}