From cd5858fbf2cdab904471c6bf32812e2db4ca45ea Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 2 Feb 2026 00:25:14 +0000 Subject: [PATCH 1/2] feat(log): batch implementation placeholder Co-Authored-By: Claude Opus 4.5 From 261328a291f5783179d51d73974765c11b371a09 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 2 Feb 2026 01:11:46 +0000 Subject: [PATCH 2/2] feat(log): add error creation and log-and-return helpers Implements issues #129 and #132: - Add Err struct with Op, Msg, Err, Code fields for structured errors - Add E(), Wrap(), WrapCode(), NewCode() for error creation - Add Is(), As(), NewError(), Join() as stdlib wrappers - Add Op(), ErrCode(), Message(), Root() for introspection - Add LogError(), LogWarn(), Must() for combined log-and-return Closes #129 Closes #132 Co-Authored-By: Claude Opus 4.5 --- pkg/log/errors.go | 217 +++++++++++++++++++++++++++++++++ pkg/log/errors_test.go | 267 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 484 insertions(+) create mode 100644 pkg/log/errors.go create mode 100644 pkg/log/errors_test.go diff --git a/pkg/log/errors.go b/pkg/log/errors.go new file mode 100644 index 00000000..838436f2 --- /dev/null +++ b/pkg/log/errors.go @@ -0,0 +1,217 @@ +// Package log provides structured logging and error handling for Core applications. +// +// This file implements structured error types and combined log-and-return helpers +// that simplify common error handling patterns. + +package log + +import ( + "errors" + "fmt" +) + +// Err represents a structured error with operational context. +// It implements the error interface and supports unwrapping. +type Err struct { + Op string // Operation being performed (e.g., "user.Save") + Msg string // Human-readable message + Err error // Underlying error (optional) + Code string // Error code (optional, e.g., "VALIDATION_FAILED") +} + +// Error implements the error interface. +func (e *Err) Error() string { + if e.Err != nil { + if e.Code != "" { + return fmt.Sprintf("%s: %s [%s]: %v", e.Op, e.Msg, e.Code, e.Err) + } + return fmt.Sprintf("%s: %s: %v", e.Op, e.Msg, e.Err) + } + if e.Code != "" { + return fmt.Sprintf("%s: %s [%s]", e.Op, e.Msg, e.Code) + } + return fmt.Sprintf("%s: %s", e.Op, e.Msg) +} + +// Unwrap returns the underlying error for use with errors.Is and errors.As. +func (e *Err) Unwrap() error { + return e.Err +} + +// --- Error Creation Functions --- + +// E creates a new Err with operation context. +// If err is nil, returns nil to support conditional wrapping. +// +// Example: +// +// return log.E("user.Save", "failed to save user", err) +func E(op, msg string, err error) error { + if err == nil { + return nil + } + return &Err{Op: op, Msg: msg, Err: err} +} + +// Wrap wraps an error with operation context. +// Alias for E() for semantic clarity when wrapping existing errors. +// +// Example: +// +// return log.Wrap(err, "db.Query", "database query failed") +func Wrap(err error, op, msg string) error { + return E(op, msg, err) +} + +// WrapCode wraps an error with operation context and error code. +// Useful for API errors that need machine-readable codes. +// +// Example: +// +// return log.WrapCode(err, "VALIDATION_ERROR", "user.Validate", "invalid email") +func WrapCode(err error, code, op, msg string) error { + if err == nil { + return nil + } + return &Err{Op: op, Msg: msg, Err: err, Code: code} +} + +// NewCode creates an error with just code and message (no underlying error). +// Useful for creating sentinel errors with codes. +// +// Example: +// +// var ErrNotFound = log.NewCode("NOT_FOUND", "resource not found") +func NewCode(code, msg string) error { + return &Err{Msg: msg, Code: code} +} + +// --- Standard Library Wrappers --- + +// Is reports whether any error in err's tree matches target. +// Wrapper around errors.Is for convenience. +func Is(err, target error) bool { + return errors.Is(err, target) +} + +// As finds the first error in err's tree that matches target. +// Wrapper around errors.As for convenience. +func As(err error, target any) bool { + return errors.As(err, target) +} + +// NewError creates a simple error with the given text. +// Wrapper around errors.New for convenience. +func NewError(text string) error { + return errors.New(text) +} + +// Join combines multiple errors into one. +// Wrapper around errors.Join for convenience. +func Join(errs ...error) error { + return errors.Join(errs...) +} + +// --- Error Introspection Helpers --- + +// Op extracts the operation name from an error. +// Returns empty string if the error is not an *Err. +func Op(err error) string { + var e *Err + if As(err, &e) { + return e.Op + } + return "" +} + +// ErrCode extracts the error code from an error. +// Returns empty string if the error is not an *Err or has no code. +func ErrCode(err error) string { + var e *Err + if As(err, &e) { + return e.Code + } + return "" +} + +// Message extracts the message from an error. +// Returns the error's Error() string if not an *Err. +func Message(err error) string { + if err == nil { + return "" + } + var e *Err + if As(err, &e) { + return e.Msg + } + return err.Error() +} + +// Root returns the root cause of an error chain. +// Unwraps until no more wrapped errors are found. +func Root(err error) error { + if err == nil { + return nil + } + for { + unwrapped := errors.Unwrap(err) + if unwrapped == nil { + return err + } + err = unwrapped + } +} + +// --- Combined Log-and-Return Helpers --- + +// LogError logs an error at Error level and returns a wrapped error. +// Reduces boilerplate in error handling paths. +// +// Example: +// +// // Before +// if err != nil { +// log.Error("failed to save", "err", err) +// return errors.Wrap(err, "user.Save", "failed to save") +// } +// +// // After +// if err != nil { +// return log.LogError(err, "user.Save", "failed to save") +// } +func LogError(err error, op, msg string) error { + if err == nil { + return nil + } + wrapped := Wrap(err, op, msg) + defaultLogger.Error(msg, "op", op, "err", err) + return wrapped +} + +// LogWarn logs at Warn level and returns a wrapped error. +// Use for recoverable errors that should be logged but not treated as critical. +// +// Example: +// +// return log.LogWarn(err, "cache.Get", "cache miss, falling back to db") +func LogWarn(err error, op, msg string) error { + if err == nil { + return nil + } + wrapped := Wrap(err, op, msg) + defaultLogger.Warn(msg, "op", op, "err", err) + return wrapped +} + +// Must panics if err is not nil, logging first. +// Use for errors that should never happen and indicate programmer error. +// +// Example: +// +// 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)) + } +} diff --git a/pkg/log/errors_test.go b/pkg/log/errors_test.go new file mode 100644 index 00000000..99640549 --- /dev/null +++ b/pkg/log/errors_test.go @@ -0,0 +1,267 @@ +package log + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// --- Err Type Tests --- + +func TestErr_Error_Good(t *testing.T) { + // With underlying error + err := &Err{Op: "db.Query", Msg: "failed to query", Err: errors.New("connection refused")} + assert.Equal(t, "db.Query: failed to query: connection refused", err.Error()) + + // With code + err = &Err{Op: "api.Call", Msg: "request failed", Code: "TIMEOUT"} + assert.Equal(t, "api.Call: request failed [TIMEOUT]", err.Error()) + + // With both underlying error and code + err = &Err{Op: "user.Save", Msg: "save failed", Err: errors.New("duplicate key"), Code: "DUPLICATE"} + assert.Equal(t, "user.Save: save failed [DUPLICATE]: duplicate key", err.Error()) + + // Just op and msg + err = &Err{Op: "cache.Get", Msg: "miss"} + assert.Equal(t, "cache.Get: miss", err.Error()) +} + +func TestErr_Unwrap_Good(t *testing.T) { + underlying := errors.New("underlying error") + err := &Err{Op: "test", Msg: "wrapped", Err: underlying} + + assert.Equal(t, underlying, errors.Unwrap(err)) + assert.True(t, errors.Is(err, underlying)) +} + +// --- Error Creation Function Tests --- + +func TestE_Good(t *testing.T) { + underlying := errors.New("base error") + err := E("op.Name", "something failed", underlying) + + assert.NotNil(t, err) + var logErr *Err + assert.True(t, errors.As(err, &logErr)) + assert.Equal(t, "op.Name", logErr.Op) + assert.Equal(t, "something failed", logErr.Msg) + assert.Equal(t, underlying, logErr.Err) +} + +func TestE_Good_NilError(t *testing.T) { + // Should return nil when wrapping nil + err := E("op.Name", "message", nil) + assert.Nil(t, err) +} + +func TestWrap_Good(t *testing.T) { + underlying := errors.New("base") + err := Wrap(underlying, "handler.Process", "processing failed") + + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "handler.Process") + assert.Contains(t, err.Error(), "processing failed") + assert.True(t, errors.Is(err, underlying)) +} + +func TestWrapCode_Good(t *testing.T) { + underlying := errors.New("validation failed") + err := WrapCode(underlying, "INVALID_INPUT", "api.Validate", "bad request") + + assert.NotNil(t, err) + var logErr *Err + assert.True(t, errors.As(err, &logErr)) + assert.Equal(t, "INVALID_INPUT", logErr.Code) + assert.Equal(t, "api.Validate", logErr.Op) + assert.Contains(t, err.Error(), "[INVALID_INPUT]") +} + +func TestWrapCode_Good_NilError(t *testing.T) { + err := WrapCode(nil, "CODE", "op", "msg") + assert.Nil(t, err) +} + +func TestNewCode_Good(t *testing.T) { + err := NewCode("NOT_FOUND", "resource not found") + + var logErr *Err + assert.True(t, errors.As(err, &logErr)) + assert.Equal(t, "NOT_FOUND", logErr.Code) + assert.Equal(t, "resource not found", logErr.Msg) + assert.Nil(t, logErr.Err) +} + +// --- Standard Library Wrapper Tests --- + +func TestIs_Good(t *testing.T) { + sentinel := errors.New("sentinel") + wrapped := Wrap(sentinel, "test", "wrapped") + + assert.True(t, Is(wrapped, sentinel)) + assert.False(t, Is(wrapped, errors.New("other"))) +} + +func TestAs_Good(t *testing.T) { + err := E("test.Op", "message", errors.New("base")) + + var logErr *Err + assert.True(t, As(err, &logErr)) + assert.Equal(t, "test.Op", logErr.Op) +} + +func TestNewError_Good(t *testing.T) { + err := NewError("simple error") + assert.NotNil(t, err) + assert.Equal(t, "simple error", err.Error()) +} + +func TestJoin_Good(t *testing.T) { + err1 := errors.New("error 1") + err2 := errors.New("error 2") + joined := Join(err1, err2) + + assert.True(t, errors.Is(joined, err1)) + assert.True(t, errors.Is(joined, err2)) +} + +// --- Helper Function Tests --- + +func TestOp_Good(t *testing.T) { + err := E("mypackage.MyFunc", "failed", errors.New("cause")) + assert.Equal(t, "mypackage.MyFunc", Op(err)) +} + +func TestOp_Good_NotLogError(t *testing.T) { + err := errors.New("plain error") + assert.Equal(t, "", Op(err)) +} + +func TestErrCode_Good(t *testing.T) { + err := WrapCode(errors.New("base"), "ERR_CODE", "op", "msg") + assert.Equal(t, "ERR_CODE", ErrCode(err)) +} + +func TestErrCode_Good_NoCode(t *testing.T) { + err := E("op", "msg", errors.New("base")) + assert.Equal(t, "", ErrCode(err)) +} + +func TestMessage_Good(t *testing.T) { + err := E("op", "the message", errors.New("base")) + assert.Equal(t, "the message", Message(err)) +} + +func TestMessage_Good_PlainError(t *testing.T) { + err := errors.New("plain message") + assert.Equal(t, "plain message", Message(err)) +} + +func TestMessage_Good_Nil(t *testing.T) { + assert.Equal(t, "", Message(nil)) +} + +func TestRoot_Good(t *testing.T) { + root := errors.New("root cause") + level1 := Wrap(root, "level1", "wrapped once") + level2 := Wrap(level1, "level2", "wrapped twice") + + assert.Equal(t, root, Root(level2)) +} + +func TestRoot_Good_SingleError(t *testing.T) { + err := errors.New("single") + assert.Equal(t, err, Root(err)) +} + +func TestRoot_Good_Nil(t *testing.T) { + assert.Nil(t, Root(nil)) +} + +// --- Log-and-Return Helper Tests --- + +func TestLogError_Good(t *testing.T) { + // Capture log output + var buf bytes.Buffer + logger := New(Options{Level: LevelDebug, Output: &buf}) + SetDefault(logger) + defer SetDefault(New(Options{Level: LevelInfo})) + + underlying := errors.New("connection failed") + err := LogError(underlying, "db.Connect", "database unavailable") + + // Check returned error + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "db.Connect") + assert.Contains(t, err.Error(), "database unavailable") + assert.True(t, errors.Is(err, underlying)) + + // Check log output + output := buf.String() + assert.Contains(t, output, "[ERR]") + assert.Contains(t, output, "database unavailable") + assert.Contains(t, output, "op=db.Connect") +} + +func TestLogError_Good_NilError(t *testing.T) { + var buf bytes.Buffer + logger := New(Options{Level: LevelDebug, Output: &buf}) + SetDefault(logger) + defer SetDefault(New(Options{Level: LevelInfo})) + + err := LogError(nil, "op", "msg") + assert.Nil(t, err) + assert.Empty(t, buf.String()) // No log output for nil error +} + +func TestLogWarn_Good(t *testing.T) { + var buf bytes.Buffer + logger := New(Options{Level: LevelDebug, Output: &buf}) + SetDefault(logger) + defer SetDefault(New(Options{Level: LevelInfo})) + + underlying := errors.New("cache miss") + err := LogWarn(underlying, "cache.Get", "falling back to db") + + assert.NotNil(t, err) + assert.True(t, errors.Is(err, underlying)) + + output := buf.String() + assert.Contains(t, output, "[WRN]") + assert.Contains(t, output, "falling back to db") +} + +func TestLogWarn_Good_NilError(t *testing.T) { + var buf bytes.Buffer + logger := New(Options{Level: LevelDebug, Output: &buf}) + SetDefault(logger) + defer SetDefault(New(Options{Level: LevelInfo})) + + err := LogWarn(nil, "op", "msg") + assert.Nil(t, err) + assert.Empty(t, buf.String()) +} + +func TestMust_Good_NoError(t *testing.T) { + // Should not panic when error is nil + assert.NotPanics(t, func() { + Must(nil, "test", "should not panic") + }) +} + +func TestMust_Ugly_Panics(t *testing.T) { + var buf bytes.Buffer + logger := New(Options{Level: LevelDebug, Output: &buf}) + SetDefault(logger) + defer SetDefault(New(Options{Level: LevelInfo})) + + assert.Panics(t, func() { + Must(errors.New("fatal error"), "startup", "initialization failed") + }) + + // Verify error was logged before panic + output := buf.String() + assert.True(t, strings.Contains(output, "[ERR]") || len(output) > 0) +}