// 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"`
// Private keeps a field out of Error(), which is the string a
// CLIENT receives, while leaving it in FieldsOf, which is what the
// structured log reads. Two renderers, two audiences.
//
// It exists because a store fault named the absolute path of the
// repository on disk, and that string reached anyone who could
// provoke the error — anonymously, on a 404. An operator needs that
// path to diagnose; a stranger needs only to be told the read
// failed.
Private bool `json:"private,omitempty"`
}
// 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.
// WithPrivate records fields for the LOG only: they reach FieldsOf and
// never Error(), so an operator sees them and a client does not.
//
// Use it for anything that describes the forge rather than the request
// — filesystem paths, internal addresses, credentials-adjacent names.
// The caller's own input is not private: telling somebody the path they
// asked for is not a disclosure.
func (f *Fault) WithPrivate(kv ...any) *Fault {
if f == nil {
return nil
}
start := len(f.Fields)
f.With(kv...)
for i := start; i < len(f.Fields); i++ {
f.Fields[i].Private = true
}
return f
}
func (f *Fault) Error() string {
if f == nil {
return ""
}
var b strings.Builder
b.WriteString(f.Msg)
// Private fields are skipped, so the brackets have to be driven by
// what is actually written rather than by the index — otherwise a
// private field in the first or last position loses a bracket and
// the message reads as truncated.
shown := 0
for _, fld := range f.Fields {
if fld.Private {
continue
}
shown++
}
written := 0
for _, fld := range f.Fields {
if fld.Private {
continue
}
if written == 0 {
b.WriteString(" [")
} else {
b.WriteString(" ")
}
fmt.Fprintf(&b, "%s=%s", fld.Key, fld.Value)
written++
if written == shown {
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
}
// chain is every fault in err's chain, outermost first.
func chain(err error) []*Fault {
var out []*Fault
for err != nil {
if f, ok := err.(*Fault); ok {
out = append(out, f)
}
u, ok := err.(interface{ Unwrap() error })
if !ok {
break
}
err = u.Unwrap()
}
return out
}
// FieldsOf collects the whole chain's context, outermost first, one
// entry per key.
//
// A wrapped fault's fields are as true as its wrapper's, and taking
// only the outer one's threw away the half that was worth reading. The
// ingest lane is the case that proved it: work.busy() attaches lane,
// running, limit, waiting, queue and shed — "the numbers that make the
// refusal actionable", by its own comment — the poll loop wrapped that
// in "a store could not be ingested" with the repository's name, and
// the panel showed the name and none of the numbers. Ten thousand
// occurrences of a capacity signal that never said what the capacity
// was.
//
// Outer keys win a collision: the wrapper is closer to the site the
// reader is looking at, so where both name a "repo" it is the
// wrapper's.
func FieldsOf(err error) []Field {
var out []Field
seen := map[string]bool{}
for _, f := range chain(err) {
for _, fd := range f.Fields {
if seen[fd.Key] {
continue
}
seen[fd.Key] = true
out = append(out, fd)
}
}
return out
}
// WhyOf and FixOf are the chain's first stated cost and remedy.
//
// Inherited rather than required at every wrap: the layer that knows
// what to DO about a saturated lane is the lane, and a caller that adds
// a sentence about its own concern should not have to restate advice it
// did not write — nor lose it by wrapping.
func WhyOf(err error) string {
for _, f := range chain(err) {
if f.Why != "" {
return f.Why
}
}
return ""
}
func FixOf(err error) string {
for _, f := range chain(err) {
if f.Fix != "" {
return f.Fix
}
}
return ""
}
// 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
}