Merge log-batch to get error helpers for deprecation
This commit is contained in:
commit
a15c7e6441
2 changed files with 484 additions and 0 deletions
217
pkg/log/errors.go
Normal file
217
pkg/log/errors.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
267
pkg/log/errors_test.go
Normal file
267
pkg/log/errors_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue