Compare commits

..

No commits in common. "dev" and "v0.0.4" have entirely different histories.
dev ... v0.0.4

10 changed files with 111 additions and 1283 deletions

View file

@ -1,54 +0,0 @@
name: CI
on:
push:
branches: [main, dev]
pull_request:
branches: [main]
pull_request_review:
types: [submitted]
jobs:
test:
if: github.event_name != 'pull_request_review'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dAppCore/build/actions/build/core@dev
with:
go-version: "1.26"
run-vet: "true"
auto-fix:
if: >
github.event_name == 'pull_request_review' &&
github.event.review.user.login == 'coderabbitai' &&
github.event.review.state == 'changes_requested'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
- uses: dAppCore/build/actions/fix@dev
with:
go-version: "1.26"
auto-merge:
if: >
github.event_name == 'pull_request_review' &&
github.event.review.user.login == 'coderabbitai' &&
github.event.review.state == 'approved'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- name: Merge PR
run: gh pr merge ${{ github.event.pull_request.number }} --merge --delete-branch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -29,8 +29,8 @@ The `core` CLI is optional; plain `go test` and `gofmt` work without it.
Single-package library (`package log`) split into two files that wire together:
- **log.go**`Logger` type, `Level` enum (Quiet→Error→Warn→Info→Debug), `Security` log method (uses Error level with `[SEC]` prefix), key-value formatting with redaction and injection prevention, `Style*` function hooks for decoration, `RotationWriterFactory` injection point, `Username()` utility, default logger with package-level proxy functions
- **errors.go**`Err` structured error type (Op/Msg/Err/Code), creation helpers (`E`, `Wrap`, `WrapCode`, `NewCode`, `NewError`), introspection (`Op`, `ErrCode`, `Message`, `Root`, `AllOps`, `StackTrace`, `FormatStackTrace`), combined log-and-return helpers (`LogError`, `LogWarn`, `Must`), stdlib wrappers (`Is`, `As`, `Join`)
- **log.go**`Logger` type, `Level` enum (Quiet→Error→Warn→Info→Debug), key-value formatting with redaction and injection prevention, `Style*` function hooks for decoration, `RotationWriterFactory` injection point, default logger with package-level proxy functions
- **errors.go**`Err` structured error type (Op/Msg/Err/Code), creation helpers (`E`, `Wrap`, `WrapCode`, `NewCode`), introspection (`Op`, `ErrCode`, `Root`, `StackTrace`), combined log-and-return helpers (`LogError`, `LogWarn`, `Must`), stdlib wrappers (`Is`, `As`, `Join`)
The logger automatically extracts `op` and `stack` from `*Err` values found in key-value pairs. `Wrap` propagates error codes upward through the chain.
@ -43,4 +43,4 @@ Zero runtime dependencies. `testify` is test-only.
- **Commit messages**: conventional commits (`feat`, `fix`, `docs`, `chore`, etc.)
- **Dependencies**: no new runtime dependencies without justification; use `RotationWriterFactory` injection point for log rotation
- Requires **Go 1.26+** (uses `iter.Seq`)
- Module path: `dappco.re/go/core/log`
- Module path: `forge.lthn.ai/core/go-log`

View file

@ -63,9 +63,6 @@ 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
}
```
@ -118,9 +115,6 @@ 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

View file

@ -5,7 +5,7 @@ description: Structured logging and error handling for Core applications
# go-log
`dappco.re/go/core/log` provides structured logging and contextual error
`forge.lthn.ai/core/go-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 "dappco.re/go/core/log"
import "forge.lthn.ai/core/go-log"
// Use the package-level default logger straight away
log.SetLevel(log.LevelDebug)
@ -54,10 +54,6 @@ 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
@ -74,7 +70,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`, and recovery-aware variants), introspection (`Op`, `ErrCode`, `Root`, `StackTrace`, recovery hints), combined log-and-return helpers (`LogError`, `LogWarn`, `Must`) |
| `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`) |
| `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 |
@ -93,7 +89,7 @@ code.
## Module Path
```
dappco.re/go/core/log
forge.lthn.ai/core/go-log
```
Requires **Go 1.26+** (uses `iter.Seq` from the standard library).

229
errors.go
View file

