outcome

package
v0.9.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 11, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

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

View Source
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).

View Source
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

func DeriveFingerprint(prefix, key string) string

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

func Derived(fp string) bool

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

type Aggregate struct {
	Recalls       int
	Resolved      int
	FeedbackUp    int // human "the diagnosis was right" votes — success observations for decay
	FeedbackDown  int // human "the diagnosis was wrong" votes — failure observations for decay
	LastConfirmed time.Time
}

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 — plus the human 👍/👎 votes attributed to the entry (one live vote per TriggerKey+user, latest wins; see applyFeedbackLocked).

type ContestedTrigger added in v0.8.0

type ContestedTrigger struct {
	TriggerKey string
	CuratedURL string    // KB link of the newest open (never "")
	Downs      int       // standing 👎 votes, per-user latest-wins
	Last       time.Time // when the trigger's newest investigation happened
}

ContestedTrigger is one trigger whose delivered conclusion has standing 👎 votes AND a KB artifact to act on — the unit of work for the curate pass that warns the human reviewing the pending KB PR. Downs counts LIVE "down" votes after per-user dedup (a vote later moved to 👍 no longer counts); CuratedURL and Last come from the trigger's newest open, matching Recurrence.

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

	// User identifies the human behind a feedback event (a Slack user id) — the
	// dedup key that keeps one live vote per (TriggerKey, user), latest wins.
	// Empty on open/resolve lines. On a feedback line Kind carries the rating
	// ("up" | "down").
	User string `json:"user,omitempty"`

	// 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

func New(path string) (*Ledger, error)

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

func NewWithMaxEvents(path string, maxEvents int) (*Ledger, error)

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) ContestedTriggers added in v0.8.0

func (l *Ledger) ContestedTriggers() []ContestedTrigger

ContestedTriggers returns every trigger with at least one standing 👎 vote and a non-empty CuratedURL, sorted by TriggerKey so the pass output is deterministic. It is the inverse view of Recurrence's per-key FeedbackDown: one iteration over the live votes map — O(live votes), which stays small (one entry per trigger×user) and runs on the scheduled curate cadence, not a hot path — joined against byTrigger for the newest open's KB link and time. A contested trigger with NO KB link is deliberately excluded: there is no PR for a reviewer to be warned on (the vote itself still stands in the ledger for trust decay and the recurrence cooldown). Nil for a disabled (or nil) ledger.

func (*Ledger) Enabled added in v0.5.0

func (l *Ledger) Enabled() bool

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

func (l *Ledger) Episodes() ([]Episode, error)

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) Feedback added in v0.5.0

func (l *Ledger) Feedback(triggerKey, rating, user string, at time.Time) error

Feedback appends a human 👍/👎 verdict on a delivered investigation and folds it into the trust aggregate. rating is "up" or "down" (anything else is an error, never silently recorded); user is the stable reviewer id (a Slack user id) the per-trigger dedup keys on. Attribution is entirely TriggerKey-based — feedback lines carry no alert fingerprint and open/resolve pairing never sees them; binaries older than the fold ignored the kind entirely.

func (*Ledger) Occurrences added in v0.5.0

func (l *Ledger) Occurrences(triggerKey string) (int, time.Time, string)

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. A narrowing of Recurrence for the delivery path, which has no use for the verdict or the 👎-vote count.

func (*Ledger) Open

func (l *Ledger) Open(e Event) error

Open records that an investigation completed for an incident (fingerprint).

func (*Ledger) OpenCounts

func (l *Ledger) OpenCounts() (map[string]Aggregate, error)

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) Recurrence added in v0.8.0

func (l *Ledger) Recurrence(triggerKey string) TriggerRecurrence

Recurrence returns the trigger's recurrence snapshot. FeedbackDown counts the votes map's current "down" entries for the key — O(live votes), which stays small (one entry per trigger×user) and off the recall hot path (read once per incoming investigation). Zero value for a disabled ledger or unseen key.

func (*Ledger) Reload added in v0.3.0

func (l *Ledger) Reload() error

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.

func (*Ledger) Resolve

func (l *Ledger) Resolve(fp string, at time.Time) (Episode, bool, error)

Resolve records that an incident's alert cleared. When it matches an open investigation it returns the Episode (with duration + kind) and ok=true.

func (*Ledger) Status

func (l *Ledger) Status() Status

Status reports whether the ledger is configured and whether its file is actually present/non-empty where this process runs. A disabled ledger (path=="") reports Configured=false.

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.

type TriggerRecurrence added in v0.8.0

type TriggerRecurrence struct {
	Count        int
	Last         time.Time
	Verdict      string // newest open's verdict ("" for pre-verdict events)
	CuratedURL   string
	FeedbackDown int // LIVE 👎 votes for this trigger, after per-user dedup
}

TriggerRecurrence is the per-TriggerKey snapshot the pre-investigation suppression gate reads: how many investigations this trigger has had, when the last one was and what it concluded, its KB link, and how many humans currently contest that conclusion.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL