// exception.go persists the error journal. The in-memory ring
// (internal/errlog) is fast, bounded and deliberately lossy; it also
// dies with the process, which meant the Exceptions panel could only
// ever answer "what has broken since the last restart" — and a restart
// is frequently the thing that happened *because* something broke.
//
// This is the other half: one row per distinct signature, counted and
// dated, surviving restarts. Social state, not truth (RFC 0001 §11) —
// losing it loses a diagnosis, never a fact the DAG holds.
package oltp
import (
"database/sql"
"time"
)
const exceptionSchema = `
CREATE TABLE IF NOT EXISTS exception (
sig TEXT NOT NULL PRIMARY KEY, -- stable hash of the message
message TEXT NOT NULL,
surface TEXT NOT NULL, -- web | api | panic | browser | store | ingest | ci | mirror | chat | template
path TEXT NOT NULL, -- where it was FIRST seen
repo TEXT NOT NULL DEFAULT '',
trace TEXT NOT NULL DEFAULT '',
count INTEGER NOT NULL,
first TEXT NOT NULL,
last TEXT NOT NULL,
-- Resolved marks a signature the maintainer has dealt with. It
-- REOPENS on the next occurrence: an error that came back is not
-- resolved, whatever anybody clicked.
resolved_at TEXT NOT NULL DEFAULT ''
);
-- The panel orders by what is still happening, so that is the index.
CREATE INDEX IF NOT EXISTS exception_last ON exception(last DESC);`
// Exception is one persisted signature and its history.
type Exception struct {
Sig string
Message string
Surface string
Path string
Repo string
Trace string
Count int
First time.Time
Last time.Time
Resolved bool
}
// RecordException upserts one occurrence. The first sighting keeps its
// path and trace; later ones only advance the count and the clock,
// because the first stack already describes the code path and a repeat
// adds nothing but bytes.
//
// A signature that recurs is un-resolved by the same statement that
// counts it: there is no window in which a returning error is both
// happening and marked handled.
func (d *DB) RecordException(e Exception) error {
if e.Sig == "" {
return nil
}
_, err := d.db.Exec(`
INSERT INTO exception (sig, message, surface, path, repo, trace, count, first, last)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(sig) DO UPDATE SET
count = exception.count + 1,
last = excluded.last,
resolved_at = ''`,
e.Sig, e.Message, e.Surface, e.Path, e.Repo, e.Trace, e.Count,
e.First.UTC().Format(time.RFC3339Nano), e.Last.UTC().Format(time.RFC3339Nano))
return err
}
// Exceptions returns the most recently active signatures, newest first.
func (d *DB) Exceptions(limit int) ([]Exception, error) {
if limit <= 0 {
limit = 200
}
rows, err := d.db.Query(`
SELECT sig, message, surface, path, repo, trace, count, first, last, resolved_at
FROM exception ORDER BY last DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Exception
for rows.Next() {
var e Exception
var first, last, resolved string
if err := rows.Scan(&e.Sig, &e.Message, &e.Surface, &e.Path, &e.Repo,
&e.Trace, &e.Count, &first, &last, &resolved); err != nil {
return nil, err
}
e.First, _ = time.Parse(time.RFC3339Nano, first)
e.Last, _ = time.Parse(time.RFC3339Nano, last)
e.Resolved = resolved != ""
out = append(out, e)
}
return out, rows.Err()
}
// ExceptionTotals is what the header counts: distinct signatures and
// total occurrences over the whole history, not this page's slice.
func (d *DB) ExceptionTotals() (signatures, occurrences int, err error) {
var occ sql.NullInt64
err = d.db.QueryRow(`SELECT count(*), sum(count) FROM exception`).Scan(&signatures, &occ)
return signatures, int(occ.Int64), err
}
// ResolveException marks a signature handled. It is a note, not a
// promise: the next occurrence clears it (see RecordException).
func (d *DB) ResolveException(sig string) error {
_, err := d.db.Exec(`UPDATE exception SET resolved_at = ? WHERE sig = ?`,
time.Now().UTC().Format(time.RFC3339Nano), sig)
return err
}