@ -7,9 +7,9 @@ package log
import (
"errors"
"fmt"
"iter"
"strings"
"time"
)
// Err represents a structured error with operational context.
@ -19,44 +19,24 @@ 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 {
if e == nil {
return ""
}
body := e.Msg
if body == "" {
if e.Code != "" {
body = "[" + e.Code + "]"
}
} else if e.Code != "" {
body += " [" + e.Code + "]"
}
if e.Err != nil {
if body != "" {
body += ": " + e.Err.Error()
} else {
body = e.Err.Error()
}
}
var prefix string
if e.Op != "" {
if body != "" {
return e.Op + ": " + body
}
return e.Op
prefix = e.Op + ": "
}
return body
if e.Err != nil {
if e.Code != "" {
return fmt.Sprintf("%s%s [%s]: %v", prefix, e.Msg, e.Code, e.Err)
}
return fmt.Sprintf("%s%s: %v", prefix, e.Msg, e.Err)
}
if e.Code != "" {
return fmt.Sprintf("%s%s [%s]", prefix, e.Msg, e.Code)
}
return fmt.Sprintf("%s%s", prefix, e.Msg)
}
// Unwrap returns the underlying error for use with errors.Is and errors.As.
@ -77,22 +57,6 @@ 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.
@ -104,29 +68,12 @@ func Wrap(err error, op, msg string) error {
if err == nil {
return nil
}
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
// 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}
}
recoveryErr := &Err{
Op: op,
Msg: msg,
Err: err,
Code: ErrCode(err),
}
inheritRecovery(recoveryErr, err)
recoveryErr.Retryable = retryable
recoveryErr.RetryAfter = retryAfter
recoveryErr.NextAction = nextAction
return recoveryErr
return &Err{Op: op, Msg: msg, Err: err}
}
// WrapCode wraps an error with operation context and error code.
@ -140,29 +87,7 @@ func WrapCode(err error, code, op, msg string) error {
if err == nil && code == "" {
return nil
}
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
return &Err{Op: op, Msg: msg, Err: err, Code: code}
}
// NewCode creates an error with just code and message (no underlying error).
@ -175,121 +100,28 @@ 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...)
}
@ -298,8 +130,6 @@ 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) {
@ -310,8 +140,6 @@ 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) {
@ -322,8 +150,6 @@ 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 ""
@ -337,8 +163,6 @@ 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
@ -354,8 +178,6 @@ 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 {
@ -373,8 +195,6 @@ 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) {
@ -384,8 +204,6 @@ 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) {
@ -419,7 +237,7 @@ func LogError(err error, op, msg string) error {
return nil
}
wrapped := Wrap(err, op, msg)
Default().Error(msg, "op", op, "err", err)
defaultLogger.Error(msg, "op", op, "err", err)
return wrapped
}
@ -434,7 +252,7 @@ func LogWarn(err error, op, msg string) error {
return nil
}
wrapped := Wrap(err, op, msg)
Default().Warn(msg, "op", op, "err", err)
defaultLogger.Warn(msg, "op", op, "err", err)
return wrapped
}
@ -446,8 +264,7 @@ func LogWarn(err error, op, msg string) error {
// log.Must(Initialize(), "app", "startup failed")
func Must(err error, op, msg string) {
if err != nil {
wrapped := Wrap(err, op, msg)
Default().Error(msg, "op", op, "err", err)
panic(wrapped)
defaultLogger.Error(msg, "op", op, "err", err)
panic(Wrap(err, op, msg))
}
}

View file

@ -6,7 +6,6 @@ import (
"fmt"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
@ -45,20 +44,6 @@ 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}
@ -88,20 +73,6 @@ 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")
@ -124,41 +95,6 @@ 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)
@ -176,41 +112,6 @@ 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")
@ -232,19 +133,6 @@ 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) {
@ -300,51 +188,6 @@ func TestErrCode_Good_NoCode(t *testing.T) {
assert.Equal(t, "", ErrCode(err))
}
func TestErrCode_Good_PlainError(t *testing.T) {
err := errors.New("plain error")
assert.Equal(t, "", ErrCode(err))
}
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))
@ -401,23 +244,6 @@ 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})
@ -493,25 +319,28 @@ func TestStackTrace_Good(t *testing.T) {
assert.Equal(t, "op3 -> op2 -> op1", formatted)
}
func TestStackTrace_Bad_PlainError(t *testing.T) {
func TestStackTrace_PlainError(t *testing.T) {
err := errors.New("plain error")
assert.Empty(t, StackTrace(err))
assert.Empty(t, FormatStackTrace(err))
}
func TestStackTrace_Bad_Nil(t *testing.T) {
func TestStackTrace_Nil(t *testing.T) {
assert.Empty(t, StackTrace(nil))
assert.Empty(t, FormatStackTrace(nil))
}
func TestStackTrace_Bad_NoOp(t *testing.T) {
func TestStackTrace_NoOp(t *testing.T) {
err := &Err{Msg: "no op"}
assert.Empty(t, StackTrace(err))
assert.Empty(t, FormatStackTrace(err))
}
func TestStackTrace_Mixed_Good(t *testing.T) {
func TestStackTrace_Mixed(t *testing.T) {
err := E("inner", "msg", nil)
err = errors.New("middle: " + err.Error()) // Breaks the chain if not handled properly, but Unwrap should work if it's a wrapped error
// Wait, errors.New doesn't wrap. fmt.Errorf("%w") does.
err = E("inner", "msg", nil)
err = fmt.Errorf("wrapper: %w", err)
err = Wrap(err, "outer", "msg")

2
go.mod
View file

@ -1,4 +1,4 @@
module dappco.re/go/core/log
module forge.lthn.ai/core/go-log
go 1.26.0

273
log.go
View file

@ -7,11 +7,10 @@ package log
import (
"fmt"
goio "io"
"io"
"os"
"os/user"
"slices"
"strings"
"sync"
"time"
)
@ -33,19 +32,6 @@ 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 {
@ -68,23 +54,18 @@ func (l Level) String() string {
type Logger struct {
mu sync.RWMutex
level Level
output goio.Writer
output io.Writer
// RedactKeys is a list of keys whose values should be masked in logs.
redactKeys []string
// StyleTimestamp formats the rendered timestamp prefix.
// Style functions for formatting (can be overridden)
StyleTimestamp 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
StyleDebug func(string) string
StyleInfo func(string) string
StyleWarn func(string) string
StyleError func(string) string
StyleSecurity func(string) string
}
// RotationOptions defines the log rotation and retention policy.
@ -112,11 +93,10 @@ 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.
Output goio.Writer
Output io.Writer
// Rotation enables log rotation to file. If provided, Filename must be set.
Rotation *RotationOptions
// RedactKeys is a list of keys whose values should be masked in logs.
@ -125,28 +105,20 @@ type Options struct {
// RotationWriterFactory creates a rotating writer from options.
// Set this to enable log rotation (provided by core/go-io integration).
var RotationWriterFactory func(RotationOptions) goio.WriteCloser
var RotationWriterFactory func(RotationOptions) io.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(normaliseRotationOptions(*opts.Rotation))
output = RotationWriterFactory(*opts.Rotation)
}
if output == nil {
output = os.Stderr
}
return &Logger{
level: level,
level: opts.Level,
output: output,
redactKeys: slices.Clone(opts.RedactKeys),
StyleTimestamp: identity,
@ -158,40 +130,16 @@ 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 = normaliseLevel(level)
l.level = 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()
@ -199,20 +147,13 @@ 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
}
func (l *Logger) SetOutput(w io.Writer) {
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)
@ -226,67 +167,47 @@ 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 {
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 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 nextAction := RecoveryAction(err); nextAction != "" {
if _, hasNextAction := existing["next_action"]; !hasNextAction {
existing["next_action"] = struct{}{}
keyvals = append(keyvals, "next_action", nextAction)
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 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
@ -297,95 +218,69 @@ func (l *Logger) log(level Level, prefix, msg string, keyvals ...any) {
if i > 0 {
kvStr += " "
}
key := normaliseLogText(fmt.Sprintf("%v", keyvals[i]))
key := keyvals[i]
var val any
if i+1 < len(keyvals) {
val = keyvals[i+1]
}
// Redaction logic
if shouldRedact(key, redactKeys) {
keyStr := fmt.Sprintf("%v", key)
if slices.Contains(redactKeys, keyStr) {
val = "[REDACTED]"
}
// Secure formatting to prevent log injection
if s, ok := val.(string); ok {
kvStr += fmt.Sprintf("%s=%q", key, s)
kvStr += fmt.Sprintf("%v=%q", key, s)
} else {
kvStr += fmt.Sprintf("%s=%v", key, normaliseLogText(fmt.Sprintf("%v", val)))
kvStr += fmt.Sprintf("%v=%v", key, val)
}
}
}
_, _ = fmt.Fprintf(output, "%s %s %s%s\n", timestamp, prefix, normaliseLogText(msg), kvStr)
_, _ = fmt.Fprintf(output, "%s %s %s%s\n", timestamp, prefix, 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.mu.RLock()
style := safeStyle(l.StyleDebug)
l.mu.RUnlock()
l.log(LevelDebug, style("[DBG]"), msg, keyvals...)
l.log(LevelDebug, l.StyleDebug("[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.mu.RLock()
style := safeStyle(l.StyleInfo)
l.mu.RUnlock()
l.log(LevelInfo, style("[INF]"), msg, keyvals...)
l.log(LevelInfo, l.StyleInfo("[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.mu.RLock()
style := safeStyle(l.StyleWarn)
l.mu.RUnlock()
l.log(LevelWarn, style("[WRN]"), msg, keyvals...)
l.log(LevelWarn, l.StyleWarn("[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.mu.RLock()
style := safeStyle(l.StyleError)
l.mu.RUnlock()
l.log(LevelError, style("[ERR]"), msg, keyvals...)
l.log(LevelError, l.StyleError("[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.mu.RLock()
style := safeStyle(l.StyleSecurity)
l.mu.RUnlock()
l.log(LevelError, style("[SEC]"), msg, keyvals...)
l.log(LevelError, l.StyleSecurity("[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
@ -394,104 +289,54 @@ func Username() string {
if u := os.Getenv("USER"); u != "" {
return u
}
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)
return os.Getenv("USERNAME")
}
// --- 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) {
Default().SetLevel(level)
defaultLogger.SetLevel(level)
}
// SetRedactKeys sets the default logger's redaction keys.
//
// log.SetRedactKeys("password", "token")
func SetRedactKeys(keys ...string) {
Default().SetRedactKeys(keys...)
defaultLogger.SetRedactKeys(keys...)
}
// Debug logs to the default logger.
//
// log.Debug("query started", "sql", query)
func Debug(msg string, keyvals ...any) {
Default().Debug(msg, keyvals...)
defaultLogger.Debug(msg, keyvals...)
}
// Info logs to the default logger.
//
// log.Info("server ready", "port", 8080)
func Info(msg string, keyvals ...any) {
Default().Info(msg, keyvals...)
defaultLogger.Info(msg, keyvals...)
}
// Warn logs to the default logger.
//
// log.Warn("retrying request", "attempt", 2)
func Warn(msg string, keyvals ...any) {
Default().Warn(msg, keyvals...)
defaultLogger.Warn(msg, keyvals...)
}
// Error logs to the default logger.
//
// log.Error("request failed", "err", err)
func Error(msg string, keyvals ...any) {
Default().Error(msg, keyvals...)
defaultLogger.Error(msg, keyvals...)
}
// Security logs to the default logger.
//
// log.Security("suspicious login", "ip", remoteAddr)
func Security(msg string, keyvals ...any) {
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
defaultLogger.Security(msg, keyvals...)
}

View file

@ -2,20 +2,11 @@ package log
import (
"bytes"
"errors"
goio "io"
"os"
"strings"
"testing"
"time"
)
// nopWriteCloser wraps a writer with a no-op Close for testing rotation.
type nopWriteCloser struct{ goio.Writer }
func (nopWriteCloser) Close() error { return nil }
func TestLogger_Levels_Good(t *testing.T) {
func TestLogger_Levels(t *testing.T) {
tests := []struct {
name string
level Level
@ -65,7 +56,7 @@ func TestLogger_Levels_Good(t *testing.T) {
}
}
func TestLogger_KeyValues_Good(t *testing.T) {
func TestLogger_KeyValues(t *testing.T) {
var buf bytes.Buffer
l := New(Options{Level: LevelDebug, Output: &buf})
@ -83,7 +74,7 @@ func TestLogger_KeyValues_Good(t *testing.T) {
}
}
func TestLogger_ErrorContext_Good(t *testing.T) {
func TestLogger_ErrorContext(t *testing.T) {
var buf bytes.Buffer
l := New(Options{Output: &buf, Level: LevelInfo})
@ -101,54 +92,7 @@ func TestLogger_ErrorContext_Good(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) {
func TestLogger_Redaction(t *testing.T) {
var buf bytes.Buffer
l := New(Options{
Level: LevelInfo,
@ -170,23 +114,7 @@ func TestLogger_Redaction_Good(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) {
func TestLogger_InjectionPrevention(t *testing.T) {
var buf bytes.Buffer
l := New(Options{Level: LevelInfo, Output: &buf})
@ -203,44 +131,7 @@ func TestLogger_InjectionPrevention_Good(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) {
func TestLogger_SetLevel(t *testing.T) {
l := New(Options{Level: LevelInfo})
if l.Level() != LevelInfo {
@ -251,14 +142,9 @@ func TestLogger_SetLevel_Good(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_Good(t *testing.T) {
func TestLevel_String(t *testing.T) {
tests := []struct {
level Level
expected string
@ -280,7 +166,7 @@ func TestLevel_String_Good(t *testing.T) {
}
}
func TestLogger_Security_Good(t *testing.T) {
func TestLogger_Security(t *testing.T) {
var buf bytes.Buffer
l := New(Options{Level: LevelError, Output: &buf})
@ -298,238 +184,19 @@ func TestLogger_Security_Good(t *testing.T) {
}
}
func TestLogger_SetOutput_Good(t *testing.T) {
var buf1, buf2 bytes.Buffer
l := New(Options{Level: LevelInfo, Output: &buf1})
l.Info("first")
if buf1.Len() == 0 {
t.Error("expected output in first buffer")
}
l.SetOutput(&buf2)
l.Info("second")
if buf2.Len() == 0 {
t.Error("expected output in second buffer after SetOutput")
}
}
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})
// No redaction initially
l.Info("msg", "secret", "visible")
if !strings.Contains(buf.String(), "secret=\"visible\"") {
t.Errorf("expected visible value, got %q", buf.String())
}
buf.Reset()
l.SetRedactKeys("secret")
l.Info("msg", "secret", "hidden")
if !strings.Contains(buf.String(), "secret=\"[REDACTED]\"") {
t.Errorf("expected redacted value, got %q", buf.String())
}
}
func TestLogger_OddKeyvals_Good(t *testing.T) {
var buf bytes.Buffer
l := New(Options{Level: LevelInfo, Output: &buf})
// Odd number of keyvals — last key should have no value
l.Info("msg", "lonely_key")
output := buf.String()
if !strings.Contains(output, "lonely_key=<nil>") {
t.Errorf("expected lonely_key=<nil>, got %q", output)
}
}
func TestLogger_ExistingOpNotDuplicated_Good(t *testing.T) {
var buf bytes.Buffer
l := New(Options{Level: LevelInfo, Output: &buf})
err := E("inner.Op", "failed", NewError("cause"))
// Pass op explicitly — should not duplicate
l.Error("failed", "op", "explicit.Op", "err", err)
output := buf.String()
if strings.Count(output, "op=") != 1 {
t.Errorf("expected exactly one op= in output, got %q", output)
}
if !strings.Contains(output, "op=\"explicit.Op\"") {
t.Errorf("expected explicit op, got %q", output)
}
}
func TestLogger_ExistingStackNotDuplicated_Good(t *testing.T) {
var buf bytes.Buffer
l := New(Options{Level: LevelInfo, Output: &buf})
err := E("inner.Op", "failed", NewError("cause"))
// Pass stack explicitly — should not duplicate
l.Error("failed", "stack", "custom.Stack", "err", err)
output := buf.String()
if strings.Count(output, "stack=") != 1 {
t.Errorf("expected exactly one stack= in output, got %q", output)
}
if !strings.Contains(output, "stack=\"custom.Stack\"") {
t.Errorf("expected custom stack, got %q", output)
}
}
func TestNew_RotationFactory_Good(t *testing.T) {
var buf bytes.Buffer
// Set up a mock rotation writer factory
original := RotationWriterFactory
defer func() { RotationWriterFactory = original }()
RotationWriterFactory = func(opts RotationOptions) goio.WriteCloser {
return nopWriteCloser{&buf}
}
l := New(Options{
Level: LevelInfo,
Rotation: &RotationOptions{Filename: "test.log"},
})
l.Info("rotated message")
if buf.Len() == 0 {
t.Error("expected output via rotation writer")
}
}
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})
if l.output == nil {
t.Error("expected non-nil output when no Output specified")
}
}
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 == "" {
t.Error("expected Username to return a non-empty string")
}
}
func TestDefault_Good(t *testing.T) {
func TestDefault(t *testing.T) {
// Default logger should exist
if Default() == nil {
t.Error("expected default logger to exist")
}
// All package-level proxy functions
// Package-level functions should work
var buf bytes.Buffer
l := New(Options{Level: LevelDebug, Output: &buf})
SetDefault(l)
defer SetDefault(New(Options{Level: LevelInfo}))
SetLevel(LevelDebug)
if l.Level() != LevelDebug {
t.Error("expected package-level SetLevel to work")
}
SetRedactKeys("secret")
Debug("debug msg")
Info("info msg")
Warn("warn msg")
Error("error msg")
Security("sec msg")
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)
}
}
}
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)
}
Info("test")
if buf.Len() == 0 {
t.Error("expected package-level Info to produce output")
}
}

View file

@ -1,266 +0,0 @@
# 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 `<nil>`, 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 loggers current threshold.
- `func (l *Logger) Level() Level`: returns the loggers 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.