// SPDX-License-Identifier: EUPL-1.2 // Structured errors, crash recovery, and reporting for the Core framework. // Provides E() for error creation, Wrap()/WrapCode() for chaining, // and Err for panic recovery and crash reporting. package core import ( "encoding/json" "errors" "iter" "maps" "os" "path/filepath" "runtime" "runtime/debug" "sync" "time" ) // ErrorSink is the shared interface for error reporting. // Implemented by ErrorLog (structured logging) and ErrorPanic (panic recovery). type ErrorSink interface { Error(msg string, keyvals ...any) Warn(msg string, keyvals ...any) } var _ ErrorSink = (*Log)(nil) // Err represents a structured error with operational context. // It implements the error interface and supports unwrapping. type Err struct { Operation string // Operation being performed (e.g., "user.Save") Message string // Human-readable message Cause error // Underlying error (optional) Code string // Error code (optional, e.g., "VALIDATION_FAILED") } // Error implements the error interface. func (e *Err) Error() string { var prefix string if e.Operation != "" { prefix = e.Operation + ": " } if e.Cause != nil { if e.Code != "" { return Concat(prefix, e.Message, " [", e.Code, "]: ", e.Cause.Error()) } return Concat(prefix, e.Message, ": ", e.Cause.Error()) } if e.Code != "" { return Concat(prefix, e.Message, " [", e.Code, "]") } return Concat(prefix, e.Message) } // Unwrap returns the underlying error for use with errors.Is and errors.As. func (e *Err) Unwrap() error { return e.Cause } // --- Error Creation Functions --- // E creates a new Err with operation context. // The underlying error can be nil for creating errors without a cause. // // Example: // // return log.E("user.Save", "failed to save user", err) // return log.E("api.Call", "rate limited", nil) // No underlying cause func E(op, msg string, err error) error { return &Err{Operation: op, Message: msg, Cause: err} } // Wrap wraps an error with operation context. // Returns nil if err is nil, to support conditional wrapping. // Preserves error Code if the wrapped error is an *Err. // // Example: // // return log.Wrap(err, "db.Query", "database query failed") func Wrap(err error, op, msg string) error { if err == nil { return nil } // Preserve Code from wrapped *Err var logErr *Err if As(err, &logErr) && logErr.Code != "" { return &Err{Operation: op, Message: msg, Cause: err, Code: logErr.Code} } return &Err{Operation: op, Message: msg, Cause: err} } // WrapCode wraps an error with operation context and error code. // Returns nil only if both err is nil AND code is empty. // Useful for API errors that need machine-readable codes. // // Example: // // return log.WrapCode(err, "VALIDATION_ERROR", "user.Validate", "invalid email") func WrapCode(err error, code, op, msg string) error { if err == nil && code == "" { return nil } return &Err{Operation: op, Message: msg, Cause: err, Code: code} } // NewCode creates an error with just code and message (no underlying error). // Useful for creating sentinel errors with codes. // // Example: // // var ErrNotFound = log.NewCode("NOT_FOUND", "resource not found") func NewCode(code, msg string) error { return &Err{Message: msg, Code: code} } // --- Standard Library Wrappers --- // Is reports whether any error in err's tree matches target. // Wrapper around errors.Is for convenience. func Is(err, target error) bool { return errors.Is(err, target) } // As finds the first error in err's tree that matches target. // Wrapper around errors.As for convenience. func As(err error, target any) bool { return errors.As(err, target) } // NewError creates a simple error with the given text. // Wrapper around errors.New for convenience. func NewError(text string) error { return errors.New(text) } // ErrorJoin combines multiple errors into one. // // core.ErrorJoin(err1, err2, err3) func ErrorJoin(errs ...error) error { return errors.Join(errs...) } // --- Error Introspection Helpers --- // Operation extracts the operation name from an error. // Returns empty string if the error is not an *Err. func Operation(err error) string { var e *Err if As(err, &e) { return e.Operation } return "" } // ErrorCode extracts the error code from an error. // Returns empty string if the error is not an *Err or has no code. func ErrorCode(err error) string { var e *Err if As(err, &e) { return e.Code } return "" } // Message extracts the message from an error. // Returns the error's Error() string if not an *Err. func ErrorMessage(err error) string { if err == nil { return "" } var e *Err if As(err, &e) { return e.Message } return err.Error() } // Root returns the root cause of an error chain. // Unwraps until no more wrapped errors are found. func Root(err error) error { if err == nil { return nil } for { unwrapped := errors.Unwrap(err) if unwrapped == nil { return err } err = unwrapped } } // AllOperations returns an iterator over all operational contexts in the error chain. // It traverses the error tree using errors.Unwrap. func AllOperations(err error) iter.Seq[string] { return func(yield func(string) bool) { for err != nil { if e, ok := err.(*Err); ok { if e.Operation != "" { if !yield(e.Operation) { return } } } err = errors.Unwrap(err) } } } // StackTrace returns the logical stack trace (chain of operations) from an error. // It returns an empty slice if no operational context is found. func StackTrace(err error) []string { var stack []string for op := range AllOperations(err) { stack = append(stack, op) } return stack } // FormatStackTrace returns a pretty-printed logical stack trace. func FormatStackTrace(err error) string { var ops []string for op := range AllOperations(err) { ops = append(ops, op) } if len(ops) == 0 { return "" } return Join(" -> ", ops...) } // --- ErrorLog: Log-and-Return Error Helpers --- // ErrorLog combines error creation with logging. // Primary action: return an error. Secondary: log it. type ErrorLog struct { log *Log } func (el *ErrorLog) logger() *Log { if el.log != nil { return el.log } return Default() } // Error logs at Error level and returns a Result with the wrapped error. func (el *ErrorLog) Error(err error, op, msg string) Result { if err == nil { return Result{OK: true} } wrapped := Wrap(err, op, msg) el.logger().Error(msg, "op", op, "err", err) return Result{wrapped, false} } // Warn logs at Warn level and returns a Result with the wrapped error. func (el *ErrorLog) Warn(err error, op, msg string) Result { if err == nil { return Result{OK: true} } wrapped := Wrap(err, op, msg) el.logger().Warn(msg, "op", op, "err", err) return Result{wrapped, false} } // Must logs and panics if err is not nil. func (el *ErrorLog) Must(err error, op, msg string) { if err != nil { el.logger().Error(msg, "op", op, "err", err) panic(Wrap(err, op, msg)) } } // --- Crash Recovery & Reporting --- // CrashReport represents a single crash event. type CrashReport struct { Timestamp time.Time `json:"timestamp"` Error string `json:"error"` Stack string `json:"stack"` System CrashSystem `json:"system,omitempty"` Meta map[string]string `json:"meta,omitempty"` } // CrashSystem holds system information at crash time. type CrashSystem struct { OperatingSystem string `json:"operatingsystem"` Architecture string `json:"architecture"` Version string `json:"go_version"` } // ErrorPanic manages panic recovery and crash reporting. type ErrorPanic struct { filePath string meta map[string]string onCrash func(CrashReport) } // Recover captures a panic and creates a crash report. // Use as: defer c.Error().Recover() func (h *ErrorPanic) Recover() { if h == nil { return } r := recover() if r == nil { return } err, ok := r.(error) if !ok { err = NewError(Sprint("panic: ", r)) } report := CrashReport{ Timestamp: time.Now(), Error: err.Error(), Stack: string(debug.Stack()), System: CrashSystem{ OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, Version: runtime.Version(), }, Meta: maps.Clone(h.meta), } if h.onCrash != nil { h.onCrash(report) } if h.filePath != "" { h.appendReport(report) } } // SafeGo runs a function in a goroutine with panic recovery. func (h *ErrorPanic) SafeGo(fn func()) { go func() { defer h.Recover() fn() }() } // Reports returns the last n crash reports from the file. func (h *ErrorPanic) Reports(n int) Result { if h.filePath == "" { return Result{} } crashMu.Lock() defer crashMu.Unlock() data, err := os.ReadFile(h.filePath) if err != nil { return Result{err, false} } var reports []CrashReport if err := json.Unmarshal(data, &reports); err != nil { return Result{err, false} } if n <= 0 || len(reports) <= n { return Result{reports, true} } return Result{reports[len(reports)-n:], true} } var crashMu sync.Mutex func (h *ErrorPanic) appendReport(report CrashReport) { crashMu.Lock() defer crashMu.Unlock() var reports []CrashReport if data, err := os.ReadFile(h.filePath); err == nil { if err := json.Unmarshal(data, &reports); err != nil { reports = nil } } reports = append(reports, report) data, err := json.MarshalIndent(reports, "", " ") if err != nil { Default().Error(Concat("crash report marshal failed: ", err.Error())) return } if err := os.MkdirAll(filepath.Dir(h.filePath), 0755); err != nil { Default().Error(Concat("crash report dir failed: ", err.Error())) return } if err := os.WriteFile(h.filePath, data, 0600); err != nil { Default().Error(Concat("crash report write failed: ", err.Error())) } }