Skip to content
quarry Artifact [d2d45bd640]
Login

Artifact [d2d45bd640]

Artifact d2d45bd640911d322c9ed5f956595f7de87a98d53e768c0a7f2f8e101a8e1ae0:


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

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