diff --git a/docs/architecture.md b/docs/architecture.md index 08c87ad..d36f7f0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -63,6 +63,9 @@ type Err struct { Msg string // human-readable description Err error // underlying cause (optional) Code string // machine-readable code (optional, e.g. "VALIDATION_FAILED") + Retryable bool // whether the caller can retry the operation + RetryAfter *time.Duration // optional retry delay hint + NextAction string // suggested next step when not retryable } ``` @@ -115,6 +118,9 @@ log(LevelInfo, "[INF]", ...) | if any value implements `error`: | extract Op -> append "op" key if not already present | extract FormatStackTrace -> append "stack" key if not already present + | extract recovery hints -> append "retryable", + | "retry_after_seconds", + | "next_action" if not already present +-- format key-value pairs: | string values -> %q (quoted, injection-safe) | other values -> %v diff --git a/docs/index.md b/docs/index.md index 945ed86..da399e9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,7 +5,7 @@ description: Structured logging and error handling for Core applications # go-log -`forge.lthn.ai/core/go-log` provides structured logging and contextual error +`dappco.re/go/core/log` provides structured logging and contextual error handling for Go applications built on the Core framework. It is a small, zero-dependency library (only `testify` at test time) that replaces ad-hoc `fmt.Println` / `log.Printf` calls with level-filtered, key-value structured @@ -15,7 +15,7 @@ stack. ## Quick Start ```go -import "forge.lthn.ai/core/go-log" +import "dappco.re/go/core/log" // Use the package-level default logger straight away log.SetLevel(log.LevelDebug) @@ -54,6 +54,10 @@ log.Op(err) // "user.Save" log.Root(err) // the original underlyingErr log.StackTrace(err) // ["user.Save", "db.Connect"] log.FormatStackTrace(err) // "user.Save -> db.Connect" + +// Recovery hints are also available when the error carries them +log.IsRetryable(err) // false unless a wrapped Err marks it retryable +log.RecoveryAction(err) // "retry with backoff" when provided ``` ### Combined Log-and-Return @@ -70,7 +74,7 @@ if err != nil { | File | Purpose | |------|---------| | `log.go` | Logger type, log levels, key-value formatting, redaction, default logger, `Username()` helper | -| `errors.go` | `Err` structured error type, creation helpers (`E`, `Wrap`, `WrapCode`, `NewCode`), introspection (`Op`, `ErrCode`, `Root`, `StackTrace`), combined log-and-return helpers (`LogError`, `LogWarn`, `Must`) | +| `errors.go` | `Err` structured error type, creation helpers (`E`, `Wrap`, `WrapCode`, `NewCode`, and recovery-aware variants), introspection (`Op`, `ErrCode`, `Root`, `StackTrace`, recovery hints), combined log-and-return helpers (`LogError`, `LogWarn`, `Must`) | | `log_test.go` | Tests for the Logger: level filtering, key-value output, redaction, injection prevention, security logging | | `errors_test.go` | Tests for structured errors: creation, wrapping, code propagation, introspection, stack traces, log-and-return helpers | @@ -89,7 +93,7 @@ code. ## Module Path ``` -forge.lthn.ai/core/go-log +dappco.re/go/core/log ``` Requires **Go 1.26+** (uses `iter.Seq` from the standard library). diff --git a/errors.go b/errors.go index 01056ea..5064555 100644 --- a/errors.go +++ b/errors.go @@ -7,9 +7,9 @@ package log import ( "errors" - "fmt" "iter" "strings" + "time" ) // Err represents a structured error with operational context. @@ -19,24 +19,44 @@ type Err struct { Msg string // Human-readable message Err error // Underlying error (optional) Code string // Error code (optional, e.g., "VALIDATION_FAILED") + // Retryable indicates whether the caller can safely retry this error. + Retryable bool + // RetryAfter suggests a delay before retrying when Retryable is true. + RetryAfter *time.Duration + // NextAction suggests an alternative path when this error is not directly retryable. + NextAction string } // Error implements the error interface. func (e *Err) Error() string { - var prefix string - if e.Op != "" { - prefix = e.Op + ": " + if e == nil { + return "" } - if e.Err != nil { + + body := e.Msg + if body == "" { if e.Code != "" { - return fmt.Sprintf("%s%s [%s]: %v", prefix, e.Msg, e.Code, e.Err) + body = "[" + e.Code + "]" } - return fmt.Sprintf("%s%s: %v", prefix, e.Msg, e.Err) + } else if e.Code != "" { + body += " [" + e.Code + "]" } - if e.Code != "" { - return fmt.Sprintf("%s%s [%s]", prefix, e.Msg, e.Code) + + if e.Err != nil { + if body != "" { + body += ": " + e.Err.Error() + } else { + body = e.Err.Error() + } } - return fmt.Sprintf("%s%s", prefix, e.Msg) + + if e.Op != "" { + if body != "" { + return e.Op + ": " + body + } + return e.Op + } + return body } // Unwrap returns the underlying error for use with errors.Is and errors.As. @@ -57,6 +77,22 @@ func E(op, msg string, err error) error { return &Err{Op: op, Msg: msg, Err: err} } +// EWithRecovery creates a new Err with operation context and recovery metadata. +// +// return log.EWithRecovery("api.Call", "temporary failure", err, true, &retryAfter, "retry with backoff") +func EWithRecovery(op, msg string, err error, retryable bool, retryAfter *time.Duration, nextAction string) error { + recoveryErr := &Err{ + Op: op, + Msg: msg, + Err: err, + } + inheritRecovery(recoveryErr, err) + recoveryErr.Retryable = retryable + recoveryErr.RetryAfter = retryAfter + recoveryErr.NextAction = nextAction + return recoveryErr +} + // 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. @@ -68,12 +104,29 @@ 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{Op: op, Msg: msg, Err: err, Code: logErr.Code} + wrapped := &Err{Op: op, Msg: msg, Err: err, Code: inheritedCode(err)} + inheritRecovery(wrapped, err) + return wrapped +} + +// WrapWithRecovery wraps an error with operation context and explicit recovery metadata. +// +// return log.WrapWithRecovery(err, "api.Call", "temporary failure", true, &retryAfter, "retry with backoff") +func WrapWithRecovery(err error, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error { + if err == nil { + return nil + } + recoveryErr := &Err{ + Op: op, + Msg: msg, + Err: err, + Code: ErrCode(err), } - return &Err{Op: op, Msg: msg, Err: err} + inheritRecovery(recoveryErr, err) + recoveryErr.Retryable = retryable + recoveryErr.RetryAfter = retryAfter + recoveryErr.NextAction = nextAction + return recoveryErr } // WrapCode wraps an error with operation context and error code. @@ -87,7 +140,29 @@ func WrapCode(err error, code, op, msg string) error { if err == nil && code == "" { return nil } - return &Err{Op: op, Msg: msg, Err: err, Code: code} + wrapped := &Err{Op: op, Msg: msg, Err: err, Code: code} + inheritRecovery(wrapped, err) + return wrapped +} + +// WrapCodeWithRecovery wraps an error with operation context, code, and recovery metadata. +// +// return log.WrapCodeWithRecovery(err, "TEMPORARY_UNAVAILABLE", "api.Call", "temporary failure", true, &retryAfter, "retry with backoff") +func WrapCodeWithRecovery(err error, code, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error { + if err == nil && code == "" { + return nil + } + recoveryErr := &Err{ + Op: op, + Msg: msg, + Err: err, + Code: code, + } + inheritRecovery(recoveryErr, err) + recoveryErr.Retryable = retryable + recoveryErr.RetryAfter = retryAfter + recoveryErr.NextAction = nextAction + return recoveryErr } // NewCode creates an error with just code and message (no underlying error). @@ -100,28 +175,121 @@ func NewCode(code, msg string) error { return &Err{Msg: msg, Code: code} } +// NewCodeWithRecovery creates a coded error with recovery metadata. +// +// var ErrTemporary = log.NewCodeWithRecovery("TEMPORARY_UNAVAILABLE", "temporary failure", true, &retryAfter, "retry with backoff") +func NewCodeWithRecovery(code, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error { + return &Err{ + Msg: msg, + Code: code, + Retryable: retryable, + RetryAfter: retryAfter, + NextAction: nextAction, + } +} + +// inheritRecovery copies recovery metadata from the first *Err in err's chain. +func inheritRecovery(dst *Err, err error) { + if err == nil || dst == nil { + return + } + var source *Err + if As(err, &source) { + dst.Retryable = source.Retryable + dst.RetryAfter = source.RetryAfter + dst.NextAction = source.NextAction + } +} + +// inheritedCode returns the first non-empty code found in an error chain. +func inheritedCode(err error) string { + for err != nil { + if wrapped, ok := err.(*Err); ok && wrapped.Code != "" { + return wrapped.Code + } + err = errors.Unwrap(err) + } + return "" +} + +// RetryAfter returns the first retry-after hint from an error chain, if present. +// +// retryAfter, ok := log.RetryAfter(err) +func RetryAfter(err error) (*time.Duration, bool) { + for err != nil { + if wrapped, ok := err.(*Err); ok && wrapped.RetryAfter != nil { + return wrapped.RetryAfter, true + } + err = errors.Unwrap(err) + } + return nil, false +} + +// IsRetryable reports whether the error chain contains a retryable Err. +// +// if log.IsRetryable(err) { /* retry the operation */ } +func IsRetryable(err error) bool { + var wrapped *Err + if As(err, &wrapped) { + return wrapped.Retryable + } + return false +} + +// RecoveryAction returns the first next action from an error chain. +// +// next := log.RecoveryAction(err) +func RecoveryAction(err error) string { + for err != nil { + if wrapped, ok := err.(*Err); ok && wrapped.NextAction != "" { + return wrapped.NextAction + } + err = errors.Unwrap(err) + } + return "" +} + +func retryableHint(err error) bool { + for err != nil { + if wrapped, ok := err.(*Err); ok && wrapped.Retryable { + return true + } + err = errors.Unwrap(err) + } + return false +} + // --- Standard Library Wrappers --- // Is reports whether any error in err's tree matches target. // Wrapper around errors.Is for convenience. +// +// if log.Is(err, context.DeadlineExceeded) { /* handle timeout */ } 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. +// +// var e *log.Err +// if log.As(err, &e) { /* use e.Code */ } 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. +// +// return log.NewError("invalid state") func NewError(text string) error { return errors.New(text) } // Join combines multiple errors into one. // Wrapper around errors.Join for convenience. +// +// return log.Join(validateErr, persistErr) func Join(errs ...error) error { return errors.Join(errs...) } @@ -130,6 +298,8 @@ func Join(errs ...error) error { // Op extracts the operation name from an error. // Returns empty string if the error is not an *Err. +// +// op := log.Op(err) // e.g. "user.Save" func Op(err error) string { var e *Err if As(err, &e) { @@ -140,6 +310,8 @@ func Op(err error) string { // ErrCode extracts the error code from an error. // Returns empty string if the error is not an *Err or has no code. +// +// code := log.ErrCode(err) // e.g. "VALIDATION_FAILED" func ErrCode(err error) string { var e *Err if As(err, &e) { @@ -150,6 +322,8 @@ func ErrCode(err error) string { // Message extracts the message from an error. // Returns the error's Error() string if not an *Err. +// +// msg := log.Message(err) func Message(err error) string { if err == nil { return "" @@ -163,6 +337,8 @@ func Message(err error) string { // Root returns the root cause of an error chain. // Unwraps until no more wrapped errors are found. +// +// cause := log.Root(err) func Root(err error) error { if err == nil { return nil @@ -178,6 +354,8 @@ func Root(err error) error { // AllOps returns an iterator over all operational contexts in the error chain. // It traverses the error tree using errors.Unwrap. +// +// for op := range log.AllOps(err) { /* "api.Call" → "db.Query" → ... */ } func AllOps(err error) iter.Seq[string] { return func(yield func(string) bool) { for err != nil { @@ -195,6 +373,8 @@ func AllOps(err error) iter.Seq[string] { // StackTrace returns the logical stack trace (chain of operations) from an error. // It returns an empty slice if no operational context is found. +// +// ops := log.StackTrace(err) // ["api.Call", "db.Query", "sql.Exec"] func StackTrace(err error) []string { var stack []string for op := range AllOps(err) { @@ -204,6 +384,8 @@ func StackTrace(err error) []string { } // FormatStackTrace returns a pretty-printed logical stack trace. +// +// trace := log.FormatStackTrace(err) // "api.Call -> db.Query -> sql.Exec" func FormatStackTrace(err error) string { var ops []string for op := range AllOps(err) { @@ -237,7 +419,7 @@ func LogError(err error, op, msg string) error { return nil } wrapped := Wrap(err, op, msg) - defaultLogger.Error(msg, "op", op, "err", err) + Default().Error(msg, "op", op, "err", err) return wrapped } @@ -252,7 +434,7 @@ func LogWarn(err error, op, msg string) error { return nil } wrapped := Wrap(err, op, msg) - defaultLogger.Warn(msg, "op", op, "err", err) + Default().Warn(msg, "op", op, "err", err) return wrapped } @@ -264,7 +446,8 @@ func LogWarn(err error, op, msg string) error { // log.Must(Initialize(), "app", "startup failed") func Must(err error, op, msg string) { if err != nil { - defaultLogger.Error(msg, "op", op, "err", err) - panic(Wrap(err, op, msg)) + wrapped := Wrap(err, op, msg) + Default().Error(msg, "op", op, "err", err) + panic(wrapped) } } diff --git a/errors_test.go b/errors_test.go index 574865e..9dde46e 100644 --- a/errors_test.go +++ b/errors_test.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -44,6 +45,20 @@ func TestErr_Error_EmptyOp_Good(t *testing.T) { assert.Equal(t, "wrapped: underlying", err.Error()) } +func TestErr_Error_EmptyMsg_Good(t *testing.T) { + err := &Err{Op: "api.Call", Code: "TIMEOUT"} + assert.Equal(t, "api.Call: [TIMEOUT]", err.Error()) + + err = &Err{Op: "api.Call", Err: errors.New("underlying")} + assert.Equal(t, "api.Call: underlying", err.Error()) + + err = &Err{Op: "api.Call", Code: "TIMEOUT", Err: errors.New("underlying")} + assert.Equal(t, "api.Call: [TIMEOUT]: underlying", err.Error()) + + err = &Err{Op: "api.Call"} + assert.Equal(t, "api.Call", err.Error()) +} + func TestErr_Unwrap_Good(t *testing.T) { underlying := errors.New("underlying error") err := &Err{Op: "test", Msg: "wrapped", Err: underlying} @@ -73,6 +88,20 @@ func TestE_Good_NilError(t *testing.T) { assert.Equal(t, "op.Name: message", err.Error()) } +func TestEWithRecovery_Good(t *testing.T) { + retryAfter := time.Second * 5 + err := EWithRecovery("op.Name", "message", nil, true, &retryAfter, "retry once") + + var logErr *Err + assert.NotNil(t, err) + assert.True(t, As(err, &logErr)) + assert.True(t, logErr.Retryable) + if assert.NotNil(t, logErr.RetryAfter) { + assert.Equal(t, retryAfter, *logErr.RetryAfter) + } + assert.Equal(t, "retry once", logErr.NextAction) +} + func TestWrap_Good(t *testing.T) { underlying := errors.New("base") err := Wrap(underlying, "handler.Process", "processing failed") @@ -95,6 +124,41 @@ func TestWrap_PreservesCode_Good(t *testing.T) { assert.Contains(t, outer.Error(), "[VALIDATION_ERROR]") } +func TestWrap_PreservesCode_FromNestedErrWithEmptyOuterCode_Good(t *testing.T) { + inner := WrapCode(errors.New("base"), "VALIDATION_ERROR", "inner.Op", "validation failed") + mid := &Err{Op: "mid.Op", Msg: "mid failed", Err: inner} + + outer := Wrap(mid, "outer.Op", "outer context") + + assert.NotNil(t, outer) + assert.Equal(t, "VALIDATION_ERROR", ErrCode(outer)) + assert.Contains(t, outer.Error(), "[VALIDATION_ERROR]") +} + +func TestWrap_PreservesRecovery_Good(t *testing.T) { + retryAfter := 15 * time.Second + inner := &Err{Msg: "inner", Retryable: true, RetryAfter: &retryAfter, NextAction: "inspect input"} + + outer := Wrap(inner, "outer.Op", "outer context") + + assert.NotNil(t, outer) + var logErr *Err + assert.True(t, As(outer, &logErr)) + assert.True(t, logErr.Retryable) + if assert.NotNil(t, logErr.RetryAfter) { + assert.Equal(t, retryAfter, *logErr.RetryAfter) + } + assert.Equal(t, "inspect input", logErr.NextAction) +} + +func TestWrap_PreservesCode_FromNestedChain_Good(t *testing.T) { + root := WrapCode(errors.New("base"), "CHAIN_ERROR", "inner", "inner failed") + wrapped := Wrap(fmt.Errorf("mid layer: %w", root), "outer", "outer context") + + assert.Equal(t, "CHAIN_ERROR", ErrCode(wrapped)) + assert.Contains(t, wrapped.Error(), "[CHAIN_ERROR]") +} + func TestWrap_NilError_Good(t *testing.T) { err := Wrap(nil, "op", "msg") assert.Nil(t, err) @@ -112,6 +176,41 @@ func TestWrapCode_Good(t *testing.T) { assert.Contains(t, err.Error(), "[INVALID_INPUT]") } +func TestWrapCode_Good_EmptyCodeDoesNotInherit(t *testing.T) { + inner := WrapCode(errors.New("base"), "INNER_CODE", "inner.Op", "inner failed") + + outer := WrapCode(inner, "", "outer.Op", "outer failed") + + var logErr *Err + assert.True(t, As(outer, &logErr)) + assert.Equal(t, "", logErr.Code) +} + +func TestWrapCodeWithRecovery_Good(t *testing.T) { + retryAfter := time.Minute + err := WrapCodeWithRecovery(errors.New("validation failed"), "INVALID_INPUT", "api.Validate", "bad request", true, &retryAfter, "retry with backoff") + + var logErr *Err + assert.NotNil(t, err) + assert.True(t, As(err, &logErr)) + assert.True(t, logErr.Retryable) + assert.NotNil(t, logErr.RetryAfter) + assert.Equal(t, retryAfter, *logErr.RetryAfter) + assert.Equal(t, "retry with backoff", logErr.NextAction) + assert.Equal(t, "INVALID_INPUT", logErr.Code) +} + +func TestWrapCodeWithRecovery_Good_EmptyCodeDoesNotInherit(t *testing.T) { + retryAfter := time.Minute + inner := WrapCodeWithRecovery(errors.New("validation failed"), "INNER_CODE", "inner.Op", "inner failed", true, &retryAfter, "retry later") + + outer := WrapCodeWithRecovery(inner, "", "outer.Op", "outer failed", true, &retryAfter, "retry later") + + var logErr *Err + assert.True(t, As(outer, &logErr)) + assert.Equal(t, "", logErr.Code) +} + func TestWrapCode_Good_NilError(t *testing.T) { // WrapCode with nil error but with code still creates an error err := WrapCode(nil, "CODE", "op", "msg") @@ -133,6 +232,19 @@ func TestNewCode_Good(t *testing.T) { assert.Nil(t, logErr.Err) } +func TestNewCodeWithRecovery_Good(t *testing.T) { + retryAfter := 2 * time.Minute + err := NewCodeWithRecovery("NOT_FOUND", "resource not found", false, &retryAfter, "contact support") + + var logErr *Err + assert.NotNil(t, err) + assert.True(t, As(err, &logErr)) + assert.False(t, logErr.Retryable) + assert.NotNil(t, logErr.RetryAfter) + assert.Equal(t, retryAfter, *logErr.RetryAfter) + assert.Equal(t, "contact support", logErr.NextAction) +} + // --- Standard Library Wrapper Tests --- func TestIs_Good(t *testing.T) { @@ -197,6 +309,42 @@ func TestErrCode_Good_Nil(t *testing.T) { assert.Equal(t, "", ErrCode(nil)) } +func TestRetryAfter_Good(t *testing.T) { + retryAfter := 42 * time.Second + err := &Err{Msg: "typed", RetryAfter: &retryAfter} + + got, ok := RetryAfter(err) + assert.True(t, ok) + assert.Equal(t, retryAfter, *got) +} + +func TestRetryAfter_Good_NestedChain(t *testing.T) { + retryAfter := 42 * time.Second + inner := &Err{Msg: "typed", RetryAfter: &retryAfter} + outer := &Err{Msg: "outer", Err: inner} + + got, ok := RetryAfter(outer) + assert.True(t, ok) + assert.Equal(t, retryAfter, *got) +} + +func TestIsRetryable_Good(t *testing.T) { + err := &Err{Msg: "typed", Retryable: true} + assert.True(t, IsRetryable(err)) +} + +func TestRecoveryAction_Good(t *testing.T) { + err := &Err{Msg: "typed", NextAction: "inspect"} + assert.Equal(t, "inspect", RecoveryAction(err)) +} + +func TestRecoveryAction_Good_NestedChain(t *testing.T) { + inner := &Err{Msg: "typed", NextAction: "inspect"} + outer := &Err{Msg: "outer", Err: inner} + + assert.Equal(t, "inspect", RecoveryAction(outer)) +} + func TestMessage_Good(t *testing.T) { err := E("op", "the message", errors.New("base")) assert.Equal(t, "the message", Message(err)) @@ -253,6 +401,23 @@ func TestLogError_Good(t *testing.T) { assert.Contains(t, output, "op=\"db.Connect\"") } +func TestLogError_Good_LogsOriginalErrorContext(t *testing.T) { + var buf bytes.Buffer + logger := New(Options{Level: LevelDebug, Output: &buf}) + SetDefault(logger) + defer SetDefault(New(Options{Level: LevelInfo})) + + underlying := E("db.Query", "query failed", errors.New("timeout")) + err := LogError(underlying, "db.Connect", "database unavailable") + + assert.NotNil(t, err) + + output := buf.String() + assert.Contains(t, output, "op=\"db.Connect\"") + assert.Contains(t, output, "stack=\"db.Query\"") + assert.NotContains(t, output, "stack=\"db.Connect -> db.Query\"") +} + func TestLogError_Good_NilError(t *testing.T) { var buf bytes.Buffer logger := New(Options{Level: LevelDebug, Output: &buf}) @@ -328,18 +493,18 @@ func TestStackTrace_Good(t *testing.T) { assert.Equal(t, "op3 -> op2 -> op1", formatted) } -func TestStackTrace_PlainError(t *testing.T) { +func TestStackTrace_Bad_PlainError(t *testing.T) { err := errors.New("plain error") assert.Empty(t, StackTrace(err)) assert.Empty(t, FormatStackTrace(err)) } -func TestStackTrace_Nil(t *testing.T) { +func TestStackTrace_Bad_Nil(t *testing.T) { assert.Empty(t, StackTrace(nil)) assert.Empty(t, FormatStackTrace(nil)) } -func TestStackTrace_NoOp(t *testing.T) { +func TestStackTrace_Bad_NoOp(t *testing.T) { err := &Err{Msg: "no op"} assert.Empty(t, StackTrace(err)) assert.Empty(t, FormatStackTrace(err)) diff --git a/log.go b/log.go index be73deb..78880dc 100644 --- a/log.go +++ b/log.go @@ -11,6 +11,7 @@ import ( "os" "os/user" "slices" + "strings" "sync" "time" ) @@ -32,6 +33,19 @@ const ( LevelDebug ) +const ( + defaultRotationMaxSize = 100 + defaultRotationMaxAge = 28 + defaultRotationMaxBackups = 5 +) + +func normaliseLevel(level Level) Level { + if level < LevelQuiet || level > LevelDebug { + return LevelInfo + } + return level +} + // String returns the level name. func (l Level) String() string { switch l { @@ -59,13 +73,18 @@ type Logger struct { // RedactKeys is a list of keys whose values should be masked in logs. redactKeys []string - // Style functions for formatting (can be overridden) + // StyleTimestamp formats the rendered timestamp prefix. StyleTimestamp func(string) string - StyleDebug func(string) string - StyleInfo func(string) string - StyleWarn func(string) string - StyleError func(string) string - StyleSecurity func(string) string + // StyleDebug formats the debug level prefix. + StyleDebug func(string) string + // StyleInfo formats the info level prefix. + StyleInfo func(string) string + // StyleWarn formats the warning level prefix. + StyleWarn func(string) string + // StyleError formats the error level prefix. + StyleError func(string) string + // StyleSecurity formats the security event prefix. + StyleSecurity func(string) string } // RotationOptions defines the log rotation and retention policy. @@ -93,6 +112,7 @@ type RotationOptions struct { // Options configures a Logger. type Options struct { + // Level controls which messages are emitted. Level Level // Output is the destination for log messages. If Rotation is provided, // Output is ignored and logs are written to the rotating file instead. @@ -108,17 +128,25 @@ type Options struct { var RotationWriterFactory func(RotationOptions) goio.WriteCloser // New creates a new Logger with the given options. +// +// logger := log.New(log.Options{ +// Level: log.LevelInfo, +// Output: os.Stdout, +// RedactKeys: []string{"password", "token"}, +// }) func New(opts Options) *Logger { + level := normaliseLevel(opts.Level) + output := opts.Output if opts.Rotation != nil && opts.Rotation.Filename != "" && RotationWriterFactory != nil { - output = RotationWriterFactory(*opts.Rotation) + output = RotationWriterFactory(normaliseRotationOptions(*opts.Rotation)) } if output == nil { output = os.Stderr } return &Logger{ - level: opts.Level, + level: level, output: output, redactKeys: slices.Clone(opts.RedactKeys), StyleTimestamp: identity, @@ -130,16 +158,40 @@ func New(opts Options) *Logger { } } +func normaliseRotationOptions(opts RotationOptions) RotationOptions { + if opts.MaxSize <= 0 { + opts.MaxSize = defaultRotationMaxSize + } + if opts.MaxAge == 0 { + opts.MaxAge = defaultRotationMaxAge + } + if opts.MaxBackups <= 0 { + opts.MaxBackups = defaultRotationMaxBackups + } + return opts +} + func identity(s string) string { return s } +func safeStyle(style func(string) string) func(string) string { + if style == nil { + return identity + } + return style +} + // SetLevel changes the log level. +// +// logger.SetLevel(log.LevelDebug) func (l *Logger) SetLevel(level Level) { l.mu.Lock() - l.level = level + l.level = normaliseLevel(level) l.mu.Unlock() } // Level returns the current log level. +// +// current := logger.Level() func (l *Logger) Level() Level { l.mu.RLock() defer l.mu.RUnlock() @@ -147,13 +199,20 @@ func (l *Logger) Level() Level { } // SetOutput changes the output writer. +// +// logger.SetOutput(os.Stdout) func (l *Logger) SetOutput(w goio.Writer) { + if w == nil { + w = os.Stderr + } l.mu.Lock() l.output = w l.mu.Unlock() } // SetRedactKeys sets the keys to be redacted. +// +// logger.SetRedactKeys("password", "token", "secret") func (l *Logger) SetRedactKeys(keys ...string) { l.mu.Lock() l.redactKeys = slices.Clone(keys) @@ -167,47 +226,67 @@ func (l *Logger) shouldLog(level Level) bool { } func (l *Logger) log(level Level, prefix, msg string, keyvals ...any) { + _ = level l.mu.RLock() output := l.output styleTimestamp := l.StyleTimestamp redactKeys := l.redactKeys l.mu.RUnlock() + if styleTimestamp == nil { + styleTimestamp = identity + } timestamp := styleTimestamp(time.Now().Format("15:04:05")) + existing := make(map[string]struct{}, len(keyvals)/2+2) + for i := 0; i < len(keyvals); i += 2 { + if key, ok := keyvals[i].(string); ok { + existing[key] = struct{}{} + } + } + // Automatically extract context from error if present in keyvals origLen := len(keyvals) for i := 0; i < origLen; i += 2 { - if i+1 < origLen { - if err, ok := keyvals[i+1].(error); ok { - if op := Op(err); op != "" { - // Check if op is already in keyvals - hasOp := false - for j := 0; j < len(keyvals); j += 2 { - if k, ok := keyvals[j].(string); ok && k == "op" { - hasOp = true - break - } - } - if !hasOp { - keyvals = append(keyvals, "op", op) - } + if i+1 >= origLen { + continue + } + err, ok := keyvals[i+1].(error) + if !ok { + continue + } + var logErr *Err + if As(err, &logErr) { + if _, hasRetryable := existing["retryable"]; !hasRetryable { + existing["retryable"] = struct{}{} + keyvals = append(keyvals, "retryable", retryableHint(err)) + } + if retryAfter, ok := RetryAfter(err); ok { + if _, hasRetryAfter := existing["retry_after_seconds"]; !hasRetryAfter { + existing["retry_after_seconds"] = struct{}{} + keyvals = append(keyvals, "retry_after_seconds", retryAfter.Seconds()) } - if stack := FormatStackTrace(err); stack != "" { - // Check if stack is already in keyvals - hasStack := false - for j := 0; j < len(keyvals); j += 2 { - if k, ok := keyvals[j].(string); ok && k == "stack" { - hasStack = true - break - } - } - if !hasStack { - keyvals = append(keyvals, "stack", stack) - } + } + if nextAction := RecoveryAction(err); nextAction != "" { + if _, hasNextAction := existing["next_action"]; !hasNextAction { + existing["next_action"] = struct{}{} + keyvals = append(keyvals, "next_action", nextAction) } } } + + if op := Op(err); op != "" { + if _, hasOp := existing["op"]; !hasOp { + existing["op"] = struct{}{} + keyvals = append(keyvals, "op", op) + } + } + if stack := FormatStackTrace(err); stack != "" { + if _, hasStack := existing["stack"]; !hasStack { + existing["stack"] = struct{}{} + keyvals = append(keyvals, "stack", stack) + } + } } // Format key-value pairs @@ -218,69 +297,95 @@ func (l *Logger) log(level Level, prefix, msg string, keyvals ...any) { if i > 0 { kvStr += " " } - key := keyvals[i] + key := normaliseLogText(fmt.Sprintf("%v", keyvals[i])) var val any if i+1 < len(keyvals) { val = keyvals[i+1] } // Redaction logic - keyStr := fmt.Sprintf("%v", key) - if slices.Contains(redactKeys, keyStr) { + if shouldRedact(key, redactKeys) { val = "[REDACTED]" } // Secure formatting to prevent log injection if s, ok := val.(string); ok { - kvStr += fmt.Sprintf("%v=%q", key, s) + kvStr += fmt.Sprintf("%s=%q", key, s) } else { - kvStr += fmt.Sprintf("%v=%v", key, val) + kvStr += fmt.Sprintf("%s=%v", key, normaliseLogText(fmt.Sprintf("%v", val))) } } } - _, _ = fmt.Fprintf(output, "%s %s %s%s\n", timestamp, prefix, msg, kvStr) + _, _ = fmt.Fprintf(output, "%s %s %s%s\n", timestamp, prefix, normaliseLogText(msg), kvStr) } // Debug logs a debug message with optional key-value pairs. +// +// logger.Debug("processing request", "method", "GET", "path", "/api/users") func (l *Logger) Debug(msg string, keyvals ...any) { if l.shouldLog(LevelDebug) { - l.log(LevelDebug, l.StyleDebug("[DBG]"), msg, keyvals...) + l.mu.RLock() + style := safeStyle(l.StyleDebug) + l.mu.RUnlock() + l.log(LevelDebug, style("[DBG]"), msg, keyvals...) } } // Info logs an info message with optional key-value pairs. +// +// logger.Info("server started", "port", 8080) func (l *Logger) Info(msg string, keyvals ...any) { if l.shouldLog(LevelInfo) { - l.log(LevelInfo, l.StyleInfo("[INF]"), msg, keyvals...) + l.mu.RLock() + style := safeStyle(l.StyleInfo) + l.mu.RUnlock() + l.log(LevelInfo, style("[INF]"), msg, keyvals...) } } // Warn logs a warning message with optional key-value pairs. +// +// logger.Warn("high memory usage", "percent", 92) func (l *Logger) Warn(msg string, keyvals ...any) { if l.shouldLog(LevelWarn) { - l.log(LevelWarn, l.StyleWarn("[WRN]"), msg, keyvals...) + l.mu.RLock() + style := safeStyle(l.StyleWarn) + l.mu.RUnlock() + l.log(LevelWarn, style("[WRN]"), msg, keyvals...) } } // Error logs an error message with optional key-value pairs. +// +// logger.Error("database connection failed", "err", err, "host", "db.local") func (l *Logger) Error(msg string, keyvals ...any) { if l.shouldLog(LevelError) { - l.log(LevelError, l.StyleError("[ERR]"), msg, keyvals...) + l.mu.RLock() + style := safeStyle(l.StyleError) + l.mu.RUnlock() + l.log(LevelError, style("[ERR]"), msg, keyvals...) } } // Security logs a security event with optional key-value pairs. // It uses LevelError to ensure security events are visible even in restrictive // log configurations. +// +// logger.Security("brute force detected", "ip", remoteAddr, "attempts", 50) func (l *Logger) Security(msg string, keyvals ...any) { if l.shouldLog(LevelError) { - l.log(LevelError, l.StyleSecurity("[SEC]"), msg, keyvals...) + l.mu.RLock() + style := safeStyle(l.StyleSecurity) + l.mu.RUnlock() + l.log(LevelError, style("[SEC]"), msg, keyvals...) } } // Username returns the current system username. // It uses os/user for reliability and falls back to environment variables. +// +// user := log.Username() func Username() string { if u, err := user.Current(); err == nil { return u.Username @@ -289,54 +394,104 @@ func Username() string { if u := os.Getenv("USER"); u != "" { return u } - return os.Getenv("USERNAME") + if u := os.Getenv("USERNAME"); u != "" { + return u + } + return "unknown" +} + +var logTextCleaner = strings.NewReplacer( + "\r", "\\r", + "\n", "\\n", + "\t", "\\t", +) + +func normaliseLogText(text string) string { + return logTextCleaner.Replace(text) } // --- Default logger --- var defaultLogger = New(Options{Level: LevelInfo}) +var defaultLoggerMu sync.RWMutex // Default returns the default logger. +// +// logger := log.Default() func Default() *Logger { + defaultLoggerMu.RLock() + defer defaultLoggerMu.RUnlock() return defaultLogger } // SetDefault sets the default logger. +// Passing nil is ignored to preserve the current default logger. +// +// log.SetDefault(customLogger) func SetDefault(l *Logger) { + if l == nil { + return + } + defaultLoggerMu.Lock() defaultLogger = l + defaultLoggerMu.Unlock() } // SetLevel sets the default logger's level. +// +// log.SetLevel(log.LevelDebug) func SetLevel(level Level) { - defaultLogger.SetLevel(level) + Default().SetLevel(level) } // SetRedactKeys sets the default logger's redaction keys. +// +// log.SetRedactKeys("password", "token") func SetRedactKeys(keys ...string) { - defaultLogger.SetRedactKeys(keys...) + Default().SetRedactKeys(keys...) } // Debug logs to the default logger. +// +// log.Debug("query started", "sql", query) func Debug(msg string, keyvals ...any) { - defaultLogger.Debug(msg, keyvals...) + Default().Debug(msg, keyvals...) } // Info logs to the default logger. +// +// log.Info("server ready", "port", 8080) func Info(msg string, keyvals ...any) { - defaultLogger.Info(msg, keyvals...) + Default().Info(msg, keyvals...) } // Warn logs to the default logger. +// +// log.Warn("retrying request", "attempt", 2) func Warn(msg string, keyvals ...any) { - defaultLogger.Warn(msg, keyvals...) + Default().Warn(msg, keyvals...) } // Error logs to the default logger. +// +// log.Error("request failed", "err", err) func Error(msg string, keyvals ...any) { - defaultLogger.Error(msg, keyvals...) + Default().Error(msg, keyvals...) } // Security logs to the default logger. +// +// log.Security("suspicious login", "ip", remoteAddr) func Security(msg string, keyvals ...any) { - defaultLogger.Security(msg, keyvals...) + Default().Security(msg, keyvals...) +} + +func shouldRedact(key any, redactKeys []string) bool { + keyStr := fmt.Sprintf("%v", key) + for _, redactKey := range redactKeys { + if redactKey == keyStr { + return true + } + } + return false } diff --git a/log_test.go b/log_test.go index eec88f4..cd3b4d8 100644 --- a/log_test.go +++ b/log_test.go @@ -2,9 +2,12 @@ package log import ( "bytes" + "errors" goio "io" + "os" "strings" "testing" + "time" ) // nopWriteCloser wraps a writer with a no-op Close for testing rotation. @@ -12,7 +15,7 @@ type nopWriteCloser struct{ goio.Writer } func (nopWriteCloser) Close() error { return nil } -func TestLogger_Levels(t *testing.T) { +func TestLogger_Levels_Good(t *testing.T) { tests := []struct { name string level Level @@ -62,7 +65,7 @@ func TestLogger_Levels(t *testing.T) { } } -func TestLogger_KeyValues(t *testing.T) { +func TestLogger_KeyValues_Good(t *testing.T) { var buf bytes.Buffer l := New(Options{Level: LevelDebug, Output: &buf}) @@ -80,7 +83,7 @@ func TestLogger_KeyValues(t *testing.T) { } } -func TestLogger_ErrorContext(t *testing.T) { +func TestLogger_ErrorContext_Good(t *testing.T) { var buf bytes.Buffer l := New(Options{Output: &buf, Level: LevelInfo}) @@ -98,7 +101,54 @@ func TestLogger_ErrorContext(t *testing.T) { } } -func TestLogger_Redaction(t *testing.T) { +func TestLogger_ErrorContextIncludesRecovery_Good(t *testing.T) { + var buf bytes.Buffer + l := New(Options{Output: &buf, Level: LevelInfo}) + retryAfter := 45 * time.Second + + err := EWithRecovery("retryable.Op", "temporary failure", errors.New("temporary failure"), true, &retryAfter, "retry with backoff") + l.Error("request failed", "err", err) + + output := buf.String() + if !strings.Contains(output, "retryable=true") { + t.Errorf("expected output to contain retryable=true, got %q", output) + } + if !strings.Contains(output, "retry_after_seconds=45") { + t.Errorf("expected output to contain retry_after_seconds=45, got %q", output) + } + if !strings.Contains(output, "next_action=\"retry with backoff\"") { + t.Errorf("expected output to contain next_action=\"retry with backoff\", got %q", output) + } +} + +func TestLogger_ErrorContextIncludesNestedRecovery_Good(t *testing.T) { + var buf bytes.Buffer + l := New(Options{Output: &buf, Level: LevelInfo}) + retryAfter := 30 * time.Second + + inner := &Err{ + Msg: "inner failure", + Retryable: true, + RetryAfter: &retryAfter, + NextAction: "retry later", + } + outer := &Err{Msg: "outer failure", Err: inner} + + l.Error("request failed", "err", outer) + + output := buf.String() + if !strings.Contains(output, "retryable=true") { + t.Errorf("expected output to contain retryable=true, got %q", output) + } + if !strings.Contains(output, "retry_after_seconds=30") { + t.Errorf("expected output to contain retry_after_seconds=30, got %q", output) + } + if !strings.Contains(output, "next_action=\"retry later\"") { + t.Errorf("expected output to contain next_action=\"retry later\", got %q", output) + } +} + +func TestLogger_Redaction_Good(t *testing.T) { var buf bytes.Buffer l := New(Options{ Level: LevelInfo, @@ -120,7 +170,23 @@ func TestLogger_Redaction(t *testing.T) { } } -func TestLogger_InjectionPrevention(t *testing.T) { +func TestLogger_Redaction_Bad_CaseMismatchNotRedacted(t *testing.T) { + var buf bytes.Buffer + l := New(Options{ + Level: LevelInfo, + Output: &buf, + RedactKeys: []string{"password"}, + }) + + l.Info("login", "PASSWORD", "secret123") + + output := buf.String() + if !strings.Contains(output, "PASSWORD=\"secret123\"") { + t.Errorf("expected case-mismatched key to remain visible, got %q", output) + } +} + +func TestLogger_InjectionPrevention_Good(t *testing.T) { var buf bytes.Buffer l := New(Options{Level: LevelInfo, Output: &buf}) @@ -137,7 +203,44 @@ func TestLogger_InjectionPrevention(t *testing.T) { } } -func TestLogger_SetLevel(t *testing.T) { +func TestLogger_KeySanitization_Good(t *testing.T) { + var buf bytes.Buffer + l := New(Options{Level: LevelInfo, Output: &buf}) + + l.Info("message", "key\nwith newline", "value\nwith newline") + output := buf.String() + + if !strings.Contains(output, "key\\nwith newline") { + t.Errorf("expected sanitized key, got %q", output) + } + if !strings.Contains(output, "value\\nwith newline") { + t.Errorf("expected sanitized value, got %q", output) + } + + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) != 1 { + t.Errorf("expected 1 line, got %d", len(lines)) + } +} + +func TestLogger_MessageSanitization_Good(t *testing.T) { + var buf bytes.Buffer + l := New(Options{Level: LevelInfo, Output: &buf}) + + l.Info("message\nwith\tcontrol\rchars") + output := buf.String() + + if !strings.Contains(output, "message\\nwith\\tcontrol\\rchars") { + t.Errorf("expected control characters to be escaped, got %q", output) + } + + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) != 1 { + t.Errorf("expected 1 line, got %d", len(lines)) + } +} + +func TestLogger_SetLevel_Good(t *testing.T) { l := New(Options{Level: LevelInfo}) if l.Level() != LevelInfo { @@ -148,9 +251,14 @@ func TestLogger_SetLevel(t *testing.T) { if l.Level() != LevelDebug { t.Error("expected level to be Debug after SetLevel") } + + l.SetLevel(99) + if l.Level() != LevelInfo { + t.Errorf("expected invalid level to default back to info, got %v", l.Level()) + } } -func TestLevel_String(t *testing.T) { +func TestLevel_String_Good(t *testing.T) { tests := []struct { level Level expected string @@ -172,7 +280,7 @@ func TestLevel_String(t *testing.T) { } } -func TestLogger_Security(t *testing.T) { +func TestLogger_Security_Good(t *testing.T) { var buf bytes.Buffer l := New(Options{Level: LevelError, Output: &buf}) @@ -206,6 +314,17 @@ func TestLogger_SetOutput_Good(t *testing.T) { } } +func TestLogger_SetOutput_Bad_NilFallsBackToStderr(t *testing.T) { + var buf bytes.Buffer + l := New(Options{Level: LevelInfo, Output: &buf}) + + l.SetOutput(nil) + + if l.output != os.Stderr { + t.Errorf("expected nil output to fallback to os.Stderr, got %T", l.output) + } +} + func TestLogger_SetRedactKeys_Good(t *testing.T) { var buf bytes.Buffer l := New(Options{Level: LevelInfo, Output: &buf}) @@ -291,6 +410,32 @@ func TestNew_RotationFactory_Good(t *testing.T) { } } +func TestNew_RotationFactory_Good_DefaultRetentionValues(t *testing.T) { + original := RotationWriterFactory + defer func() { RotationWriterFactory = original }() + + var captured RotationOptions + RotationWriterFactory = func(opts RotationOptions) goio.WriteCloser { + captured = opts + return nopWriteCloser{goio.Discard} + } + + _ = New(Options{ + Level: LevelInfo, + Rotation: &RotationOptions{Filename: "test.log"}, + }) + + if captured.MaxSize != defaultRotationMaxSize { + t.Errorf("expected default MaxSize=%d, got %d", defaultRotationMaxSize, captured.MaxSize) + } + if captured.MaxAge != defaultRotationMaxAge { + t.Errorf("expected default MaxAge=%d, got %d", defaultRotationMaxAge, captured.MaxAge) + } + if captured.MaxBackups != defaultRotationMaxBackups { + t.Errorf("expected default MaxBackups=%d, got %d", defaultRotationMaxBackups, captured.MaxBackups) + } +} + func TestNew_DefaultOutput_Good(t *testing.T) { // No output or rotation — should default to stderr (not nil) l := New(Options{Level: LevelInfo}) @@ -299,6 +444,13 @@ func TestNew_DefaultOutput_Good(t *testing.T) { } } +func TestNew_Bad_InvalidLevelDefaultsToInfo(t *testing.T) { + l := New(Options{Level: Level(99)}) + if l.Level() != LevelInfo { + t.Errorf("expected invalid level to default to info, got %v", l.Level()) + } +} + func TestUsername_Good(t *testing.T) { name := Username() if name == "" { @@ -337,3 +489,47 @@ func TestDefault_Good(t *testing.T) { } } } + +func TestDefault_Bad_SetDefaultNilIgnored(t *testing.T) { + original := Default() + var buf bytes.Buffer + custom := New(Options{Level: LevelInfo, Output: &buf}) + SetDefault(custom) + defer SetDefault(original) + + SetDefault(nil) + + if Default() != custom { + t.Error("expected SetDefault(nil) to preserve the current default logger") + } +} + +func TestLogger_StyleHooks_Bad_NilHooksDoNotPanic(t *testing.T) { + var buf bytes.Buffer + l := New(Options{Level: LevelDebug, Output: &buf}) + l.StyleTimestamp = nil + l.StyleDebug = nil + l.StyleInfo = nil + l.StyleWarn = nil + l.StyleError = nil + l.StyleSecurity = nil + + defer func() { + if r := recover(); r != nil { + t.Fatalf("expected nil style hooks not to panic, got panic: %v", r) + } + }() + + l.Debug("debug") + l.Info("info") + l.Warn("warn") + l.Error("error") + l.Security("security") + + output := buf.String() + for _, tag := range []string{"[DBG]", "[INF]", "[WRN]", "[ERR]", "[SEC]"} { + if !strings.Contains(output, tag) { + t.Errorf("expected %s in output, got %q", tag, output) + } + } +} diff --git a/specs/RFC.md b/specs/RFC.md new file mode 100644 index 0000000..78aaf1d --- /dev/null +++ b/specs/RFC.md @@ -0,0 +1,266 @@ +# log +**Import:** `dappco.re/go/core/log` +**Files:** 2 + +## Types + +### Err +`type Err struct` + +Structured error wrapper that carries operation context, a human-readable message, an optional wrapped cause, and an optional machine-readable code. +It can also carry agent-facing recovery metadata that survives wrapping and is +surfaced automatically in structured logs. + +Fields: +- `Op string`: operation name. When non-empty, `Error` prefixes the formatted message with `Op + ": "`. +- `Msg string`: human-readable message stored on the error and returned by `Message`. +- `Err error`: wrapped cause returned by `Unwrap`. +- `Code string`: optional machine-readable code. When non-empty, `Error` includes it in square brackets. +- `Retryable bool`: whether the caller can safely retry the operation. +- `RetryAfter *time.Duration`: optional retry delay hint when `Retryable` is true. +- `NextAction string`: suggested next step when the error is not directly retryable. + +Methods: +- `func (e *Err) Error() string`: formats the error text from `Op`, `Msg`, `Code`, and `Err`. The result omits missing parts, so the output can be `"{Msg}"`, `"{Msg} [{Code}]"`, `"{Msg}: {Err}"`, `"{Op}: {Msg} [{Code}]: {Err}"`, or a cleanly collapsed form such as `"{Op}"` when no message, code, or cause is present. +- `func (e *Err) Unwrap() error`: returns `e.Err`. + +### Level +`type Level int` + +Logging verbosity enum used by `Logger` and `Options`. + +Methods: +- `func (l Level) String() string`: returns `quiet`, `error`, `warn`, `info`, or `debug`. Any other value returns `unknown`. + +### Logger +`type Logger struct` + +Concurrency-safe structured logger. `New` clones the configured redaction keys, stores the configured level and writer, and initializes all style hooks to identity functions. + +Each log call writes one line in the form `HH:MM:SS {prefix} {msg}` followed by space-separated key/value pairs. String values are rendered with Go `%q` quoting, redacted keys are replaced with `"[REDACTED]"`, a trailing key without a value renders as ``, and any `error` value in `keyvals` can cause `op`, `stack`, `retryable`, `retry_after_seconds`, and `next_action` fields to be appended automatically if those keys were not already supplied. + +Fields: +- `StyleTimestamp func(string) string`: transforms the rendered `HH:MM:SS` timestamp before it is written. +- `StyleDebug func(string) string`: transforms the debug prefix passed to debug log lines. +- `StyleInfo func(string) string`: transforms the info prefix passed to info log lines. +- `StyleWarn func(string) string`: transforms the warn prefix passed to warning log lines. +- `StyleError func(string) string`: transforms the error prefix passed to error log lines. +- `StyleSecurity func(string) string`: transforms the security prefix passed to security log lines. + +Methods: +- `func (l *Logger) SetLevel(level Level)`: sets the logger’s current threshold. +- `func (l *Logger) Level() Level`: returns the logger’s current threshold. +- `func (l *Logger) SetOutput(w goio.Writer)`: replaces the writer used for future log lines. +- `func (l *Logger) SetRedactKeys(keys ...string)`: replaces the exact-match key list whose values are masked during formatting. +- `func (l *Logger) Debug(msg string, keyvals ...any)`: emits a debug line when the logger level is at least `LevelDebug`. +- `func (l *Logger) Info(msg string, keyvals ...any)`: emits an info line when the logger level is at least `LevelInfo`. +- `func (l *Logger) Warn(msg string, keyvals ...any)`: emits a warning line when the logger level is at least `LevelWarn`. +- `func (l *Logger) Error(msg string, keyvals ...any)`: emits an error line when the logger level is at least `LevelError`. +- `func (l *Logger) Security(msg string, keyvals ...any)`: emits a security line with the security style prefix and the same visibility threshold as `LevelError`. + +### Options +`type Options struct` + +Constructor input for `New`. + +Fields: +- `Level Level`: initial logger threshold. +- `Output goio.Writer`: destination used when rotation is not selected. +- `Rotation *RotationOptions`: optional rotation configuration. `New` uses rotation only when this field is non-nil, `Rotation.Filename` is non-empty, and `RotationWriterFactory` is non-nil. +- `RedactKeys []string`: keys whose values should be masked in formatted log output. + +### RotationOptions +`type RotationOptions struct` + +Rotation configuration passed through to `RotationWriterFactory` when `New` selects a rotating writer. + +Fields: +- `Filename string`: log file path. `New` only attempts rotation when this field is non-empty. +- `MaxSize int`: value forwarded to the rotation writer factory. +- `MaxAge int`: value forwarded to the rotation writer factory. +- `MaxBackups int`: value forwarded to the rotation writer factory. +- `Compress bool`: value forwarded to the rotation writer factory. + +## Functions + +### AllOps +`func AllOps(err error) iter.Seq[string]` + +Returns an iterator over non-empty `Op` values found by repeatedly calling `errors.Unwrap` on `err`. Operations are yielded from the outermost `*Err` to the innermost one. + +### As +`func As(err error, target any) bool` + +Thin wrapper around `errors.As`. + +### Debug +`func Debug(msg string, keyvals ...any)` + +Calls `Default().Debug(msg, keyvals...)`. + +### Default +`func Default() *Logger` + +Returns the package-level default logger. The package initializes it with `New(Options{Level: LevelInfo})`. + +### E +`func E(op, msg string, err error) error` + +Returns `&Err{Op: op, Msg: msg, Err: err}` as an `error`. It always returns a non-nil error value, even when `err` is nil. + +### EWithRecovery +`func EWithRecovery(op, msg string, err error, retryable bool, retryAfter *time.Duration, nextAction string) error` + +Returns `&Err{Op: op, Msg: msg, Err: err}` with explicit recovery metadata +attached to the new error value. + +### ErrCode +`func ErrCode(err error) string` + +If `err` contains an `*Err`, returns its `Code`. Otherwise returns the empty string. + +### Error +`func Error(msg string, keyvals ...any)` + +Calls `Default().Error(msg, keyvals...)`. + +### FormatStackTrace +`func FormatStackTrace(err error) string` + +Collects `AllOps(err)` and joins the operations with `" -> "`. Returns the empty string when no operations are found. + +### Info +`func Info(msg string, keyvals ...any)` + +Calls `Default().Info(msg, keyvals...)`. + +### Is +`func Is(err, target error) bool` + +Thin wrapper around `errors.Is`. + +### Join +`func Join(errs ...error) error` + +Thin wrapper around `errors.Join`. + +### IsRetryable +`func IsRetryable(err error) bool` + +Returns whether the first matching `*Err` in the chain is marked retryable. + +### NewCodeWithRecovery +`func NewCodeWithRecovery(code, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error` + +Returns `&Err{Msg: msg, Code: code}` with recovery metadata attached. + +### LogError +`func LogError(err error, op, msg string) error` + +If `err` is nil, returns nil. Otherwise wraps the error with `Wrap(err, op, msg)`, logs `msg` through the default logger at error level with key/value pairs `"op", op, "err", err`, and returns the wrapped error. + +### LogWarn +`func LogWarn(err error, op, msg string) error` + +If `err` is nil, returns nil. Otherwise wraps the error with `Wrap(err, op, msg)`, logs `msg` through the default logger at warn level with key/value pairs `"op", op, "err", err`, and returns the wrapped error. + +### Message +`func Message(err error) string` + +Returns the `Msg` field from the first matching `*Err`. If `err` is nil, returns the empty string. For non-`*Err` errors, returns `err.Error()`. + +### Must +`func Must(err error, op, msg string)` + +If `err` is nil, does nothing. Otherwise logs `msg` through the default logger at error level with key/value pairs `"op", op, "err", err`, then panics with `Wrap(err, op, msg)`. + +### New +`func New(opts Options) *Logger` + +Constructs a logger from `opts`. It prefers a rotating writer only when `opts.Rotation` is non-nil, `opts.Rotation.Filename` is non-empty, and `RotationWriterFactory` is set; otherwise it uses `opts.Output`. If neither path yields a writer, it falls back to `os.Stderr`. + +### NewCode +`func NewCode(code, msg string) error` + +Returns `&Err{Msg: msg, Code: code}` as an `error`. + +### RecoveryAction +`func RecoveryAction(err error) string` + +Returns the first next-action hint from the error chain. + +### RetryAfter +`func RetryAfter(err error) (*time.Duration, bool)` + +Returns the first retry-after hint from the error chain, if present. + +### NewError +`func NewError(text string) error` + +Thin wrapper around `errors.New`. + +### Op +`func Op(err error) string` + +If `err` contains an `*Err`, returns its `Op`. Otherwise returns the empty string. + +### Root +`func Root(err error) error` + +Repeatedly unwraps `err` with `errors.Unwrap` until no further wrapped error exists, then returns the last error in that chain. If `err` is nil, returns nil. + +### Security +`func Security(msg string, keyvals ...any)` + +Calls `Default().Security(msg, keyvals...)`. + +### SetDefault +`func SetDefault(l *Logger)` + +Replaces the package-level default logger with `l`. + +### SetLevel +`func SetLevel(level Level)` + +Calls `Default().SetLevel(level)`. + +### SetRedactKeys +`func SetRedactKeys(keys ...string)` + +Calls `Default().SetRedactKeys(keys...)`. + +### StackTrace +`func StackTrace(err error) []string` + +Collects `AllOps(err)` into a slice in outermost-to-innermost order. When no operations are found, the returned slice is nil. + +### Username +`func Username() string` + +Returns the current username by trying `user.Current()` first, then the `USER` environment variable, then the `USERNAME` environment variable. + +### Warn +`func Warn(msg string, keyvals ...any)` + +Calls `Default().Warn(msg, keyvals...)`. + +### Wrap +`func Wrap(err error, op, msg string) error` + +If `err` is nil, returns nil. Otherwise returns a new `*Err` containing `op`, `msg`, and `err`. If the wrapped error chain already contains an `*Err` with a non-empty `Code`, the new wrapper copies that code. + +### WrapCodeWithRecovery +`func WrapCodeWithRecovery(err error, code, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error` + +Returns nil only when both `err` is nil and `code` is empty. Otherwise it +returns a wrapped `*Err` with explicit recovery metadata attached. + +### WrapCode +`func WrapCode(err error, code, op, msg string) error` + +Returns nil only when both `err` is nil and `code` is empty. In every other case it returns `&Err{Op: op, Msg: msg, Err: err, Code: code}` as an `error`. + +### WrapWithRecovery +`func WrapWithRecovery(err error, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error` + +Returns nil when `err` is nil. Otherwise it returns a wrapped `*Err` with explicit recovery metadata attached.