aboutsummaryrefslogtreecommitdiffhomepage
path: root/internal/report/report.go
diff options
context:
space:
mode:
authorOphestra <cat@gensokyo.uk>2026-05-26 13:36:28 +0900
committerOphestra <cat@gensokyo.uk>2026-05-26 14:56:16 +0900
commit12b9f51128b2683303bc9ea500c07d6f4417988f (patch)
tree001bc9f2791ba6776c4cc1337553007c6b87abd5 /internal/report/report.go
parentd15f965d0c5587e79d8da9a514f94be32d817f67 (diff)
internal/report: report errors with persistent backing
This is useful for reporting errors from processes that must never terminate. Signed-off-by: Ophestra <cat@gensokyo.uk>
Diffstat (limited to 'internal/report/report.go')
-rw-r--r--internal/report/report.go186
1 files changed, 186 insertions, 0 deletions
diff --git a/internal/report/report.go b/internal/report/report.go
new file mode 100644
index 00000000..133176da
--- /dev/null
+++ b/internal/report/report.go
@@ -0,0 +1,186 @@
+package report
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "os"
+ "strconv"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "hakurei.app/check"
+)
+
+const (
+ // Inconsistent denotes an error diagnosed due to inconsistent state.
+ Inconsistent = iota
+ // Degraded denotes an error condition causing a degraded state.
+ Degraded
+ // Fatal denotes an unrecoverable error. This is generally followed by
+ // notifying the user and suggesting immediate restart, or an automatic
+ // restart of a nonessential daemon process.
+ Fatal
+)
+
+// A Reporter represents an error reporting object backed by user-facing display
+// and optional persistent storage. A Reporter can be used simultaneously from
+// multiple goroutines.
+type Reporter struct {
+ // Backing logger, the zero value implies the return value of [log.Default].
+ log atomic.Pointer[log.Logger]
+ // Backing persistent storage.
+ pathname atomic.Pointer[check.Absolute]
+ // Reporting behaviour.
+ flag atomic.Uint64
+
+ // Caller-supplied reporting functions.
+ notify []func(e Error)
+ // Synchronises access to notify.
+ notifyMu sync.RWMutex
+
+ // Synchronises access to persistent storage.
+ mu sync.Mutex
+}
+
+// SetOutput sets the backing [log.Logger].
+func (r *Reporter) SetOutput(l *log.Logger) { r.log.Store(l) }
+
+// SetPathname sets the pathname to persistent storage.
+func (r *Reporter) SetPathname(a *check.Absolute) { r.pathname.Store(a) }
+
+const (
+ // DStrict causes all dispatches to end in a call to panic.
+ DStrict = 1 << iota
+ // DNoRecover disallows recovering from error write to persistent storage.
+ DNoRecover
+ // DBypassEarly allows persistent storage to be skipped before it is ready.
+ DBypassEarly
+)
+
+// SetFlags sets flags for r.
+func (r *Reporter) SetFlags(flag uint64) { r.flag.Store(flag) }
+
+// An Error represents an error reported via [Reporter.Dispatch].
+type Error struct {
+ // Nature of error.
+ Severity int
+ // User-facing reporting message.
+ Message string
+ // Underlying error.
+ Err error
+}
+
+// RepresentableError is implemented by errors friendly to JSON serialisation.
+type RepresentableError interface {
+ error
+ Representable()
+}
+
+// MarshalJSON returns an incomplete JSON representation of e.
+func (e Error) MarshalJSON() (data []byte, err error) {
+ v := struct {
+ Severity any `json:"kind"`
+ Message string `json:"message"`
+ Err json.RawMessage `json:"error"`
+ }{Message: e.Message}
+
+ switch e.Severity {
+ case Inconsistent:
+ v.Severity = "inconsistent"
+ case Degraded:
+ v.Severity = "degradation"
+ case Fatal:
+ v.Severity = "fatal"
+ default:
+ v.Severity = e.Severity
+ }
+
+ var re RepresentableError
+ if errors.As(e.Err, &re) {
+ v.Err, err = json.Marshal(re)
+ } else {
+ v.Err, err = json.Marshal(e.Err.Error())
+ }
+ if err != nil {
+ return
+ }
+ return json.Marshal(&v)
+}
+
+// Error is generally only called during a [Reporter.Dispatch] call ending in
+// a panic or an otherwise unrecoverable condition.
+func (e Error) Error() string {
+ return fmt.Sprintf("%s: %v", e.Message, e.Err)
+}
+
+// Notify arranges for f to be called for all future dispatched errors.
+func (r *Reporter) Notify(f func(Error)) {
+ r.notifyMu.Lock()
+ r.notify = append(r.notify, f)
+ r.notifyMu.Unlock()
+}
+
+// getLog returns the address of the underlying [log.Logger], or the return
+// value of [log.Default].
+func (r *Reporter) getLog() *log.Logger {
+ l := r.log.Load()
+ if l == nil {
+ l = log.Default()
+ }
+ return l
+}
+
+// dispatch implements Dispatch but returns the reporting error.
+func (r *Reporter) dispatch(severity int, message string, e error) (err error) {
+ p := Error{severity, message, e}
+ r.getLog().Println(message+":", e)
+
+ flag := r.flag.Load()
+ defer func() {
+ if flag&DNoRecover != 0 && err != nil {
+ panic(err)
+ }
+ }()
+
+ if a := r.pathname.Load(); a != nil {
+ var w *os.File
+ r.mu.Lock()
+ w, err = os.OpenFile(
+ a.Append(strconv.FormatUint(uint64(time.Now().UnixNano()), 10)).String(),
+ os.O_CREATE|os.O_EXCL|os.O_WRONLY,
+ 0400,
+ )
+ if err != nil {
+ r.mu.Unlock()
+ return
+ }
+ err = json.NewEncoder(w).Encode(p)
+ if _err := w.Close(); err == nil {
+ err = _err
+ }
+ r.mu.Unlock()
+ } else if flag&DBypassEarly == 0 {
+ panic(p)
+ }
+
+ if flag&DStrict != 0 {
+ panic(p)
+ }
+
+ r.notifyMu.RLock()
+ defer r.notifyMu.RUnlock()
+ for _, f := range r.notify {
+ f(p)
+ }
+ return
+}
+
+// Dispatch reports an error and saves it to backing storage if available.
+func (r *Reporter) Dispatch(severity int, message string, e error) {
+ if err := r.dispatch(severity, message, e); err != nil {
+ r.getLog().Println(err)
+ }
+}