// Package fault is an error that arrives at the journal already
// explaining itself.
//
// The error journal used to fill with lines written for whoever wrote
// them. "work: lane is saturated", 833 times, named no lane, no depth
// and no repository — while the Lane that raised it knew all three. A
// browser resource error carried the failing URL and put it in the
// stack box, where the title needed it. Six notify failures that were
// one outage appeared as six rows, because the workflow name was
// interpolated into the message and the message WAS the identity.
//
// A fault fixes those three things at once:
//
// fault.New("ingest lane is full").
// Cost("check-ins wait to be indexed; nothing is lost").
// With("lane", "ingest", "running", 8, "queued", 32)
//
// - The message is a STABLE sentence. Specifics go in fields, so one
// fault is one row however much its numbers move.
// - Cost says what it means for the forge. A reader who does not know
// what a lane is still learns what is not happening.
// - Fields are the context an operator or an agent needs to act,
// rendered as data rather than buried in prose.
//
// It is an ordinary error: it wraps, it compares with errors.Is, and
// its Error() reads as one line for a log. Nothing has to know about
// this package to keep working.
package fault
import (
"fmt"
"regexp"
"sort"
"strings"
)
// Field is one piece of context: a name an operator recognises and a
// value already rendered as text, because this is display data, not a
// place to smuggle a live object.
type Field struct {
Key string `json:"key"`
Value string `json:"value"`
}
// Fault is an error with the context needed to act on it.
type Fault struct {
// Msg is the stable sentence. Interpolate nothing into it: two
// occurrences differing only by a name or a number are one fault,
// and a message that carries them is two rows in the journal.
Msg string
// Why is what the failure costs the forge, in a reader's terms.
Why string
// Fix is what to do about it, when there is a definite answer. Left
// empty rather than guessed: a wrong instruction is worse than none.
Fix string
// Fields are the specifics, in the order they were added — the
// order that reads best, not alphabetical.
Fields []Field
// Err is the cause, when this fault wraps one.
Err error
}
// New starts a fault from a stable message.
func New(msg string) *Fault { return &Fault{Msg: msg} }
// Wrap puts a sentence and context around a cause. A nil cause gives a
// nil fault, so `return fault.Wrap(err, …)` is safe on the happy path.
func Wrap(err error, msg string) *Fault {
if err == nil {
return nil
}
return &Fault{Msg: msg, Err: err}
}
// Cost records what the failure means for the forge.
func (f *Fault) Cost(why string) *Fault {
if f == nil {
return nil
}
f.Why = why
return f
}
// Do records what to do about it.
func (f *Fault) Do(fix string) *Fault {
if f == nil {
return nil
}
f.Fix = fix
return f
}
// With adds context as alternating key and value, the way slog takes
// attributes. Values are formatted at the call site's moment, because
// that is the moment they were true. An odd trailing key is kept with
// an empty value rather than dropped: losing an operator's context to
// a typo is the wrong trade.
func (f *Fault) With(kv ...any) *Fault {
if f == nil {
return nil
}
for i := 0; i < len(kv); i += 2 {
key := fmt.Sprint(kv[i])
val := ""
if i+1 < len(kv) {
val = fmt.Sprint(kv[i+1])
}
if key == "" {
continue
}
f.Fields = append(f.Fields, Field{Key: key, Value: val})
}
return f
}
// Error renders one line: the sentence, its fields, then the cause.
// Deliberately complete — a fault that reaches a plain log file must
// still say everything it knows, because that file is what survives a
// crash.
func (f *Fault) Error() string {
if f == nil {
return ""
}
var b strings.Builder
b.WriteString(f.Msg)
for i, fld := range f.Fields {
if i == 0 {
b.WriteString(" [")
} else {
b.WriteString(" ")
}
fmt.Fprintf(&b, "%s=%s", fld.Key, fld.Value)
if i == len(f.Fields)-1 {
b.WriteString("]")
}
}
if f.Err != nil {
b.WriteString(": ")
b.WriteString(f.Err.Error())
}
return b.String()
}
// Sentence is the fault WITHOUT its fields: the stable words plus the
// cause. Error() is for a log line, where everything belongs on one
// line; this is for a display that shows the fields separately, and it
// is also what the fields exist to keep out of — a message carrying a
// workflow name is one row per workflow.
func (f *Fault) Sentence() string {
if f == nil {
return ""
}
if f.Err == nil {
return f.Msg
}
// A wrapped fault contributes its own sentence, not its fields.
if inner, ok := f.Err.(*Fault); ok {
return f.Msg + ": " + inner.Sentence()
}
return f.Msg + ": " + f.Err.Error()
}
// Cause is the wrapped error's text, without this fault's own words —
// the evidence for the sentence, shown as a field rather than trailing
// the title, where a URL or a port would make one fault look like many.
func (f *Fault) Cause() string {
if f == nil || f.Err == nil {
return ""
}
if inner, ok := f.Err.(*Fault); ok {
return inner.Sentence()
}
return f.Err.Error()
}
// Unwrap gives errors.Is and errors.As the cause.
func (f *Fault) Unwrap() error {
if f == nil {
return nil
}
return f.Err
}
// Of finds the fault in an error chain, or nil. The reporter uses it to
// pull structure out of an error that has been wrapped on its way up.
func Of(err error) *Fault {
for err != nil {
if f, ok := err.(*Fault); ok {
return f
}
u, ok := err.(interface{ Unwrap() error })
if !ok {
return nil
}
err = u.Unwrap()
}
return nil
}
// Grouping: which occurrences are the same fault.
//
// A fault's Msg is already stable, so its own grouping is free. Errors
// from everywhere else are not: a store error names a hash, an HTTP
// failure names a port, a workflow failure names the workflow. Those
// vary per occurrence and identify no fault, so they are replaced
// before the text is fingerprinted — otherwise one outage is one row
// per name it touched, which is what the journal did.
var (
reQuoted = regexp.MustCompile(`"[^"]*"|'[^']*'`)
reURL = regexp.MustCompile(`\b[a-zA-Z][a-zA-Z0-9+.-]*://[^\s"'\)]+`)
rePath = regexp.MustCompile(`(/[A-Za-z0-9._~%-]+){2,}`)
reHex = regexp.MustCompile(`\b[0-9a-fA-F]{8,}\b`)
reNum = regexp.MustCompile(`\b\d+(\.\d+)?([a-zµ]{1,2})?\b`)
)
// Normalize reduces a message to what identifies the fault. Order
// matters: URLs before paths (a URL contains one), and both before
// numbers, so a port is not blanked out of a URL that is about to be
// replaced whole.
func Normalize(msg string) string {
out := reQuoted.ReplaceAllString(msg, `"·"`)
out = reURL.ReplaceAllString(out, "·url·")
out = rePath.ReplaceAllString(out, "·path·")
out = reHex.ReplaceAllString(out, "·id·")
out = reNum.ReplaceAllString(out, "·n·")
return strings.Join(strings.Fields(out), " ")
}
// Keys lists a fault's field names, sorted — for a test or a schema
// check that cares which context a call site provides, not its values.
func (f *Fault) Keys() []string {
if f == nil {
return nil
}
out := make([]string, 0, len(f.Fields))
for _, fl := range f.Fields {
out = append(out, fl.Key)
}
sort.Strings(out)
return out
}