Documentation
¶
Overview ¶
Package outcome records, in an append-only JSONL ledger, whether an investigated incident later resolved and which answer was used for it — the "did it actually work?" signal the learning loop reads. The ledger keeps an in-memory index of still-open incidents, rebuilt by replaying the file on startup so a resolve survives a restart / leader failover.
Index ¶
- Constants
- func DeriveFingerprint(prefix, key string) string
- func Derived(fp string) bool
- type Aggregate
- type Episode
- type Event
- type Ledger
- func (l *Ledger) Enabled() bool
- func (l *Ledger) Episodes() ([]Episode, error)
- func (l *Ledger) Occurrences(triggerKey string) (int, time.Time, string)
- func (l *Ledger) Open(e Event) error
- func (l *Ledger) OpenCounts() (map[string]Aggregate, error)
- func (l *Ledger) Reload() error
- func (l *Ledger) Resolve(fp string, at time.Time) (Episode, bool, error)
- func (l *Ledger) Status() Status
- type Status
Constants ¶
const ( // GitOpsFingerprintPrefix marks a fingerprint derived from a GitOps failure's // resource-ref + condition reason. GitOpsFingerprintPrefix = "gitops:" // ReinvestigateFingerprintPrefix marks a fingerprint derived for a reinvestigate poll. ReinvestigateFingerprintPrefix = "reinvestigate:" )
Fingerprint prefixes for incidents RunLore assigns a synthetic id to because the triggering source carries no external alert fingerprint (an Alertmanager/PagerDuty incident id). They are chosen so a derived id can never collide with a real fingerprint, and they double as the "no ground-truth resolve channel" marker that keeps such recall opens out of recall-decay (see Event.Resolvable / applyOpenLocked).
const DefaultMaxEvents = 50000
DefaultMaxEvents is the generous default compaction bound used by New (and by the serve wiring when outcome.max_events is unset). Chosen high enough that a healthy ledger never compacts in normal operation; compaction is a growth backstop, not a routine trim.
Variables ¶
This section is empty.
Functions ¶
func DeriveFingerprint ¶ added in v0.7.0
DeriveFingerprint returns a stable, deterministic fingerprint for an incident that carries no external alert id, formed as prefix + a short hex sha256 of key (e.g. the trigger key, or resource-ref+reason). Determinism is the point: the same recurring incident derives the SAME fingerprint every time, so its opens roll up into Occurrences/Episodes as recurrences — while the prefix keeps it from ever colliding with a real Alertmanager/PagerDuty fingerprint.
func Derived ¶ added in v0.7.0
Derived reports whether fp was assigned by DeriveFingerprint (a synthetic id for a source with no external fingerprint). Such incidents have no resolve channel — no resolved-alert webhook can ever match them — so their recall opens are recorded for recurrence but never counted toward recall decay (see Event.Resolvable).
Types ¶
type Aggregate ¶
Aggregate is a per-entry roll-up of recall episodes: how often the entry was recalled, how often the incident then resolved, and when it last resolved.
type Episode ¶
type Episode struct {
Kind, Entry, Title, Resource string
DupFingerprint string // curator dedup fingerprint; stable join key for the curated-PR resolution check
OpenedAt, ResolvedAt time.Time
Duration time.Duration
Resolved bool
}
Episode is a matched open→resolve pair (or, from Episodes(), an unresolved open when Resolved is false).
type Event ¶
type Event struct {
Event string `json:"event"` // "open" | "resolve"
Fingerprint string `json:"fingerprint"` // Alertmanager fingerprint (stable firing↔resolved)
DupFingerprint string `json:"dup_fingerprint,omitempty"` // curator dedup fingerprint (resource+cause); the curated-PR resolution join key
Kind string `json:"kind,omitempty"` // open: "recall" | "fresh"
Entry string `json:"entry,omitempty"` // open+recall: the recalled entry path
Title string `json:"title,omitempty"`
Resource string `json:"resource,omitempty"`
At time.Time `json:"at"`
// Recurrence fields (written by the delivery path). Kept omitempty so the
// append-only file stays backward/forward compatible with older readers.
TriggerKey string `json:"trigger_key,omitempty"` // groups recurrences of the same alert; keys the byTrigger index
CuratedURL string `json:"curated_url,omitempty"` // KB link surfaced as "previous: <link>" on recurrence
Verdict string `json:"verdict,omitempty"` // curator's machine verdict on the investigation
// Resolvable is set on an open when we know whether a ground-truth resolve signal
// can ever arrive for it: true for sources with a resolve channel (Alertmanager,
// PagerDuty), false for sources that never emit one (GitOps, reinvestigate, or
// Alertmanager with send_resolved off). A pointer for a three-state distinction:
// nil ⇒ the field is absent, i.e. a LEGACY open written before this field existed —
// those came from Alertmanager/PagerDuty and are treated as resolvable. Only a
// non-resolvable recall open is excluded from recall decay (see applyOpenLocked).
Resolvable *bool `json:"resolvable,omitempty"`
// Checkpoint is set only on a compaction record (Event=="checkpoint"); nil on every
// open/resolve. It carries the folded aggregate state of the events a compaction
// dropped. Kept omitempty so normal lines are unaffected, and a distinct event kind
// so OLD binaries — whose switch has no "checkpoint" case — ignore it gracefully
// (they lose the pre-horizon aggregates, but do not choke).
Checkpoint *checkpointData `json:"checkpoint,omitempty"`
}
Event is one ledger line: an investigation opened, or an incident resolved.
type Ledger ¶
type Ledger struct {
// contains filtered or unexported fields
}
Ledger is an append-only outcome log with an in-memory open-index and a cached per-entry recall aggregate (the OpenCounts roll-up). The aggregate is built once by replaying the file at New and then maintained INCREMENTALLY under mu on every Open/Resolve, so OpenCounts is O(1) and never re-reads the file — it lived on the recall hot path, which previously replayed the whole JSONL per incident lookup.
func New ¶
New opens (replaying) the ledger at path with the default compaction bound (DefaultMaxEvents). An empty path returns a disabled, no-op ledger (the feature is off).
func NewWithMaxEvents ¶ added in v0.7.0
NewWithMaxEvents opens (replaying) the ledger at path, compacting the JSONL on load when it exceeds maxEvents (0 disables compaction). An empty path returns a disabled, no-op ledger.
func (*Ledger) Enabled ¶ added in v0.5.0
Enabled reports whether the ledger will actually persist events (a non-empty ledger_path was configured); nil-safe. Exported for wiring sites: a disabled ledger's methods silently no-op, so handing it to a consumer that assumes its writes land would misrepresent persistence. Cheaper than Status(), which re-reads the whole file.
func (*Ledger) Episodes ¶
Episodes replays the full ledger and turns every open into an Episode, pairing each resolve with the most-recent (LIFO) unresolved open for the same fingerprint — so recurrence is preserved (N opens + 1 resolve ⇒ N episodes, 1 resolved). Pairing is order-independent: a resolve that arrives BEFORE its open (a transient incident that cleared mid-investigation, so the resolve webhook landed before the open was recorded) is buffered and paired with the next open for that fingerprint. Episodes are returned in open order; all kinds are included. A disabled/empty ledger yields nil.
func (*Ledger) Occurrences ¶ added in v0.5.0
Occurrences reports how many investigations have been recorded for a TriggerKey, when the most recent one happened, and its KB link — the recurrence facts the notifier renders. Zero values for a disabled ledger, an empty key, or a never-seen key.
func (*Ledger) OpenCounts ¶
OpenCounts rolls recall episodes up per catalog entry (fresh investigations carry no entry). It is the input to recall decay: resolve-rate ≈ (Resolved+k)/(Recalls+k), and runs on the recall hot path once per incident lookup. It returns the cached aggregate — built once at New and maintained incrementally on every Open/Resolve — so it is O(entries) and never re-reads the file; the value equals a fresh full replay of the ledger for any event sequence below the maxPendingResolvesPerFingerprint cap — above the cap (pathological orphan-resolve load), dropped excess resolves mean the cache and an unbounded Episodes() replay may diverge. A disabled/empty ledger yields an empty (non-nil) map. The returned map is a fresh copy the caller may freely mutate.
Behaviour note: because the read moved to New/Reload, OpenCounts no longer performs file I/O and so can no longer return a read error — any error reading the ledger surfaces at construction (New) or re-sync (Reload) time, not per call. The error return is retained for signature stability and is always nil here.
func (*Ledger) Reload ¶ added in v0.3.0
Reload re-replays the ledger file under the write lock and rebuilds the cached aggregate, open-index, and pairing state from scratch. The cache is otherwise maintained incrementally and so only reflects writes made through THIS process: in a multi-replica HA deployment sharing one ledger file, a replica that loses then re-acquires leadership would keep serving its pre-handover cache, missing every open/resolve another replica appended while it was a follower — stale aggregates and thus wrong recall-decay. Call Reload when this process (re-)acquires leadership so it re-syncs with those external writes. It takes the write lock (no torn read against a concurrent OpenCounts). A disabled/nil ledger is a no-op.
type Status ¶
type Status struct {
Path string // the configured ledger path ("" when disabled)
Configured bool // a non-empty ledger_path was set
Present bool // the file exists (true even when empty)
Events int // number of replayable events (0 for absent/empty)
}
Status is a cheap snapshot of the ledger's on-disk reality, used to tell apart "feature off" (Configured=false) from "configured but the file the curate pod can see is absent/empty" (Configured=true, Present=false or Events==0) — the silent-no-op the `lore curate` startup warning surfaces. It re-reads the file (no cached open-index) so it reflects what a fresh process actually sees.