From 77e53e30815b1c732430fbd8b675816958538ad1 Mon Sep 17 00:00:00 2001 From: Igor Lazarev Date: Mon, 9 Feb 2026 21:40:15 +0300 Subject: [PATCH 1/5] refactor: integrate native log/slog support and remove custom field system This commit introduces breaking changes by replacing the custom field system with Go's native log/slog attributes. Key updates include the removal of the FieldLogger interface and related field types, the addition of slog.Attr support, and the introduction of new logging functions. The migration guide is provided for transitioning from v0.4.1 to v0.5.0. --- MIGRATION.md | 561 +++++++++++++++++++++++++++++++ README.md | 201 +++++++++-- errors.go | 184 +++++----- errors_test.go | 34 +- errorstest/mock.go | 127 ++++++- example_log_test.go | 65 ++-- example_loggable_test.go | 51 ++- format_test.go | 2 +- go.mod | 6 +- go.sum | 10 - joining.go | 20 +- logging.go | 175 +++------- logging/logrusadapter/adapter.go | 87 ----- logging_test.go | 165 +++++++-- options.go | 104 +++++- slog_test.go | 545 ++++++++++++++++++++++++++++++ 16 files changed, 1874 insertions(+), 463 deletions(-) create mode 100644 MIGRATION.md delete mode 100644 logging/logrusadapter/adapter.go create mode 100644 slog_test.go diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..99e993d --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,561 @@ +# Migration Guide: v0.4.1 → v0.5.0 + +This guide will help you migrate from v0.4.1 to v0.5.0, which introduces native `log/slog` integration and removes the custom field system. + +## Overview of Changes + +Version 0.5.0 is a **breaking change** release that replaces the custom field system with Go's native `log/slog` attributes: + +- ❌ **Removed**: `FieldLogger`, `Logger` interfaces +- ❌ **Removed**: All field types (`BoolField`, `IntField`, etc.) +- ❌ **Removed**: `errors.Log(err, logger)` function +- ❌ **Removed**: `logging/logrusadapter` package +- ✅ **Added**: Native `slog.Attr` support +- ✅ **Added**: `slog.LogValuer` implementation +- ✅ **Added**: Grouped attributes via `slog.Group` +- ✅ **Added**: `errors.Attrs(err)` to extract attributes +- ✅ **Added**: `errors.Log(ctx, logger, level, err)` for slog logging +- 📦 **Minimum Go version**: 1.21 (for `log/slog` support) + +## Quick Migration Checklist + +- [ ] Update Go version to 1.21 or higher +- [ ] Remove imports of `errors/logging/logrusadapter` +- [ ] Replace old `errors.Log(err, logger)` calls with new `errors.Log(ctx, logger, level, err)` or direct slog usage +- [ ] Update custom error types implementing `LoggableError` +- [ ] Update mock loggers in tests to use `errorstest.Logger` +- [ ] Consider using grouped attributes for better structure + +## Breaking Changes + +### 1. Removed Interfaces and Types + +#### Before (v0.4.1) +```go +// These interfaces no longer exist +type FieldLogger interface { + SetBool(key string, value bool) + SetInt(key string, value int) + // ... 9 more methods +} + +type Logger interface { + FieldLogger + Log(message string) +} + +type Field interface { + Set(logger FieldLogger) +} + +// Field types like BoolField, IntField, etc. are removed +``` + +#### After (v0.5.0) +```go +// Use slog.Attr directly +import "log/slog" + +// LoggableError now returns slog.Attr +type LoggableError interface { + Attrs() []slog.Attr +} +``` + +### 2. Logging Function Changes + +#### Before (v0.4.1) +```go +import "github.com/muonsoft/errors/logging/logrusadapter" + +err := errors.Wrap(dbErr, errors.String("table", "users")) + +// Log with logrus adapter +logger := logrus.New() +logrusadapter.Log(err, logger) + +// Or with custom logger +errors.Log(err, myLogger) +``` + +#### After (v0.5.0) +```go +import "log/slog" + +err := errors.Wrap(dbErr, errors.String("table", "users")) + +// Option 1: Use Log convenience function +errors.Log(ctx, slog.Default(), slog.LevelError, err) + +// Option 2: Extract attributes and log manually +attrs := errors.Attrs(err) +slog.ErrorContext(ctx, err.Error(), attrsToAny(attrs)...) + +// Option 3: Use LogValuer (error logs its attributes automatically) +slog.Error("database error", "error", err) +``` + +### 3. Custom Error Types + +#### Before (v0.4.1) +```go +type ValidationError struct { + Field string + Message string +} + +func (e *ValidationError) Error() string { + return e.Message +} + +func (e *ValidationError) LogFields(logger errors.FieldLogger) { + logger.SetString("field", e.Field) + logger.SetString("validation_error", e.Message) +} +``` + +#### After (v0.5.0) +```go +import "log/slog" + +type ValidationError struct { + Field string + Message string +} + +func (e *ValidationError) Error() string { + return e.Message +} + +func (e *ValidationError) Attrs() []slog.Attr { + return []slog.Attr{ + slog.String("field", e.Field), + slog.String("validation_error", e.Message), + } +} +``` + +### 4. Test Code Changes + +#### Before (v0.4.1) +```go +func TestMyError(t *testing.T) { + err := errors.Wrap(baseErr, errors.String("key", "value")) + + loggable, ok := errors.As[errors.LoggableError](err) + require.True(t, ok) + + logger := errorstest.NewLogger() + loggable.LogFields(logger) + + logger.AssertField(t, "key", "value") +} +``` + +#### After (v0.5.0) +```go +func TestMyError(t *testing.T) { + err := errors.Wrap(baseErr, errors.String("key", "value")) + + attrs := errors.Attrs(err) + require.NotEmpty(t, attrs) + + // Option 1: Check attrs directly + require.Equal(t, "key", attrs[0].Key) + require.Equal(t, "value", attrs[0].Value.String()) + + // Option 2: Use mock logger + logger := errorstest.NewLogger() + logger.Attrs = attrs + logger.AssertField(t, "key", "value") +} +``` + +## New Features + +### 1. Grouped Attributes + +Group related attributes together for better structure: + +```go +err := errors.Wrap( + dbErr, + errors.Group("database", + slog.String("host", "localhost"), + slog.Int("port", 5432), + slog.String("name", "mydb"), + ), + errors.Group("query", + slog.String("sql", "SELECT * FROM users"), + slog.Duration("duration", 150*time.Millisecond), + ), +) + +// JSON output: +// { +// "error": "...", +// "database": { +// "host": "localhost", +// "port": 5432, +// "name": "mydb" +// }, +// "query": { +// "sql": "SELECT * FROM users", +// "duration": "150ms" +// } +// } + +// %+v output: +// error message +// database.host: localhost +// database.port: 5432 +// database.name: mydb +// query.sql: SELECT * FROM users +// query.duration: 150ms +``` + +### 2. Direct slog.Attr Usage + +Pass `slog.Attr` directly for maximum flexibility: + +```go +err := errors.Wrap( + err, + errors.Attr(slog.Int64("timestamp", time.Now().Unix())), + errors.Attr(slog.Group("metadata", + slog.String("version", "v1.2.3"), + slog.Bool("production", true), + )), +) +``` + +### 3. Multiple Attributes at Once + +```go +commonAttrs := []slog.Attr{ + slog.String("service", "api"), + slog.String("version", "v1.0.0"), +} + +err := errors.Wrap(err, errors.WithAttrs(commonAttrs...)) +``` + +### 4. slog.LogValuer Implementation + +Errors automatically work with slog: + +```go +err := errors.Wrap( + dbErr, + errors.String("table", "users"), + errors.Int("id", 123), +) + +// The error's attributes are automatically included +slog.Error("operation failed", "error", err) +``` + +## Migration Strategy + +### Step 1: Update Dependencies + +```bash +go get -u github.com/muonsoft/errors@v0.5.0 +go mod tidy +``` + +Update your `go.mod` to require Go 1.21+: + +```go +go 1.21 +``` + +### Step 2: Remove Logrus Adapter + +If you were using the logrus adapter: + +```go +// Remove this import +- import "github.com/muonsoft/errors/logging/logrusadapter" + +// Replace logrusadapter.Log() calls +- logrusadapter.Log(err, logrusLogger) + +// Option 1: Switch to slog ++ errors.Log(ctx, slog.Default(), slog.LevelError, err) + +// Option 2: Create your own logrus adapter ++ // See "Custom Logger Adapters" section below +``` + +### Step 3: Update Custom Error Types + +Search for types implementing `LogFields(logger FieldLogger)`: + +```bash +# Find custom error types +grep -r "LogFields.*FieldLogger" . +``` + +Update each one: + +```diff +- func (e *MyError) LogFields(logger errors.FieldLogger) { +- logger.SetString("key", e.value) +- } + ++ func (e *MyError) Attrs() []slog.Attr { ++ return []slog.Attr{ ++ slog.String("key", e.value), ++ } ++ } +``` + +### Step 4: Update Tests + +Replace mock logger usage: + +```diff +- logger := errorstest.NewLogger() +- errors.Log(err, logger) +- logger.AssertField(t, "key", "value") + ++ attrs := errors.Attrs(err) ++ logger := errorstest.NewLogger() ++ logger.Attrs = attrs ++ logger.AssertField(t, "key", "value") +``` + +### Step 5: Update Error Logging + +Replace `errors.Log()` calls: + +```diff +- errors.Log(err, myLogger) + ++ // Option 1: Use Log ++ errors.Log(ctx, slog.Default(), slog.LevelError, err) + ++ // Option 2: Extract and log ++ attrs := errors.Attrs(err) ++ slog.ErrorContext(ctx, err.Error(), attrsToAny(attrs)...) +``` + +## Custom Logger Adapters + +If you still need to use logrus or another logging library, create a simple adapter: + +### Logrus Adapter Example + +```go +package myapp + +import ( + "log/slog" + "github.com/muonsoft/errors" + "github.com/sirupsen/logrus" +) + +func LogWithLogrus(err error, logger *logrus.Logger) { + if err == nil { + return + } + + // Extract attributes + attrs := errors.Attrs(err) + + // Convert to logrus fields + fields := logrus.Fields{} + for _, attr := range attrs { + fields[attr.Key] = attrValue(attr) + } + + // Log with logrus + logger.WithFields(fields).Error(err.Error()) +} + +func attrValue(attr slog.Attr) interface{} { + if attr.Value.Kind() == slog.KindGroup { + // Handle groups recursively + group := make(map[string]interface{}) + for _, a := range attr.Value.Group() { + group[a.Key] = attrValue(a) + } + return group + } + return attr.Value.Any() +} +``` + +### Zerolog Adapter Example + +```go +package myapp + +import ( + "log/slog" + "github.com/muonsoft/errors" + "github.com/rs/zerolog" +) + +func LogWithZerolog(err error, logger zerolog.Logger) { + if err == nil { + return + } + + event := logger.Error() + + // Add attributes + attrs := errors.Attrs(err) + for _, attr := range attrs { + addAttrToZerolog(event, attr) + } + + event.Msg(err.Error()) +} + +func addAttrToZerolog(event *zerolog.Event, attr slog.Attr) { + switch attr.Value.Kind() { + case slog.KindString: + event.Str(attr.Key, attr.Value.String()) + case slog.KindInt64: + event.Int64(attr.Key, attr.Value.Int64()) + case slog.KindBool: + event.Bool(attr.Key, attr.Value.Bool()) + case slog.KindGroup: + dict := zerolog.Dict() + for _, a := range attr.Value.Group() { + addAttrToDict(dict, a) + } + event.Dict(attr.Key, dict) + default: + event.Interface(attr.Key, attr.Value.Any()) + } +} +``` + +## Benefits of Migration + +### 1. No External Dependencies + +v0.5.0 has zero external dependencies. Everything is based on Go standard library. + +### 2. Better Structured Logs + +Grouped attributes provide better organization: + +```json +{ + "error": "database query failed", + "database": { + "host": "localhost", + "port": 5432, + "name": "production" + }, + "query": { + "sql": "SELECT * FROM users WHERE id = ?", + "params": [123], + "duration": "150ms" + } +} +``` + +### 3. Native slog Integration + +Works seamlessly with any slog-compatible logger: + +```go +// OpenTelemetry +logger := otelslog.NewHandler(...) + +// Custom handler +logger := slog.New(myHandler) + +// Works the same way +errors.Log(ctx, logger, slog.LevelError, err) +``` + +### 4. Simplified Testing + +Testing is more straightforward with direct attribute access: + +```go +attrs := errors.Attrs(err) +require.Len(t, attrs, 3) +require.Equal(t, "user_id", attrs[0].Key) +require.Equal(t, int64(123), attrs[0].Value.Any()) +``` + +## Troubleshooting + +### Issue: "cannot use errors.String() as type Option" + +**Cause**: Import path is wrong or mixing v0.4.1 and v0.5.0. + +**Solution**: Ensure you're using v0.5.0 consistently: +```bash +go get github.com/muonsoft/errors@v0.5.0 +go mod tidy +``` + +### Issue: "FieldLogger undefined" + +**Cause**: Trying to use removed interface. + +**Solution**: Implement `LoggableError.Attrs()` instead: +```go +func (e *MyError) Attrs() []slog.Attr { + return []slog.Attr{ + slog.String("key", e.Value), + } +} +``` + +### Issue: "cannot convert attrs to []any" + +**Cause**: Trying to use `[]slog.Attr` directly with slog methods. + +**Solution**: Convert to `[]any` or use `Log()`: +```go +// Option 1: Use Log +errors.Log(ctx, logger, level, err) + +// Option 2: Convert manually +attrs := errors.Attrs(err) +args := make([]any, len(attrs)) +for i, a := range attrs { + args[i] = a +} +slog.ErrorContext(ctx, err.Error(), args...) +``` + +### Issue: Tests failing with "Fields undefined" + +**Cause**: Old mock logger usage. + +**Solution**: Update to new mock logger API: +```go +logger := errorstest.NewLogger() +logger.Attrs = errors.Attrs(err) +logger.AssertField(t, "key", "value") +``` + +## Support + +If you encounter issues during migration: + +1. Check [examples](examples/) directory for working code +2. Open an [issue](https://github.com/muonsoft/errors/issues) on GitHub +3. Start a [discussion](https://github.com/muonsoft/errors/discussions) for questions + +## Summary + +The migration to v0.5.0 modernizes the errors package by embracing Go's native `log/slog`. While it requires some code changes, the benefits include: + +- ✅ Zero external dependencies +- ✅ Native slog integration +- ✅ Better structured logging with groups +- ✅ Simplified API +- ✅ Future-proof with Go's standard library + +Most migrations can be completed in a few hours by following this guide. diff --git a/README.md b/README.md index 8480bde..232519f 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,13 @@ [![Maintainability](https://api.codeclimate.com/v1/badges/fe1720426006f3af30b0/maintainability)](https://codeclimate.com/github/muonsoft/errors/maintainability) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.0-4baaaa.svg)](CODE_OF_CONDUCT.md) -Errors package for structured logging. Adds stack trace without a pain +Errors package for structured logging with native `log/slog` integration. Adds stack trace without a pain (no confuse with `Wrap`/`WithMessage` methods). +> **⚠️ Breaking Changes in v0.5.0** +> Version 0.5.0 replaces the custom field system with native `log/slog` integration. +> If you're migrating from v0.4.1, please read the [Migration Guide](MIGRATION.md). + ## Key features This package is based on well known [github.com/pkg/errors](https://github.com/pkg/errors). @@ -22,13 +26,17 @@ Key differences and features: * minimalistic API: few methods to wrap an error: `errors.Errorf()`, `errors.Wrap()`; * adds stack trace idempotently (only once in a chain); * `errors.As()` method is based on typed parameters (aka generics); -* options to skip caller in a stack trace and to add error fields for structured logging; -* error fields are made for the statically typed logger interface; +* options to skip caller in a stack trace and to add error attributes for structured logging; +* **native integration with Go's `log/slog`** - error attributes use `slog.Attr`; +* **supports grouped attributes** via `slog.Group`; +* implements `slog.LogValuer` for seamless slog integration; * package errors can be easily marshaled into JSON with all fields in a chain. ## Additional features * `errors.IsOfType[T any](err error)` to test for error types. +* `errors.Attrs(err error) []slog.Attr` to extract all attributes from error chain. +* `errors.Log(ctx, logger, level, err)` convenience function for logging with slog. ## Installation @@ -38,6 +46,10 @@ Run the following command to install the package go get -u github.com/muonsoft/errors ``` +Requires Go 1.21+ for `log/slog` support. + +**Migrating from v0.4.1?** See the [Migration Guide](MIGRATION.md) for detailed instructions. + ## How to use ### `errors.New()` for package-level errors @@ -57,14 +69,14 @@ func NewNotFoundError() error { } ``` -### `errors.Errorf()` for wrapping errors with formatted message, fields and stack trace +### `errors.Errorf()` for wrapping errors with formatted message, attributes and stack trace `errors.Errorf()` is an equivalent to standard `fmt.Errorf()`. It formats according to a format specifier and returns the string as a value that satisfies error. You can wrap an error using `%w` modifier. `errors.Errorf()` also records the stack trace at the point it was called. If the wrapped error -contains a stack trace then a new one will not be added to a chain. Also, you can pass an -options to set a structured fields or to skip a caller in a stack trace. +contains a stack trace then a new one will not be added to a chain. Also, you can pass +options to set structured attributes or to skip a caller in a stack trace. Options must be specified after formatting arguments. ```golang @@ -73,7 +85,7 @@ var product Product err := row.Scan(&product.ID, &product.Name) if err != nil { // Use errors.Errorf to wrap the library error with the message context and - // error fields to be used for structured logging. + // error attributes to be used for structured logging. return nil, errors.Errorf( "%w: %v", errSQLError, err.Error(), errors.String("sql", findSQL), @@ -82,18 +94,18 @@ if err != nil { } ``` -### `errors.Wrap()` for wrapping errors with fields and stack trace +### `errors.Wrap()` for wrapping errors with attributes and stack trace `errors.Wrap()` returns an error annotating err with a stack trace at the point `errors.Wrap()` is called. If the wrapped error contains a stack trace then a new one will not be added to a chain. -If err is nil, Wrap returns nil. Also, you can pass an options to set a structured fields or to skip a caller +If err is nil, Wrap returns nil. Also, you can pass options to set structured attributes or to skip a caller in a stack trace. ```golang data, err := service.Handle(ctx, userID, message) if err != nil { // Adds a stack trace to the line that was called (if there is no stack trace in the chain already) - // and adds fields for structured logging. + // and adds attributes for structured logging. return nil, errors.Wrap( err, errors.Int("userID", userID), @@ -102,9 +114,42 @@ if err != nil { } ``` +### Working with grouped attributes + +The package supports grouped attributes via `slog.Group`, allowing you to organize related attributes: + +```golang +err := errors.Wrap( + dbErr, + errors.Group("request", + slog.String("method", "POST"), + slog.String("path", "/api/users"), + slog.Int("status", 500), + ), + errors.Group("database", + slog.String("query", "INSERT INTO users..."), + slog.Duration("duration", 150*time.Millisecond), + ), +) +``` + +You can also use `errors.Attr()` to pass `slog.Attr` directly: + +```golang +err := errors.Wrap( + err, + errors.Attr(slog.Int64("timestamp", time.Now().Unix())), + errors.Attr(slog.Group("metadata", + slog.String("version", "v1.2.3"), + slog.Bool("production", true), + )), +) +``` + ### Printing error with stack trace -You can use formatting with `%+v` modifier to print errors with message, fields for logging and a stack trace. +You can use formatting with `%+v` modifier to print errors with message, attributes and stack trace. +Grouped attributes are displayed using dot notation. Example @@ -117,7 +162,10 @@ func main() { ) err = errors.Errorf( "find product: %w", err, - errors.String("requestID", "24874020-cab7-4ef3-bac5-76858832f8b0"), + errors.Group("request", + slog.String("id", "24874020-cab7-4ef3-bac5-76858832f8b0"), + slog.String("method", "GET"), + ), ) fmt.Printf("%+v", err) } @@ -127,7 +175,8 @@ Output ``` find product: sql error: sql: no rows in result set -requestID: 24874020-cab7-4ef3-bac5-76858832f8b0 +request.id: 24874020-cab7-4ef3-bac5-76858832f8b0 +request.method: GET sql: SELECT id, name FROM product WHERE id = ? productID: 123 main.main @@ -140,7 +189,7 @@ runtime.goexit ### Marshal error into JSON -Wrapped errors implements `json.Marshaler` interface. So you can easily marshal errors into JSON. +Wrapped errors implement `json.Marshaler` interface. Grouped attributes are marshaled as nested objects. Example @@ -153,7 +202,10 @@ func main() { ) err = errors.Errorf( "find product: %w", err, - errors.String("requestID", "24874020-cab7-4ef3-bac5-76858832f8b0"), + errors.Group("request", + slog.String("id", "24874020-cab7-4ef3-bac5-76858832f8b0"), + slog.String("method", "GET"), + ), ) errJSON, err := json.MarshalIndent(err, "", "\t") if err != nil { @@ -169,7 +221,10 @@ Output { "error": "find product: sql error: sql: no rows in result set", "productID": 123, - "requestID": "24874020-cab7-4ef3-bac5-76858832f8b0", + "request": { + "id": "24874020-cab7-4ef3-bac5-76858832f8b0", + "method": "GET" + }, "sql": "SELECT id, name FROM product WHERE id = ?", "stackTrace": [ { @@ -191,31 +246,117 @@ Output } ``` -### Structured logging +### Structured logging with slog -To use structured logging, you need to use an adapter for your logging system. It can be one of the -built-in adapters from the `logging` directory, or you can implement your own adapter using `errors.Logger` interface. +The package provides native integration with Go's `log/slog`. Errors implement `slog.LogValuer`, +so they work seamlessly with any slog logger. -Example of using an adapter for [Logrus](https://github.com/sirupsen/logrus). +#### Using Log convenience function ```golang err := errors.Errorf( - "sql error: %w", sql.ErrNoRows, - errors.String("sql", "SELECT id, name FROM product WHERE id = ?"), - errors.Int("productID", 123), + "database query failed: %w", dbErr, + errors.String("query", "SELECT * FROM users WHERE id = ?"), + errors.Int("userID", 123), + errors.Group("performance", + slog.Duration("duration", 250*time.Millisecond), + slog.Int("retries", 3), + ), ) -err = errors.Errorf( - "find product: %w", err, - errors.String("requestID", "24874020-cab7-4ef3-bac5-76858832f8b0"), + +// Log error with all attributes and stack trace +errors.Log(ctx, slog.Default(), slog.LevelError, err) +``` + +#### Extracting attributes manually + +```golang +err := errors.Errorf( + "operation failed: %w", someErr, + errors.String("operation", "user.create"), + errors.Int("userID", 123), ) -logger := logrus.New() -logrusadapter.Log(err, logger) + +// Extract all attributes from error chain +attrs := errors.Attrs(err) + +// Use with slog +slog.Error("request failed", append([]any{slog.Any("error", err)}, attrsToAny(attrs)...)...) ``` -Output +#### Using slog.LogValuer +Errors automatically work as `slog.LogValuer`, so you can log them directly: + +```golang +err := errors.Wrap( + dbErr, + errors.String("table", "users"), + errors.Int("id", 123), +) + +// The error will automatically provide its attributes to slog +slog.Error("database error", "error", err) +``` + +### Custom LoggableError types + +You can implement `errors.LoggableError` interface on your custom error types to provide +structured attributes: + +```golang +type ValidationError struct { + Field string + Value interface{} + Message string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("validation failed: %s", e.Message) +} + +// Implement errors.LoggableError +func (e *ValidationError) Attrs() []slog.Attr { + return []slog.Attr{ + slog.String("field", e.Field), + slog.Any("value", e.Value), + slog.String("validation_message", e.Message), + } +} + +// Usage +err := &ValidationError{ + Field: "email", + Value: "invalid-email", + Message: "must be a valid email address", +} +wrapped := errors.Wrap(err, errors.String("operation", "user.create")) + +// All attributes from ValidationError will be included +attrs := errors.Attrs(wrapped) ``` -ERRO[0000] find product: sql error: sql: no rows in result set productID=123 requestID=24874020-cab7-4ef3-bac5-76858832f8b0 sql="SELECT id, name FROM product WHERE id = ?" stackTrace="[{main.main /home/strider/projects/errors/var/scratch.go 12} {runtime.main /usr/local/go/src/runtime/proc.go 250} {runtime.goexit /usr/local/go/src/runtime/asm_amd64.s 1571}]" + +## Available attribute options + +The package provides convenience functions for creating attributes: + +```golang +errors.Bool(key string, value bool) +errors.Int(key string, value int) +errors.Uint(key string, value uint) +errors.Float(key string, value float64) +errors.String(key string, value string) +errors.Stringer(key string, value fmt.Stringer) +errors.Strings(key string, values []string) +errors.Value(key string, value interface{}) +errors.Time(key string, value time.Time) +errors.Duration(key string, value time.Duration) +errors.JSON(key string, value json.RawMessage) + +// New slog-specific options +errors.Attr(attr slog.Attr) // Add any slog.Attr directly +errors.WithAttrs(attrs ...slog.Attr) // Add multiple slog.Attr values +errors.Group(key string, attrs ...slog.Attr) // Create a grouped attribute ``` ## Contributing diff --git a/errors.go b/errors.go index 8c5942e..58febd2 100644 --- a/errors.go +++ b/errors.go @@ -11,7 +11,7 @@ // - minimalistic API: few methods to wrap an error: errors.Errorf(), errors.Wrap(); // - adds stack trace idempotently (only once in a chain); // - options to skip caller in a stack trace and to add error fields for structured logging; -// - error fields are made for the statically typed logger interface; +// - error attributes use slog.Attr for native integration with Go's structured logging; // - package errors can be easily marshaled into JSON with all fields in a chain. package errors @@ -20,8 +20,8 @@ import ( "errors" "fmt" "io" + "log/slog" "strconv" - "time" ) // New returns an error that formats as the given text. @@ -126,11 +126,11 @@ func Errorf(message string, argsAndOptions ...interface{}) error { argErrors := getArgErrors(message, args) if len(argErrors) == 1 && isWrapper(argErrors[0]) { - return &wrapped{wrapped: err, fields: opts.fields} + return &wrapped{wrapped: err, attrs: opts.attrs} } return &stacked{ - wrapped: &wrapped{wrapped: err, fields: opts.fields}, + wrapped: &wrapped{wrapped: err, attrs: opts.attrs}, stack: newStack(opts.skipCallers), } } @@ -149,13 +149,13 @@ func Wrap(err error, options ...Option) error { return err } - return &wrapped{wrapped: err, fields: newOptions(options...).fields} + return &wrapped{wrapped: err, attrs: newOptions(options...).attrs} } opts := newOptions(options...) return &stacked{ - wrapped: &wrapped{wrapped: err, fields: opts.fields}, + wrapped: &wrapped{wrapped: err, attrs: opts.attrs}, stack: newStack(opts.skipCallers), } } @@ -177,17 +177,17 @@ func isWrapper(err error) bool { type wrapped struct { wrapper wrapped error - fields []Field + attrs []slog.Attr } -func (e *wrapped) Fields() []Field { return e.fields } -func (e *wrapped) Error() string { return e.wrapped.Error() } -func (e *wrapped) Unwrap() error { return e.wrapped } +func (e *wrapped) Attrs() []slog.Attr { return e.attrs } +func (e *wrapped) Error() string { return e.wrapped.Error() } +func (e *wrapped) Unwrap() error { return e.wrapped } -func (e *wrapped) LogFields(logger FieldLogger) { - for _, field := range e.fields { - field.Set(logger) - } +// LogValue implements slog.LogValuer, allowing the error to be logged +// directly with slog and have its attributes automatically extracted. +func (e *wrapped) LogValue() slog.Value { + return slog.GroupValue(e.attrs...) } func (e *wrapped) Format(s fmt.State, verb rune) { @@ -195,11 +195,10 @@ func (e *wrapped) Format(s fmt.State, verb rune) { case 'v': io.WriteString(s, e.Error()) if s.Flag('+') { - fieldsWriter := &stringWriter{writer: s} var err error for err = e; err != nil; err = Unwrap(err) { if loggable, ok := err.(LoggableError); ok { - loggable.LogFields(fieldsWriter) + writeAttrs(s, loggable.Attrs(), "") } if tracer, ok := err.(stackTracer); ok { tracer.StackTrace().Format(s, verb) @@ -212,15 +211,15 @@ func (e *wrapped) Format(s fmt.State, verb rune) { } func (e *wrapped) MarshalJSON() ([]byte, error) { - data := mapWriter{"error": e.Error()} + data := map[string]interface{}{"error": e.Error()} var err error for err = e; err != nil; err = Unwrap(err) { if loggable, ok := err.(LoggableError); ok { - loggable.LogFields(data) + attrsToMap(data, loggable.Attrs()) } if tracer, ok := err.(stackTracer); ok { - data.SetStackTrace(tracer.StackTrace()) + data["stackTrace"] = tracer.StackTrace() } } @@ -237,7 +236,7 @@ func (e *stacked) Format(s fmt.State, verb rune) { case 'v': if s.Flag('+') { io.WriteString(s, e.wrapped.Error()) - e.wrapped.LogFields(&stringWriter{writer: s}) + writeAttrs(s, e.wrapped.Attrs(), "") e.stack.Format(s, verb) return } @@ -250,13 +249,13 @@ func (e *stacked) Format(s fmt.State, verb rune) { } func (e *stacked) MarshalJSON() ([]byte, error) { - data := mapWriter{"error": e.Error()} - data.SetStackTrace(e.StackTrace()) + data := map[string]interface{}{"error": e.Error()} + data["stackTrace"] = e.StackTrace() var err error for err = e; err != nil; err = Unwrap(err) { if loggable, ok := err.(LoggableError); ok { - loggable.LogFields(data) + attrsToMap(data, loggable.Attrs()) } } @@ -317,72 +316,89 @@ func getErrorIndices(message string) []int { return indices } -type mapWriter map[string]interface{} - -func (m mapWriter) SetBool(key string, value bool) { m[key] = value } -func (m mapWriter) SetInt(key string, value int) { m[key] = value } -func (m mapWriter) SetUint(key string, value uint) { m[key] = value } -func (m mapWriter) SetFloat(key string, value float64) { m[key] = value } -func (m mapWriter) SetString(key string, value string) { m[key] = value } -func (m mapWriter) SetStrings(key string, values []string) { m[key] = values } -func (m mapWriter) SetValue(key string, value interface{}) { m[key] = value } -func (m mapWriter) SetTime(key string, value time.Time) { m[key] = value } -func (m mapWriter) SetDuration(key string, value time.Duration) { m[key] = value } -func (m mapWriter) SetJSON(key string, value json.RawMessage) { m[key] = value } -func (m mapWriter) SetStackTrace(trace StackTrace) { m["stackTrace"] = trace } - -type stringWriter struct { - writer io.Writer -} - -func (s *stringWriter) SetBool(key string, value bool) { - if value { - io.WriteString(s.writer, "\n"+key+": true") - } else { - io.WriteString(s.writer, "\n"+key+": false") +// attrsToMap converts slog.Attr slice to a map for JSON marshaling. +// Groups with keys create nested maps. Groups without keys merge into the parent map. +func attrsToMap(target map[string]interface{}, attrs []slog.Attr) { + for _, attr := range attrs { + if attr.Value.Kind() == slog.KindGroup { + groupAttrs := attr.Value.Group() + if attr.Key == "" { + // Group without key - merge into parent + attrsToMap(target, groupAttrs) + } else { + // Group with key - create nested map + nested := make(map[string]interface{}) + attrsToMap(nested, groupAttrs) + target[attr.Key] = nested + } + } else { + target[attr.Key] = attr.Value.Any() + } } } -func (s *stringWriter) SetInt(key string, value int) { - io.WriteString(s.writer, "\n"+key+": "+strconv.Itoa(value)) -} - -func (s *stringWriter) SetUint(key string, value uint) { - io.WriteString(s.writer, "\n"+key+": "+strconv.FormatUint(uint64(value), 10)) -} - -func (s *stringWriter) SetFloat(key string, value float64) { - io.WriteString(s.writer, "\n"+key+": "+fmt.Sprintf("%f", value)) -} - -func (s *stringWriter) SetString(key string, value string) { - io.WriteString(s.writer, "\n"+key+": "+value) -} - -func (s *stringWriter) SetStrings(key string, values []string) { - io.WriteString(s.writer, "\n"+key+": ") - for i, value := range values { - if i > 0 { - io.WriteString(s.writer, ", ") +// writeAttrs writes slog.Attr values to an io.Writer for %+v formatting. +// Groups with keys use dot notation (e.g., "group.key: value"). +// Groups without keys merge their attributes at the current prefix level. +func writeAttrs(w io.Writer, attrs []slog.Attr, prefix string) { + for _, attr := range attrs { + if attr.Value.Kind() == slog.KindGroup { + groupAttrs := attr.Value.Group() + if attr.Key == "" { + // Group without key - use same prefix + writeAttrs(w, groupAttrs, prefix) + } else { + // Group with key - add to prefix + newPrefix := prefix + attr.Key + "." + writeAttrs(w, groupAttrs, newPrefix) + } + } else { + writeAttr(w, prefix+attr.Key, attr.Value) } - io.WriteString(s.writer, value) } } -func (s *stringWriter) SetValue(key string, value interface{}) { - io.WriteString(s.writer, "\n"+key+": "+fmt.Sprintf("%v", value)) -} - -func (s *stringWriter) SetTime(key string, value time.Time) { - io.WriteString(s.writer, "\n"+key+": "+value.String()) -} - -func (s *stringWriter) SetDuration(key string, value time.Duration) { - io.WriteString(s.writer, "\n"+key+": "+value.String()) -} - -func (s *stringWriter) SetJSON(key string, value json.RawMessage) { - io.WriteString(s.writer, "\n"+key+": "+string(value)) +// writeAttr writes a single attribute value to an io.Writer +func writeAttr(w io.Writer, key string, value slog.Value) { + io.WriteString(w, "\n"+key+": ") + + switch value.Kind() { + case slog.KindBool: + if value.Bool() { + io.WriteString(w, "true") + } else { + io.WriteString(w, "false") + } + case slog.KindInt64: + io.WriteString(w, strconv.FormatInt(value.Int64(), 10)) + case slog.KindUint64: + io.WriteString(w, strconv.FormatUint(value.Uint64(), 10)) + case slog.KindFloat64: + io.WriteString(w, fmt.Sprintf("%f", value.Float64())) + case slog.KindString: + io.WriteString(w, value.String()) + case slog.KindTime: + io.WriteString(w, value.Time().String()) + case slog.KindDuration: + io.WriteString(w, value.Duration().String()) + default: + // For Any and other types, handle special cases + v := value.Any() + switch typed := v.(type) { + case []string: + // Format string slices with comma separation + for i, s := range typed { + if i > 0 { + io.WriteString(w, ", ") + } + io.WriteString(w, s) + } + case json.RawMessage: + // Format JSON as string + io.WriteString(w, string(typed)) + default: + // Default formatting + io.WriteString(w, fmt.Sprintf("%v", v)) + } + } } - -func (s *stringWriter) SetStackTrace(trace StackTrace) {} diff --git a/errors_test.go b/errors_test.go index e39a2be..0da88c5 100644 --- a/errors_test.go +++ b/errors_test.go @@ -6,11 +6,11 @@ import ( "fmt" "io/fs" "os" + "reflect" "testing" "time" "github.com/muonsoft/errors" - "github.com/muonsoft/errors/errorstest" ) func TestStackTrace(t *testing.T) { @@ -239,12 +239,12 @@ func TestFields(t *testing.T) { { name: "int", err: errors.Wrap(errors.Errorf("error"), errors.Int("key", 1)), - expected: 1, + expected: int64(1), // slog.Int returns int64 }, { name: "uint", err: errors.Wrap(errors.Errorf("error"), errors.Uint("key", 1)), - expected: uint(1), + expected: uint64(1), // slog.Any(uint) returns uint64 }, { name: "float", @@ -312,13 +312,29 @@ func TestFields(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - loggable, ok := errors.As[errors.LoggableError](test.err) - if !ok { - t.Fatalf("expected %#v to implement errors.LoggableError", test.err) + attrs := errors.Attrs(test.err) + if len(attrs) == 0 { + t.Fatalf("expected %#v to have attributes", test.err) + } + + // Find the "key" attribute + var found bool + var value interface{} + for _, attr := range attrs { + if attr.Key == "key" { + value = attr.Value.Any() + found = true + break + } + } + + if !found { + t.Fatalf("expected %#v to have attribute with key 'key'", test.err) + } + + if !reflect.DeepEqual(value, test.expected) { + t.Errorf("want value %v (%T), got %v (%T)", test.expected, test.expected, value, value) } - logger := errorstest.NewLogger() - loggable.LogFields(logger) - logger.AssertField(t, "key", test.expected) }) } } diff --git a/errorstest/mock.go b/errorstest/mock.go index 620b194..e911ab5 100644 --- a/errorstest/mock.go +++ b/errorstest/mock.go @@ -1,11 +1,10 @@ package errorstest import ( - "encoding/json" + "log/slog" "reflect" "regexp" "testing" - "time" "github.com/muonsoft/errors" ) @@ -19,28 +18,16 @@ type Frame struct { } type Logger struct { - Fields map[string]interface{} + Attrs []slog.Attr StackTrace errors.StackTrace Message string + Level slog.Level } func NewLogger() *Logger { - return &Logger{Fields: make(map[string]interface{})} + return &Logger{Attrs: make([]slog.Attr, 0)} } -func (m *Logger) SetBool(key string, value bool) { m.Fields[key] = value } -func (m *Logger) SetInt(key string, value int) { m.Fields[key] = value } -func (m *Logger) SetUint(key string, value uint) { m.Fields[key] = value } -func (m *Logger) SetFloat(key string, value float64) { m.Fields[key] = value } -func (m *Logger) SetString(key string, value string) { m.Fields[key] = value } -func (m *Logger) SetStrings(key string, values []string) { m.Fields[key] = values } -func (m *Logger) SetValue(key string, value interface{}) { m.Fields[key] = value } -func (m *Logger) SetTime(key string, value time.Time) { m.Fields[key] = value } -func (m *Logger) SetDuration(key string, value time.Duration) { m.Fields[key] = value } -func (m *Logger) SetJSON(key string, value json.RawMessage) { m.Fields[key] = value } -func (m *Logger) SetStackTrace(trace errors.StackTrace) { m.StackTrace = trace } -func (m *Logger) Log(message string) { m.Message = message } - func (m *Logger) AssertMessage(t *testing.T, expected string) { t.Helper() @@ -49,10 +36,12 @@ func (m *Logger) AssertMessage(t *testing.T, expected string) { } } +// AssertField checks if an attribute with the given key exists and has the expected value. +// It searches through all attributes, including nested groups (flattened with dot notation). func (m *Logger) AssertField(t *testing.T, key string, expected interface{}) { t.Helper() - value, exists := m.Fields[key] + value, exists := m.findAttr(key, m.Attrs, "") if !exists { t.Errorf(`want logger to have a field with key "%s"`, key) return @@ -62,6 +51,80 @@ func (m *Logger) AssertField(t *testing.T, key string, expected interface{}) { } } +// findAttr recursively searches for an attribute by key, handling groups with dot notation +func (m *Logger) findAttr(key string, attrs []slog.Attr, prefix string) (interface{}, bool) { + for _, attr := range attrs { + if attr.Value.Kind() == slog.KindGroup { + groupAttrs := attr.Value.Group() + if attr.Key == "" { + // Group without key - search within same prefix + if value, ok := m.findAttr(key, groupAttrs, prefix); ok { + return value, true + } + } else { + // Group with key - add to prefix + newPrefix := prefix + attr.Key + "." + if value, ok := m.findAttr(key, groupAttrs, newPrefix); ok { + return value, true + } + } + } else { + fullKey := prefix + attr.Key + if fullKey == key { + return attr.Value.Any(), true + } + } + } + return nil, false +} + +// AssertAttr checks if an attribute with matching key and value exists in the logger. +func (m *Logger) AssertAttr(t *testing.T, expected slog.Attr) { + t.Helper() + + for _, attr := range m.Attrs { + if attrsEqual(attr, expected) { + return + } + } + + t.Errorf(`want logger to have attribute %v`, expected) +} + +// AssertGroup checks if a group attribute with the given key exists and contains the expected attributes. +func (m *Logger) AssertGroup(t *testing.T, key string, expectedAttrs ...slog.Attr) { + t.Helper() + + var groupAttrs []slog.Attr + found := false + + for _, attr := range m.Attrs { + if attr.Key == key && attr.Value.Kind() == slog.KindGroup { + groupAttrs = attr.Value.Group() + found = true + break + } + } + + if !found { + t.Errorf(`want logger to have a group with key "%s"`, key) + return + } + + for _, expected := range expectedAttrs { + found := false + for _, actual := range groupAttrs { + if attrsEqual(actual, expected) { + found = true + break + } + } + if !found { + t.Errorf(`want group "%s" to contain attribute %v`, key, expected) + } + } +} + func (m *Logger) AssertStackTrace(t *testing.T, want StackTrace) { t.Helper() @@ -93,3 +156,31 @@ func (m *Logger) AssertStackTrace(t *testing.T, want StackTrace) { } } } + +// attrsEqual compares two slog.Attr values for equality +func attrsEqual(a, b slog.Attr) bool { + if a.Key != b.Key { + return false + } + if a.Value.Kind() != b.Value.Kind() { + return false + } + + // For groups, recursively compare + if a.Value.Kind() == slog.KindGroup { + aGroup := a.Value.Group() + bGroup := b.Value.Group() + if len(aGroup) != len(bGroup) { + return false + } + for i := range aGroup { + if !attrsEqual(aGroup[i], bGroup[i]) { + return false + } + } + return true + } + + // For other types, compare values + return reflect.DeepEqual(a.Value.Any(), b.Value.Any()) +} diff --git a/example_log_test.go b/example_log_test.go index d4634dd..7102530 100644 --- a/example_log_test.go +++ b/example_log_test.go @@ -9,7 +9,6 @@ import ( "strings" "github.com/muonsoft/errors" - "github.com/muonsoft/errors/errorstest" ) var ( @@ -124,15 +123,24 @@ func ExampleLog_typicalErrorHandling() { ) fmt.Println(`repository error as JSON, field "stackTrace[0].line":`, jsonError.StackTrace[0].Line) - // Log error with structured logger. - logger := errorstest.NewLogger() - errors.Log(notFoundError, logger) - fmt.Println(`log repository error, message:`, logger.Message) + // Get attributes from error + attrs := errors.Attrs(notFoundError) + + // Get stack trace + var stackTrace errors.StackTrace + for e := notFoundError; e != nil; e = errors.Unwrap(e) { + if s, ok := e.(interface{ StackTrace() errors.StackTrace }); ok { + stackTrace = s.StackTrace() + break + } + } + + fmt.Println(`log repository error, attrs count:`, len(attrs)) fmt.Printf( "log repository error, first line of stack trace: %s %s:%d\n", - logger.StackTrace[0].Name(), - logger.StackTrace[0].File()[strings.LastIndex(logger.StackTrace[0].File(), "/")+1:], - logger.StackTrace[0].Line(), + stackTrace[0].Name(), + stackTrace[0].File()[strings.LastIndex(stackTrace[0].File(), "/")+1:], + stackTrace[0].Line(), ) } @@ -155,16 +163,30 @@ func ExampleLog_typicalErrorHandling() { ) fmt.Println(`repository error as JSON, field "stackTrace[0].line":`, jsonError.StackTrace[0].Line) - // Log error with structured logger. - logger := errorstest.NewLogger() - errors.Log(sqlError, logger) - fmt.Println(`log repository error, message:`, logger.Message) - fmt.Println(`log repository error, fields:`, logger.Fields) + // Get attributes from error + attrs := errors.Attrs(sqlError) + + // Create a map for display + fields := make(map[string]interface{}) + for _, attr := range attrs { + fields[attr.Key] = attr.Value.Any() + } + + // Get stack trace + var stackTrace errors.StackTrace + for e := sqlError; e != nil; e = errors.Unwrap(e) { + if s, ok := e.(interface{ StackTrace() errors.StackTrace }); ok { + stackTrace = s.StackTrace() + break + } + } + + fmt.Println(`log repository error, fields:`, fields) fmt.Printf( "log repository error, first line of stack trace: %s %s:%d\n", - logger.StackTrace[0].Name(), - logger.StackTrace[0].File()[strings.LastIndex(logger.StackTrace[0].File(), "/")+1:], - logger.StackTrace[0].Line(), + stackTrace[0].Name(), + stackTrace[0].File()[strings.LastIndex(stackTrace[0].File(), "/")+1:], + stackTrace[0].Line(), ) } @@ -174,16 +196,15 @@ func ExampleLog_typicalErrorHandling() { // repository error as JSON, field "error": not found // repository error as JSON, field "stackTrace[0].function": github.com/muonsoft/errors_test.(*ProductRepository).FindByID // repository error as JSON, field "stackTrace[0].file": example_log_test.go - // repository error as JSON, field "stackTrace[0].line": 63 - // log repository error, message: not found - // log repository error, first line of stack trace: github.com/muonsoft/errors_test.(*ProductRepository).FindByID example_log_test.go:63 + // repository error as JSON, field "stackTrace[0].line": 62 + // log repository error, attrs count: 0 + // log repository error, first line of stack trace: github.com/muonsoft/errors_test.(*ProductRepository).FindByID example_log_test.go:62 // repository error: sql error: sql: connection is already closed // repository error is errSQLError: true // repository error as JSON, field "error": sql error: sql: connection is already closed // repository error as JSON, field "stackTrace[0].function": github.com/muonsoft/errors_test.(*ProductRepository).FindByID // repository error as JSON, field "stackTrace[0].file": example_log_test.go - // repository error as JSON, field "stackTrace[0].line": 68 - // log repository error, message: sql error: sql: connection is already closed + // repository error as JSON, field "stackTrace[0].line": 67 // log repository error, fields: map[productID:123 sql:SELECT id, name FROM product WHERE id = ?] - // log repository error, first line of stack trace: github.com/muonsoft/errors_test.(*ProductRepository).FindByID example_log_test.go:68 + // log repository error, first line of stack trace: github.com/muonsoft/errors_test.(*ProductRepository).FindByID example_log_test.go:67 } diff --git a/example_loggable_test.go b/example_loggable_test.go index 990af4e..50aacf9 100644 --- a/example_loggable_test.go +++ b/example_loggable_test.go @@ -2,10 +2,10 @@ package errors_test import ( "fmt" + "log/slog" "strings" "github.com/muonsoft/errors" - "github.com/muonsoft/errors/errorstest" ) const adminUser = 123 @@ -19,10 +19,12 @@ func (err *ForbiddenError) Error() string { return "access denied" } -// Implement errors.LoggableError interface to set fields into structured logger. -func (err *ForbiddenError) LogFields(logger errors.FieldLogger) { - logger.SetString("action", err.Action) - logger.SetInt("userID", err.UserID) +// Implement errors.LoggableError interface to provide structured attributes for logging. +func (err *ForbiddenError) Attrs() []slog.Attr { + return []slog.Attr{ + slog.String("action", err.Action), + slog.Int("userID", err.UserID), + } } func DoSomething(userID int) error { @@ -36,20 +38,35 @@ func DoSomething(userID int) error { func ExampleLog_loggableError() { err := DoSomething(1) - // Log error with structured logger. - logger := errorstest.NewLogger() - errors.Log(err, logger) - fmt.Println(`logged message:`, logger.Message) - fmt.Println(`logged fields:`, logger.Fields) + // Get attributes from error + attrs := errors.Attrs(err) + + // Create a map for display + fields := make(map[string]interface{}) + for _, attr := range attrs { + fields[attr.Key] = attr.Value.Any() + } + + // Get stack trace + var stackTrace errors.StackTrace + for e := err; e != nil; e = errors.Unwrap(e) { + if s, ok := e.(interface{ StackTrace() errors.StackTrace }); ok { + stackTrace = s.StackTrace() + break + } + } + + fmt.Println(`error message:`, err.Error()) + fmt.Println(`error fields:`, fields) fmt.Printf( - "logged first line of stack trace: %s %s:%d\n", - logger.StackTrace[0].Name(), - logger.StackTrace[0].File()[strings.LastIndex(logger.StackTrace[0].File(), "/")+1:], - logger.StackTrace[0].Line(), + "first line of stack trace: %s %s:%d\n", + stackTrace[0].Name(), + stackTrace[0].File()[strings.LastIndex(stackTrace[0].File(), "/")+1:], + stackTrace[0].Line(), ) // Output: - // logged message: access denied - // logged fields: map[action:DoSomething userID:1] - // logged first line of stack trace: github.com/muonsoft/errors_test.DoSomething example_loggable_test.go:30 + // error message: access denied + // error fields: map[action:DoSomething userID:1] + // first line of stack trace: github.com/muonsoft/errors_test.DoSomething example_loggable_test.go:32 } diff --git a/format_test.go b/format_test.go index b93aa0a..b3e525e 100644 --- a/format_test.go +++ b/format_test.go @@ -125,7 +125,7 @@ func TestFormat_Errorf(t *testing.T) { "%+v for error with value field", errors.Errorf("%s", "error", errors.Value("key", []string{"foo", "bar", "baz"})), "%+v", - "error\nkey: \\[foo bar baz\\]\n", + "error\nkey: foo, bar, baz\n", }, { "%+v for error with time field", diff --git a/go.mod b/go.mod index 82c9dbc..6bf1297 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,3 @@ module github.com/muonsoft/errors -go 1.20 - -require github.com/sirupsen/logrus v1.8.1 - -require golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 // indirect +go 1.21 diff --git a/go.sum b/go.sum index 59bd790..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +0,0 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= -github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/joining.go b/joining.go index 53af66b..e312f50 100644 --- a/joining.go +++ b/joining.go @@ -1,5 +1,7 @@ package errors +import "log/slog" + // Join returns an error that wraps the given errors with a stack trace // at the point Join is called. Any nil error values are discarded. // Join returns nil if errs contains no non-nil values. @@ -51,8 +53,8 @@ type joinError struct { errs []error } -func (e *joinError) LogFields(logger FieldLogger) { - logFieldsFromErrors(logger, e.errs) +func (e *joinError) Attrs() []slog.Attr { + return attrsFromErrors(e.errs) } func (e *joinError) Error() string { @@ -72,15 +74,23 @@ func (e *joinError) Unwrap() []error { return e.errs } -func logFieldsFromErrors(logger FieldLogger, errs []error) { +// attrsFromErrors recursively collects attributes from a slice of errors. +// It handles nested joined errors by recursively traversing them. +func attrsFromErrors(errs []error) []slog.Attr { + var attrs []slog.Attr + for _, err := range errs { for w := err; w != nil; w = Unwrap(w) { + // Handle nested joined errors if j, ok := w.(interface{ Unwrap() []error }); ok { - logFieldsFromErrors(logger, j.Unwrap()) + attrs = append(attrs, attrsFromErrors(j.Unwrap())...) } + // Collect attributes from LoggableError if loggable, ok := w.(LoggableError); ok { - loggable.LogFields(logger) + attrs = append(attrs, loggable.Attrs()...) } } } + + return attrs } diff --git a/logging.go b/logging.go index 72878d6..87eee6d 100644 --- a/logging.go +++ b/logging.go @@ -1,154 +1,79 @@ package errors import ( - "encoding/json" + "context" "errors" - "time" + "log/slog" ) -// FieldLogger used to set error fields into structured logger. -type FieldLogger interface { - SetBool(key string, value bool) - SetInt(key string, value int) - SetUint(key string, value uint) - SetFloat(key string, value float64) - SetString(key string, value string) - SetStrings(key string, values []string) - SetValue(key string, value interface{}) - SetTime(key string, value time.Time) - SetDuration(key string, value time.Duration) - SetJSON(key string, value json.RawMessage) - SetStackTrace(trace StackTrace) -} - -type Logger interface { - FieldLogger - Log(message string) -} - -type Field interface { - Set(logger FieldLogger) -} - +// LoggableError is an interface for errors that provide structured attributes +// for logging. Implement this interface on custom error types to add fields +// to structured logs. type LoggableError interface { - LogFields(logger FieldLogger) + Attrs() []slog.Attr } -func Log(err error, logger Logger) { +// Attrs extracts all structured attributes from an error chain. +// It traverses the error chain via Unwrap() and collects attributes from +// any error that implements LoggableError. For joined errors (multiple unwrapped +// errors), it recursively extracts attributes from all branches. +func Attrs(err error) []slog.Attr { if err == nil { - return + return nil } - - for e := err; e != nil; e = errors.Unwrap(e) { - if s, ok := e.(stackTracer); ok { - logger.SetStackTrace(s.StackTrace()) - } - } - logFields(err, logger) - - logger.Log(err.Error()) + return attrsFromError(err) } -func logFields(err error, logger Logger) { +func attrsFromError(err error) []slog.Attr { + var attrs []slog.Attr + for e := err; e != nil; e = errors.Unwrap(e) { - if w, ok := e.(LoggableError); ok { - w.LogFields(logger) + if loggable, ok := e.(LoggableError); ok { + attrs = append(attrs, loggable.Attrs()...) } + // Handle joined errors (multiple unwrapped errors) if joined, ok := e.(interface{ Unwrap() []error }); ok { for _, u := range joined.Unwrap() { - logFields(u, logger) + attrs = append(attrs, attrsFromError(u)...) } } } -} - -type BoolField struct { - Key string - Value bool -} - -func (f BoolField) Set(logger FieldLogger) { - logger.SetBool(f.Key, f.Value) -} - -type IntField struct { - Key string - Value int -} -func (f IntField) Set(logger FieldLogger) { - logger.SetInt(f.Key, f.Value) + return attrs } -type UintField struct { - Key string - Value uint -} - -func (f UintField) Set(logger FieldLogger) { - logger.SetUint(f.Key, f.Value) -} - -type FloatField struct { - Key string - Value float64 -} - -func (f FloatField) Set(logger FieldLogger) { - logger.SetFloat(f.Key, f.Value) -} - -type StringField struct { - Key string - Value string -} - -func (f StringField) Set(logger FieldLogger) { - logger.SetString(f.Key, f.Value) -} - -type StringsField struct { - Key string - Values []string -} - -func (f StringsField) Set(logger FieldLogger) { - logger.SetStrings(f.Key, f.Values) -} - -type ValueField struct { - Key string - Value interface{} -} - -func (f ValueField) Set(logger FieldLogger) { - logger.SetValue(f.Key, f.Value) -} - -type TimeField struct { - Key string - Value time.Time -} - -func (f TimeField) Set(logger FieldLogger) { - logger.SetTime(f.Key, f.Value) -} +// Log logs an error with all its structured attributes and stack trace +// using the provided slog.Logger. This is a convenience function for logging +// errors with slog. +// +// If err is nil, this function does nothing. +// +// Example: +// +// err := errors.Wrap(dbErr, errors.String("query", sql), errors.Int("userID", 123)) +// errors.Log(ctx, slog.Default(), slog.LevelError, err) +func Log(ctx context.Context, logger *slog.Logger, level slog.Level, err error) { + if err == nil { + return + } -type DurationField struct { - Key string - Value time.Duration -} + // Collect all attributes + attrs := Attrs(err) -func (f DurationField) Set(logger FieldLogger) { - logger.SetDuration(f.Key, f.Value) -} + // Find and add stack trace if present + for e := err; e != nil; e = errors.Unwrap(e) { + if s, ok := e.(stackTracer); ok { + attrs = append(attrs, slog.Any("stackTrace", s.StackTrace())) + break + } + } -type JSONField struct { - Key string - Value json.RawMessage -} + // Convert attrs to []any for logger.Log + args := make([]any, len(attrs)) + for i, attr := range attrs { + args[i] = attr + } -func (f JSONField) Set(logger FieldLogger) { - logger.SetJSON(f.Key, f.Value) + logger.Log(ctx, level, err.Error(), args...) } diff --git a/logging/logrusadapter/adapter.go b/logging/logrusadapter/adapter.go deleted file mode 100644 index d0350ab..0000000 --- a/logging/logrusadapter/adapter.go +++ /dev/null @@ -1,87 +0,0 @@ -package logrusadapter - -import ( - "encoding/json" - "time" - - "github.com/muonsoft/errors" - "github.com/sirupsen/logrus" -) - -type Option func(adapter *adapter) - -func SetLevel(level logrus.Level) Option { - return func(adapter *adapter) { - adapter.level = level - } -} - -func Log(err error, logger logrus.FieldLogger, options ...Option) { - a := &adapter{log: logger, level: logrus.ErrorLevel} - for _, setOption := range options { - setOption(a) - } - errors.Log(err, a) -} - -type adapter struct { - log logrus.FieldLogger - level logrus.Level -} - -func (a *adapter) SetBool(key string, value bool) { a.log = a.log.WithField(key, value) } -func (a *adapter) SetInt(key string, value int) { a.log = a.log.WithField(key, value) } -func (a *adapter) SetUint(key string, value uint) { a.log = a.log.WithField(key, value) } -func (a *adapter) SetFloat(key string, value float64) { a.log = a.log.WithField(key, value) } -func (a *adapter) SetString(key string, value string) { a.log = a.log.WithField(key, value) } -func (a *adapter) SetStrings(key string, values []string) { a.log = a.log.WithField(key, values) } -func (a *adapter) SetValue(key string, value interface{}) { a.log = a.log.WithField(key, value) } -func (a *adapter) SetTime(key string, value time.Time) { a.log = a.log.WithField(key, value) } -func (a *adapter) SetDuration(key string, value time.Duration) { a.log = a.log.WithField(key, value) } -func (a *adapter) SetJSON(key string, value json.RawMessage) { a.log = a.log.WithField(key, value) } - -func (a *adapter) SetStackTrace(trace errors.StackTrace) { - type Frame struct { - Function string `json:"function"` - File string `json:"file,omitempty"` - Line int `json:"line,omitempty"` - } - - frames := make([]Frame, len(trace)) - for i, frame := range trace { - frames[i].File = frame.File() - frames[i].Function = frame.Name() - frames[i].Line = frame.Line() - } - - a.log = a.log.WithField("stackTrace", frames) -} - -type levelLogger interface { - Log(level logrus.Level, args ...interface{}) -} - -func (a *adapter) Log(message string) { - if levelLog, ok := a.log.(levelLogger); ok { - levelLog.Log(a.level, message) - - return - } - - switch a.level { - case logrus.PanicLevel: - a.log.Panic(message) - case logrus.FatalLevel: - a.log.Fatal(message) - case logrus.ErrorLevel: - a.log.Error(message) - case logrus.WarnLevel: - a.log.Warn(message) - case logrus.InfoLevel: - a.log.Info(message) - case logrus.DebugLevel: - a.log.Debug(message) - default: - a.log.Error(message) - } -} diff --git a/logging_test.go b/logging_test.go index f486f84..6805d5d 100644 --- a/logging_test.go +++ b/logging_test.go @@ -1,30 +1,31 @@ package errors_test import ( + "context" stderrors "errors" + "log/slog" "testing" "github.com/muonsoft/errors" "github.com/muonsoft/errors/errorstest" ) -func TestLog_noError(t *testing.T) { - logger := errorstest.NewLogger() - - errors.Log(nil, logger) +func TestAttrs_noError(t *testing.T) { + attrs := errors.Attrs(nil) + if attrs != nil { + t.Errorf("expected nil attrs for nil error, got %v", attrs) + } } -func TestLog_errorWithoutStack(t *testing.T) { - logger := errorstest.NewLogger() - - errors.Log(errors.New("ooh"), logger) - - logger.AssertMessage(t, "ooh") +func TestAttrs_errorWithoutStack(t *testing.T) { + err := errors.New("ooh") + attrs := errors.Attrs(err) + if len(attrs) != 0 { + t.Errorf("expected no attrs for error without fields, got %v", attrs) + } } -func TestLog_errorWithStack(t *testing.T) { - logger := errorstest.NewLogger() - +func TestAttrs_errorWithStack(t *testing.T) { err := errors.Wrap( errors.Wrap( errors.Errorf("ooh", errors.String("deepestKey", "deepestValue")), @@ -32,24 +33,24 @@ func TestLog_errorWithStack(t *testing.T) { ), errors.String("key", "value"), ) - errors.Log(err, logger) - - logger.AssertMessage(t, "ooh") - logger.AssertStackTrace(t, errorstest.StackTrace{ - { - Function: "github.com/muonsoft/errors_test.TestLog_errorWithStack", - File: ".+errors/logging_test.go", - Line: 30, - }, - }) + + attrs := errors.Attrs(err) + + // Should have 3 attributes + if len(attrs) != 3 { + t.Fatalf("expected 3 attrs, got %d", len(attrs)) + } + + // Create a mock logger to use assertion helpers + logger := errorstest.NewLogger() + logger.Attrs = attrs + logger.AssertField(t, "key", "value") logger.AssertField(t, "deepKey", "deepValue") logger.AssertField(t, "deepestKey", "deepestValue") } -func TestLog_joinedErrors(t *testing.T) { - logger := errorstest.NewLogger() - +func TestAttrs_joinedErrors(t *testing.T) { err := errors.Wrap( errors.Join( errors.Wrap( @@ -63,19 +64,111 @@ func TestLog_joinedErrors(t *testing.T) { ), ), ) - errors.Log(err, logger) - - logger.AssertMessage(t, "error 1\nerror 2\nerror 3\nerror 4") - logger.AssertStackTrace(t, errorstest.StackTrace{ - { - Function: "github.com/muonsoft/errors_test.TestLog_joinedErrors", - File: ".+errors/logging_test.go", - Line: 54, - }, - }) + + attrs := errors.Attrs(err) + + // Should have 5 attributes + if len(attrs) < 5 { + t.Fatalf("expected at least 5 attrs, got %d", len(attrs)) + } + + // Create a mock logger to use assertion helpers + logger := errorstest.NewLogger() + logger.Attrs = attrs + logger.AssertField(t, "key1", "value1") logger.AssertField(t, "key2", "value2") logger.AssertField(t, "key3", "value3") logger.AssertField(t, "key4", "value4") logger.AssertField(t, "key5", "value5") } + +func TestLog(t *testing.T) { + // Create a custom handler to capture log output + var capturedAttrs []slog.Attr + var capturedMsg string + var capturedLevel slog.Level + + handler := &testHandler{ + onHandle: func(ctx context.Context, r slog.Record) error { + capturedMsg = r.Message + capturedLevel = r.Level + r.Attrs(func(a slog.Attr) bool { + capturedAttrs = append(capturedAttrs, a) + return true + }) + return nil + }, + } + + logger := slog.New(handler) + + err := errors.Wrap( + errors.Errorf("test error", errors.String("user", "john"), errors.Int("id", 123)), + ) + + errors.Log(context.Background(), logger, slog.LevelError, err) + + if capturedMsg != "test error" { + t.Errorf("expected message 'test error', got '%s'", capturedMsg) + } + + if capturedLevel != slog.LevelError { + t.Errorf("expected level Error, got %v", capturedLevel) + } + + if len(capturedAttrs) < 2 { + t.Fatalf("expected at least 2 attrs, got %d", len(capturedAttrs)) + } + + // Check for user and id attributes + hasUser := false + hasID := false + hasStackTrace := false + + for _, attr := range capturedAttrs { + if attr.Key == "user" && attr.Value.String() == "john" { + hasUser = true + } + if attr.Key == "id" && attr.Value.Any() == int64(123) { + hasID = true + } + if attr.Key == "stackTrace" { + hasStackTrace = true + } + } + + if !hasUser { + t.Error("expected 'user' attribute") + } + if !hasID { + t.Error("expected 'id' attribute") + } + if !hasStackTrace { + t.Error("expected 'stackTrace' attribute") + } +} + +// testHandler is a simple slog.Handler for testing +type testHandler struct { + onHandle func(ctx context.Context, r slog.Record) error +} + +func (h *testHandler) Enabled(ctx context.Context, level slog.Level) bool { + return true +} + +func (h *testHandler) Handle(ctx context.Context, r slog.Record) error { + if h.onHandle != nil { + return h.onHandle(ctx, r) + } + return nil +} + +func (h *testHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return h +} + +func (h *testHandler) WithGroup(name string) slog.Handler { + return h +} diff --git a/options.go b/options.go index a8405bd..4d464e9 100644 --- a/options.go +++ b/options.go @@ -3,96 +3,172 @@ package errors import ( "encoding/json" "fmt" + "log/slog" "time" ) +// Options holds configuration for error wrapping, including stack trace skip count +// and structured attributes for logging. type Options struct { skipCallers int - fields []Field + attrs []slog.Attr } -func (o *Options) AddField(field Field) { - o.fields = append(o.fields, field) +func (o *Options) addAttr(attr slog.Attr) { + o.attrs = append(o.attrs, attr) } -// Option is used to set error fields for structured logging and to skip caller +func (o *Options) addAttrs(attrs []slog.Attr) { + o.attrs = append(o.attrs, attrs...) +} + +// Option is used to set error attributes for structured logging and to skip caller // for a stack trace. type Option func(*Options) +// SkipCaller returns an Option that increments the skip count for stack trace by 1. +// Use this to exclude the immediate caller from the stack trace. func SkipCaller() Option { return func(options *Options) { options.skipCallers++ } } +// SkipCallers returns an Option that adds skip to the skip count for stack trace. +// Use this to exclude multiple callers from the stack trace. func SkipCallers(skip int) Option { return func(options *Options) { options.skipCallers += skip } } +// Bool returns an Option that adds a boolean attribute to the error. func Bool(key string, value bool) Option { return func(options *Options) { - options.AddField(BoolField{Key: key, Value: value}) + options.addAttr(slog.Bool(key, value)) } } +// Int returns an Option that adds an integer attribute to the error. func Int(key string, value int) Option { return func(options *Options) { - options.AddField(IntField{Key: key, Value: value}) + options.addAttr(slog.Int(key, value)) } } +// Uint returns an Option that adds an unsigned integer attribute to the error. +// Note: slog doesn't have a native Uint type, so this uses Any(). func Uint(key string, value uint) Option { return func(options *Options) { - options.AddField(UintField{Key: key, Value: value}) + options.addAttr(slog.Any(key, value)) } } +// Float returns an Option that adds a float64 attribute to the error. func Float(key string, value float64) Option { return func(options *Options) { - options.AddField(FloatField{Key: key, Value: value}) + options.addAttr(slog.Float64(key, value)) } } +// String returns an Option that adds a string attribute to the error. func String(key string, value string) Option { return func(options *Options) { - options.AddField(StringField{Key: key, Value: value}) + options.addAttr(slog.String(key, value)) } } +// Stringer returns an Option that adds a fmt.Stringer attribute to the error. +// The value is converted to string using its String() method. func Stringer(key string, value fmt.Stringer) Option { return String(key, value.String()) } +// Strings returns an Option that adds a string slice attribute to the error. +// Note: slog doesn't have a native Strings type, so this uses Any(). func Strings(key string, values []string) Option { return func(options *Options) { - options.AddField(StringsField{Key: key, Values: values}) + options.addAttr(slog.Any(key, values)) } } +// Value returns an Option that adds an arbitrary value attribute to the error. func Value(key string, value interface{}) Option { return func(options *Options) { - options.AddField(ValueField{Key: key, Value: value}) + options.addAttr(slog.Any(key, value)) } } +// Time returns an Option that adds a time.Time attribute to the error. func Time(key string, value time.Time) Option { return func(options *Options) { - options.AddField(TimeField{Key: key, Value: value}) + options.addAttr(slog.Time(key, value)) } } +// Duration returns an Option that adds a time.Duration attribute to the error. func Duration(key string, value time.Duration) Option { return func(options *Options) { - options.AddField(DurationField{Key: key, Value: value}) + options.addAttr(slog.Duration(key, value)) } } +// JSON returns an Option that adds a JSON attribute to the error. func JSON(key string, value json.RawMessage) Option { return func(options *Options) { - options.AddField(JSONField{Key: key, Value: value}) + options.addAttr(slog.Any(key, value)) + } +} + +// Attr returns an Option that adds an slog.Attr directly to the error. +// This allows using any slog attribute, including custom types and groups. +// +// Example: +// +// err := errors.Wrap(err, errors.Attr(slog.Int64("timestamp", time.Now().Unix()))) +func Attr(attr slog.Attr) Option { + return func(options *Options) { + options.addAttr(attr) + } +} + +// WithAttrs returns an Option that adds multiple slog.Attr values to the error. +// +// Example: +// +// err := errors.Wrap(err, errors.WithAttrs( +// slog.String("user", "john"), +// slog.Int("age", 30), +// )) +func WithAttrs(attrs ...slog.Attr) Option { + return func(options *Options) { + options.addAttrs(attrs) + } +} + +// Group returns an Option that adds a group attribute to the error. +// Groups allow organizing related attributes under a common key. +// +// Example: +// +// err := errors.Wrap(err, errors.Group("request", +// slog.String("method", "GET"), +// slog.String("path", "/api/users"), +// slog.Int("status", 404), +// )) +func Group(key string, attrs ...slog.Attr) Option { + return func(options *Options) { + options.addAttr(slog.Group(key, attrsToAny(attrs)...)) + } +} + +// attrsToAny converts []slog.Attr to []any for use with slog.Group +func attrsToAny(attrs []slog.Attr) []any { + result := make([]any, len(attrs)) + for i, attr := range attrs { + result[i] = attr } + return result } func newOptions(options ...Option) *Options { diff --git a/slog_test.go b/slog_test.go new file mode 100644 index 0000000..78030a6 --- /dev/null +++ b/slog_test.go @@ -0,0 +1,545 @@ +package errors_test + +import ( + "context" + "encoding/json" + "log/slog" + "strings" + "testing" + + "github.com/muonsoft/errors" +) + +// TestGroupAttributes tests error attributes with slog groups +func TestGroupAttributes(t *testing.T) { + t.Run("simple group", func(t *testing.T) { + err := errors.Wrap( + errors.New("test error"), + errors.Group("request", + slog.String("method", "GET"), + slog.String("path", "/api/users"), + slog.Int("status", 404), + ), + ) + + attrs := errors.Attrs(err) + if len(attrs) != 1 { + t.Fatalf("expected 1 attribute (group), got %d", len(attrs)) + } + + if attrs[0].Key != "request" { + t.Errorf("expected group key 'request', got '%s'", attrs[0].Key) + } + + if attrs[0].Value.Kind() != slog.KindGroup { + t.Errorf("expected group kind, got %v", attrs[0].Value.Kind()) + } + + groupAttrs := attrs[0].Value.Group() + if len(groupAttrs) != 3 { + t.Fatalf("expected 3 attributes in group, got %d", len(groupAttrs)) + } + + // Check group contents + if groupAttrs[0].Key != "method" || groupAttrs[0].Value.String() != "GET" { + t.Errorf("unexpected first group attribute: %v", groupAttrs[0]) + } + if groupAttrs[1].Key != "path" || groupAttrs[1].Value.String() != "/api/users" { + t.Errorf("unexpected second group attribute: %v", groupAttrs[1]) + } + if groupAttrs[2].Key != "status" || groupAttrs[2].Value.Any() != int64(404) { + t.Errorf("unexpected third group attribute: %v", groupAttrs[2]) + } + }) + + t.Run("nested groups", func(t *testing.T) { + err := errors.Wrap( + errors.New("test error"), + errors.Group("outer", + slog.String("field1", "value1"), + slog.Group("inner", + slog.String("field2", "value2"), + slog.Int("field3", 123), + ), + ), + ) + + attrs := errors.Attrs(err) + if len(attrs) != 1 { + t.Fatalf("expected 1 attribute (outer group), got %d", len(attrs)) + } + + outerGroup := attrs[0].Value.Group() + if len(outerGroup) != 2 { + t.Fatalf("expected 2 attributes in outer group, got %d", len(outerGroup)) + } + + // Check nested group + if outerGroup[1].Key != "inner" || outerGroup[1].Value.Kind() != slog.KindGroup { + t.Errorf("expected nested group 'inner', got %v", outerGroup[1]) + } + + innerGroup := outerGroup[1].Value.Group() + if len(innerGroup) != 2 { + t.Fatalf("expected 2 attributes in inner group, got %d", len(innerGroup)) + } + }) + + t.Run("group without key", func(t *testing.T) { + err := errors.Wrap( + errors.New("test error"), + errors.Attr(slog.Group("", + slog.String("field1", "value1"), + slog.String("field2", "value2"), + )), + ) + + attrs := errors.Attrs(err) + if len(attrs) != 1 { + t.Fatalf("expected 1 attribute (group without key), got %d", len(attrs)) + } + + // Group without key should still be a group + if attrs[0].Key != "" { + t.Errorf("expected empty key, got '%s'", attrs[0].Key) + } + if attrs[0].Value.Kind() != slog.KindGroup { + t.Errorf("expected group kind, got %v", attrs[0].Value.Kind()) + } + }) + + t.Run("groups at different levels", func(t *testing.T) { + err := errors.Wrap( + errors.Wrap( + errors.New("test error"), + errors.Group("inner", slog.String("a", "1")), + ), + errors.Group("outer", slog.String("b", "2")), + ) + + attrs := errors.Attrs(err) + if len(attrs) != 2 { + t.Fatalf("expected 2 attributes (2 groups), got %d", len(attrs)) + } + + // Groups should be collected from all levels + hasInner := false + hasOuter := false + for _, attr := range attrs { + if attr.Key == "inner" { + hasInner = true + } + if attr.Key == "outer" { + hasOuter = true + } + } + + if !hasInner || !hasOuter { + t.Errorf("expected both 'inner' and 'outer' groups, got attrs: %v", attrs) + } + }) + + t.Run("groups in joined errors", func(t *testing.T) { + err1 := errors.Wrap( + errors.New("error 1"), + errors.Group("group1", slog.String("a", "1")), + ) + err2 := errors.Wrap( + errors.New("error 2"), + errors.Group("group2", slog.String("b", "2")), + ) + + joined := errors.Join(err1, err2) + + attrs := errors.Attrs(joined) + if len(attrs) < 2 { + t.Fatalf("expected at least 2 attributes from joined errors, got %d", len(attrs)) + } + + // Check that both groups are present + hasGroup1 := false + hasGroup2 := false + for _, attr := range attrs { + if attr.Key == "group1" { + hasGroup1 = true + } + if attr.Key == "group2" { + hasGroup2 = true + } + } + + if !hasGroup1 || !hasGroup2 { + t.Errorf("expected both 'group1' and 'group2', got attrs: %v", attrs) + } + }) + + t.Run("mixed flat and grouped attributes", func(t *testing.T) { + err := errors.Wrap( + errors.New("test error"), + errors.String("flat1", "value1"), + errors.Group("grouped", + slog.String("nested1", "value2"), + slog.Int("nested2", 42), + ), + errors.Int("flat2", 100), + ) + + attrs := errors.Attrs(err) + if len(attrs) != 3 { + t.Fatalf("expected 3 attributes (2 flat + 1 group), got %d", len(attrs)) + } + + // Check that we have both flat and grouped attributes + hasFlatString := false + hasFlatInt := false + hasGroup := false + + for _, attr := range attrs { + if attr.Key == "flat1" && attr.Value.String() == "value1" { + hasFlatString = true + } + if attr.Key == "flat2" && attr.Value.Any() == int64(100) { + hasFlatInt = true + } + if attr.Key == "grouped" && attr.Value.Kind() == slog.KindGroup { + hasGroup = true + } + } + + if !hasFlatString || !hasFlatInt || !hasGroup { + t.Errorf("expected flat1, flat2, and grouped, got attrs: %v", attrs) + } + }) +} + +// TestGroupJSON tests JSON marshaling of grouped attributes +func TestGroupJSON(t *testing.T) { + t.Run("group with key creates nested object", func(t *testing.T) { + err := errors.Wrap( + errors.Errorf("test error"), + errors.Group("request", + slog.String("method", "GET"), + slog.String("path", "/api"), + ), + ) + + data, marshalErr := json.Marshal(err) + if marshalErr != nil { + t.Fatalf("failed to marshal error: %v", marshalErr) + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("failed to unmarshal JSON: %v", err) + } + + // Check that request is a nested object + request, ok := result["request"].(map[string]interface{}) + if !ok { + t.Fatalf("expected 'request' to be a nested object, got %T: %v", result["request"], result["request"]) + } + + if request["method"] != "GET" { + t.Errorf("expected method=GET, got %v", request["method"]) + } + if request["path"] != "/api" { + t.Errorf("expected path=/api, got %v", request["path"]) + } + }) + + t.Run("group without key merges fields", func(t *testing.T) { + err := errors.Wrap( + errors.Errorf("test error"), + errors.Attr(slog.Group("", + slog.String("field1", "value1"), + slog.String("field2", "value2"), + )), + ) + + data, marshalErr := json.Marshal(err) + if marshalErr != nil { + t.Fatalf("failed to marshal error: %v", marshalErr) + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("failed to unmarshal JSON: %v", err) + } + + // Fields should be at top level (merged) + if result["field1"] != "value1" { + t.Errorf("expected field1=value1 at top level, got %v", result["field1"]) + } + if result["field2"] != "value2" { + t.Errorf("expected field2=value2 at top level, got %v", result["field2"]) + } + }) + + t.Run("nested groups", func(t *testing.T) { + err := errors.Wrap( + errors.Errorf("test error"), + errors.Group("outer", + slog.String("field1", "value1"), + slog.Group("inner", + slog.String("field2", "value2"), + ), + ), + ) + + data, marshalErr := json.Marshal(err) + if marshalErr != nil { + t.Fatalf("failed to marshal error: %v", marshalErr) + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("failed to unmarshal JSON: %v", err) + } + + outer, ok := result["outer"].(map[string]interface{}) + if !ok { + t.Fatalf("expected 'outer' to be a nested object, got %T", result["outer"]) + } + + inner, ok := outer["inner"].(map[string]interface{}) + if !ok { + t.Fatalf("expected 'inner' to be a nested object, got %T", outer["inner"]) + } + + if inner["field2"] != "value2" { + t.Errorf("expected inner.field2=value2, got %v", inner["field2"]) + } + }) +} + +// TestGroupFormatting tests %+v formatting of grouped attributes +func TestGroupFormatting(t *testing.T) { + t.Run("group with key uses dot notation", func(t *testing.T) { + err := errors.Wrap( + errors.Errorf("test error"), + errors.Group("request", + slog.String("method", "GET"), + slog.String("path", "/api"), + ), + ) + + formatted := strings.TrimSpace(strings.Split(errors.Errorf("%+v", err).Error(), "\n")[0]) + output := errors.Errorf("%+v", err).Error() + + // Check for dot notation in output + if !strings.Contains(output, "request.method: GET") { + t.Errorf("expected 'request.method: GET' in output, got:\n%s", output) + } + if !strings.Contains(output, "request.path: /api") { + t.Errorf("expected 'request.path: /api' in output, got:\n%s", output) + } + + _ = formatted // Use the variable to avoid "declared and not used" error + }) + + t.Run("group without key uses no prefix", func(t *testing.T) { + err := errors.Wrap( + errors.Errorf("test error"), + errors.Attr(slog.Group("", + slog.String("field1", "value1"), + slog.String("field2", "value2"), + )), + ) + + output := errors.Errorf("%+v", err).Error() + + // Fields should appear without prefix + if !strings.Contains(output, "field1: value1") { + t.Errorf("expected 'field1: value1' in output, got:\n%s", output) + } + if !strings.Contains(output, "field2: value2") { + t.Errorf("expected 'field2: value2' in output, got:\n%s", output) + } + }) + + t.Run("nested groups", func(t *testing.T) { + err := errors.Wrap( + errors.Errorf("test error"), + errors.Group("outer", + slog.Group("inner", + slog.String("field", "value"), + ), + ), + ) + + output := errors.Errorf("%+v", err).Error() + + // Should show nested dot notation + if !strings.Contains(output, "outer.inner.field: value") { + t.Errorf("expected 'outer.inner.field: value' in output, got:\n%s", output) + } + }) +} + +// TestSlogLogValuer tests that wrapped errors implement slog.LogValuer +func TestSlogLogValuer(t *testing.T) { + t.Run("error implements LogValuer", func(t *testing.T) { + err := errors.Wrap( + errors.New("test error"), + errors.String("key", "value"), + ) + + // Check if error implements slog.LogValuer + _, ok := err.(slog.LogValuer) + if !ok { + t.Error("expected error to implement slog.LogValuer") + } + }) + + t.Run("LogValue returns group", func(t *testing.T) { + err := errors.Wrap( + errors.New("test error"), + errors.String("key1", "value1"), + errors.Int("key2", 42), + ) + + logValuer, ok := err.(slog.LogValuer) + if !ok { + t.Fatal("error does not implement slog.LogValuer") + } + + value := logValuer.LogValue() + if value.Kind() != slog.KindGroup { + t.Errorf("expected LogValue to return a group, got %v", value.Kind()) + } + + attrs := value.Group() + if len(attrs) != 2 { + t.Fatalf("expected 2 attributes in LogValue group, got %d", len(attrs)) + } + }) +} + +// TestLogAttrs tests the LogAttrs convenience function +func TestLogAttrsComplete(t *testing.T) { + t.Run("logs all attributes and stack trace", func(t *testing.T) { + var capturedAttrs []slog.Attr + var capturedMsg string + var capturedLevel slog.Level + + handler := &testHandler{ + onHandle: func(ctx context.Context, r slog.Record) error { + capturedMsg = r.Message + capturedLevel = r.Level + r.Attrs(func(a slog.Attr) bool { + capturedAttrs = append(capturedAttrs, a) + return true + }) + return nil + }, + } + + logger := slog.New(handler) + + err := errors.Wrap( + errors.Errorf("database error", + errors.String("query", "SELECT * FROM users"), + errors.Int("userID", 123), + ), + ) + + errors.Log(context.Background(), logger, slog.LevelError, err) + + if capturedMsg != "database error" { + t.Errorf("expected message 'database error', got '%s'", capturedMsg) + } + + if capturedLevel != slog.LevelError { + t.Errorf("expected level Error, got %v", capturedLevel) + } + + // Should have query, userID, and stackTrace attributes + if len(capturedAttrs) < 3 { + t.Fatalf("expected at least 3 attributes, got %d: %v", len(capturedAttrs), capturedAttrs) + } + + hasQuery := false + hasUserID := false + hasStackTrace := false + + for _, attr := range capturedAttrs { + if attr.Key == "query" { + hasQuery = true + } + if attr.Key == "userID" { + hasUserID = true + } + if attr.Key == "stackTrace" { + hasStackTrace = true + } + } + + if !hasQuery { + t.Error("expected 'query' attribute") + } + if !hasUserID { + t.Error("expected 'userID' attribute") + } + if !hasStackTrace { + t.Error("expected 'stackTrace' attribute") + } + }) + + t.Run("handles nil error", func(t *testing.T) { + handlerCalled := false + + handler := &testHandler{ + onHandle: func(ctx context.Context, r slog.Record) error { + handlerCalled = true + return nil + }, + } + + logger := slog.New(handler) + + errors.Log(context.Background(), logger, slog.LevelError, nil) + + if handlerCalled { + t.Error("expected handler not to be called for nil error") + } + }) + + t.Run("logs groups correctly", func(t *testing.T) { + var capturedAttrs []slog.Attr + + handler := &testHandler{ + onHandle: func(ctx context.Context, r slog.Record) error { + r.Attrs(func(a slog.Attr) bool { + capturedAttrs = append(capturedAttrs, a) + return true + }) + return nil + }, + } + + logger := slog.New(handler) + + err := errors.Wrap( + errors.Errorf("test error"), + errors.Group("request", + slog.String("method", "POST"), + slog.Int("status", 500), + ), + ) + + errors.Log(context.Background(), logger, slog.LevelError, err) + + // Should have request group and stackTrace + hasRequestGroup := false + for _, attr := range capturedAttrs { + if attr.Key == "request" && attr.Value.Kind() == slog.KindGroup { + hasRequestGroup = true + break + } + } + + if !hasRequestGroup { + t.Errorf("expected 'request' group attribute, got: %v", capturedAttrs) + } + }) +} From 70928cbd811db20cdb32c241e02c98c9ba11a912 Mon Sep 17 00:00:00 2001 From: Igor Lazarev Date: Mon, 9 Feb 2026 21:47:00 +0300 Subject: [PATCH 2/5] feat: add complete slog type coverage and deprecate Value function This commit introduces dedicated functions for all slog attribute types, including Int64, Uint64, Float64, and Any, enhancing the flexibility of error handling. The Value function is deprecated in favor of Any for better consistency. Additionally, tests are added to ensure the new attribute options work as expected. --- MIGRATION.md | 35 ++++++++++++++++--- README.md | 51 +++++++++++++++++++-------- options.go | 45 +++++++++++++++++++++--- options_test.go | 93 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 202 insertions(+), 22 deletions(-) create mode 100644 options_test.go diff --git a/MIGRATION.md b/MIGRATION.md index 99e993d..12354d9 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -173,7 +173,34 @@ func TestMyError(t *testing.T) { ## New Features -### 1. Grouped Attributes +### 1. Complete slog Type Coverage + +All slog attribute types are now supported with dedicated functions: + +```go +// New in v0.5.0 +errors.Int64(key string, value int64) +errors.Uint64(key string, value uint64) +errors.Float64(key string, value float64) +errors.Any(key string, value interface{}) // Replaces Value + +// Deprecated +errors.Value(key string, value interface{}) // Use Any instead +``` + +Example: + +```go +err := errors.Wrap( + dbErr, + errors.Int64("timestamp", time.Now().Unix()), + errors.Uint64("bytes_processed", uint64(1024*1024)), + errors.Float64("cpu_usage", 0.75), + errors.Any("metadata", map[string]string{"region": "us-west"}), +) +``` + +### 2. Grouped Attributes Group related attributes together for better structure: @@ -214,7 +241,7 @@ err := errors.Wrap( // query.duration: 150ms ``` -### 2. Direct slog.Attr Usage +### 3. Direct slog.Attr Usage Pass `slog.Attr` directly for maximum flexibility: @@ -229,7 +256,7 @@ err := errors.Wrap( ) ``` -### 3. Multiple Attributes at Once +### 4. Multiple Attributes at Once ```go commonAttrs := []slog.Attr{ @@ -240,7 +267,7 @@ commonAttrs := []slog.Attr{ err := errors.Wrap(err, errors.WithAttrs(commonAttrs...)) ``` -### 4. slog.LogValuer Implementation +### 5. slog.LogValuer Implementation Errors automatically work with slog: diff --git a/README.md b/README.md index 232519f..1c25e4d 100644 --- a/README.md +++ b/README.md @@ -133,12 +133,26 @@ err := errors.Wrap( ) ``` -You can also use `errors.Attr()` to pass `slog.Attr` directly: +You can use all slog attribute types directly: + +```golang +err := errors.Wrap( + err, + errors.Int64("timestamp", time.Now().Unix()), + errors.Uint64("bytes_written", uint64(1024*1024*500)), + errors.Float64("cpu_usage", 0.85), + errors.Any("metadata", map[string]interface{}{ + "version": "v1.2.3", + "region": "us-west-1", + }), +) +``` + +Or pass `slog.Attr` directly for maximum flexibility: ```golang err := errors.Wrap( err, - errors.Attr(slog.Int64("timestamp", time.Now().Unix())), errors.Attr(slog.Group("metadata", slog.String("version", "v1.2.3"), slog.Bool("production", true), @@ -338,25 +352,34 @@ attrs := errors.Attrs(wrapped) ## Available attribute options -The package provides convenience functions for creating attributes: +The package provides convenience functions for creating attributes that correspond to all slog types: ```golang +// Basic types errors.Bool(key string, value bool) -errors.Int(key string, value int) -errors.Uint(key string, value uint) -errors.Float(key string, value float64) +errors.Int(key string, value int) // Converted to int64 +errors.Int64(key string, value int64) +errors.Uint(key string, value uint) // Uses slog.Any +errors.Uint64(key string, value uint64) +errors.Float(key string, value float64) // Alias for Float64 +errors.Float64(key string, value float64) errors.String(key string, value string) -errors.Stringer(key string, value fmt.Stringer) -errors.Strings(key string, values []string) -errors.Value(key string, value interface{}) + +// Complex types +errors.Stringer(key string, value fmt.Stringer) // Converted to string +errors.Strings(key string, values []string) // Uses slog.Any +errors.Any(key string, value interface{}) // For any type errors.Time(key string, value time.Time) errors.Duration(key string, value time.Duration) -errors.JSON(key string, value json.RawMessage) +errors.JSON(key string, value json.RawMessage) // Uses slog.Any + +// slog-specific options +errors.Attr(attr slog.Attr) // Add any slog.Attr directly +errors.WithAttrs(attrs ...slog.Attr) // Add multiple slog.Attr values +errors.Group(key string, attrs ...slog.Attr) // Create a grouped attribute -// New slog-specific options -errors.Attr(attr slog.Attr) // Add any slog.Attr directly -errors.WithAttrs(attrs ...slog.Attr) // Add multiple slog.Attr values -errors.Group(key string, attrs ...slog.Attr) // Create a grouped attribute +// Deprecated +errors.Value(key string, value interface{}) // Use Any instead ``` ## Contributing diff --git a/options.go b/options.go index 4d464e9..7e108a4 100644 --- a/options.go +++ b/options.go @@ -50,12 +50,20 @@ func Bool(key string, value bool) Option { } // Int returns an Option that adds an integer attribute to the error. +// The value is converted to int64 for slog compatibility. func Int(key string, value int) Option { return func(options *Options) { options.addAttr(slog.Int(key, value)) } } +// Int64 returns an Option that adds an int64 attribute to the error. +func Int64(key string, value int64) Option { + return func(options *Options) { + options.addAttr(slog.Int64(key, value)) + } +} + // Uint returns an Option that adds an unsigned integer attribute to the error. // Note: slog doesn't have a native Uint type, so this uses Any(). func Uint(key string, value uint) Option { @@ -64,13 +72,26 @@ func Uint(key string, value uint) Option { } } -// Float returns an Option that adds a float64 attribute to the error. -func Float(key string, value float64) Option { +// Uint64 returns an Option that adds a uint64 attribute to the error. +func Uint64(key string, value uint64) Option { + return func(options *Options) { + options.addAttr(slog.Uint64(key, value)) + } +} + +// Float64 returns an Option that adds a float64 attribute to the error. +func Float64(key string, value float64) Option { return func(options *Options) { options.addAttr(slog.Float64(key, value)) } } +// Float returns an Option that adds a float64 attribute to the error. +// Alias for Float64 for backward compatibility. +func Float(key string, value float64) Option { + return Float64(key, value) +} + // String returns an Option that adds a string attribute to the error. func String(key string, value string) Option { return func(options *Options) { @@ -92,13 +113,29 @@ func Strings(key string, values []string) Option { } } -// Value returns an Option that adds an arbitrary value attribute to the error. -func Value(key string, value interface{}) Option { +// Any returns an Option that adds an arbitrary value attribute to the error. +// This is the most flexible option and can handle any type that slog.Any supports. +// +// Example: +// +// err := errors.Wrap(err, +// errors.Any("metadata", map[string]string{"version": "v1.0"}), +// errors.Any("items", []int{1, 2, 3}), +// ) +func Any(key string, value interface{}) Option { return func(options *Options) { options.addAttr(slog.Any(key, value)) } } +// Value returns an Option that adds an arbitrary value attribute to the error. +// +// Deprecated: Use Any instead. Value is kept for backward compatibility +// but will be removed in a future version. +func Value(key string, value interface{}) Option { + return Any(key, value) +} + // Time returns an Option that adds a time.Time attribute to the error. func Time(key string, value time.Time) Option { return func(options *Options) { diff --git a/options_test.go b/options_test.go new file mode 100644 index 0000000..c792131 --- /dev/null +++ b/options_test.go @@ -0,0 +1,93 @@ +package errors_test + +import ( + "reflect" + "testing" + + "github.com/muonsoft/errors" +) + +func TestNewAttributeOptions(t *testing.T) { + tests := []struct { + name string + err error + expected interface{} + }{ + { + name: "Int64", + err: errors.Wrap(errors.New("test"), errors.Int64("key", 9223372036854775807)), + expected: int64(9223372036854775807), + }, + { + name: "Uint64", + err: errors.Wrap(errors.New("test"), errors.Uint64("key", 18446744073709551615)), + expected: uint64(18446744073709551615), + }, + { + name: "Float64", + err: errors.Wrap(errors.New("test"), errors.Float64("key", 3.14159)), + expected: 3.14159, + }, + { + name: "Any with struct", + err: errors.Wrap(errors.New("test"), errors.Any("key", struct{ Name string }{"test"})), + expected: struct{ Name string }{"test"}, + }, + { + name: "Any with map", + err: errors.Wrap(errors.New("test"), errors.Any("key", map[string]int{"count": 42})), + expected: map[string]int{"count": 42}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + attrs := errors.Attrs(test.err) + if len(attrs) == 0 { + t.Fatalf("expected %#v to have attributes", test.err) + } + + var found bool + var value interface{} + for _, attr := range attrs { + if attr.Key == "key" { + value = attr.Value.Any() + found = true + break + } + } + + if !found { + t.Fatalf("expected %#v to have attribute with key 'key'", test.err) + } + + if !reflect.DeepEqual(value, test.expected) { + t.Errorf("want value %v (%T), got %v (%T)", test.expected, test.expected, value, value) + } + }) + } +} + +func TestValueDeprecated(t *testing.T) { + // Test that Value still works but behaves the same as Any + errWithValue := errors.Wrap(errors.New("test"), errors.Value("key", "test-value")) + errWithAny := errors.Wrap(errors.New("test"), errors.Any("key", "test-value")) + + attrsValue := errors.Attrs(errWithValue) + attrsAny := errors.Attrs(errWithAny) + + if len(attrsValue) != 1 || len(attrsAny) != 1 { + t.Fatalf("expected both errors to have exactly 1 attribute") + } + + valueAttr := attrsValue[0] + anyAttr := attrsAny[0] + + if valueAttr.Key != anyAttr.Key { + t.Errorf("expected same key, got Value=%s, Any=%s", valueAttr.Key, anyAttr.Key) + } + + if valueAttr.Value.Any() != anyAttr.Value.Any() { + t.Errorf("expected same value, got Value=%v, Any=%v", valueAttr.Value.Any(), anyAttr.Value.Any()) + } +} From c97ad887e1b7acdcdc92e58549fb80fd045b8685 Mon Sep 17 00:00:00 2001 From: Igor Lazarev Date: Mon, 9 Feb 2026 21:53:38 +0300 Subject: [PATCH 3/5] feat: enhance error handling with direct slog.Attr support This commit updates the error handling functions to allow passing slog.Attr directly to Errorf and Wrap, improving flexibility and usability. It also introduces new helper functions for structured attributes and updates the documentation to reflect these changes. Additionally, comprehensive tests are added to ensure the correct functionality of the new features. --- MIGRATION.md | 25 ++++- README.md | 106 +++++++++++++------- errors.go | 60 +++++++++--- slog_attr_test.go | 243 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 386 insertions(+), 48 deletions(-) create mode 100644 slog_attr_test.go diff --git a/MIGRATION.md b/MIGRATION.md index 12354d9..dfab1d8 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -243,16 +243,33 @@ err := errors.Wrap( ### 3. Direct slog.Attr Usage -Pass `slog.Attr` directly for maximum flexibility: +v0.5.0 allows passing `slog.Attr` directly without wrapping in `errors.Attr()`: ```go +// You can pass slog.Attr directly (NEW!) err := errors.Wrap( err, - errors.Attr(slog.Int64("timestamp", time.Now().Unix())), - errors.Attr(slog.Group("metadata", + slog.Int64("timestamp", time.Now().Unix()), + slog.String("user", "john"), + slog.Group("metadata", slog.String("version", "v1.2.3"), slog.Bool("production", true), - )), + ), +) + +// Or use helper functions (also works) +err := errors.Wrap( + err, + errors.Int64("timestamp", time.Now().Unix()), + errors.String("user", "john"), +) + +// Or mix both styles +err := errors.Wrap( + err, + errors.SkipCaller(), // errors.Option + slog.String("user", "john"), // slog.Attr directly + errors.Int("id", 123), // errors.Option ) ``` diff --git a/README.md b/README.md index 1c25e4d..c6c4f86 100644 --- a/README.md +++ b/README.md @@ -75,22 +75,30 @@ func NewNotFoundError() error { and returns the string as a value that satisfies error. You can wrap an error using `%w` modifier. `errors.Errorf()` also records the stack trace at the point it was called. If the wrapped error -contains a stack trace then a new one will not be added to a chain. Also, you can pass -options to set structured attributes or to skip a caller in a stack trace. -Options must be specified after formatting arguments. +contains a stack trace then a new one will not be added to a chain. + +You can pass options to set structured attributes or to skip a caller in a stack trace. +Both helper functions like `errors.String()` and `slog.Attr` directly are supported. +Options/attributes must be specified after formatting arguments. ```golang row := repository.db.QueryRow(ctx, findSQL, id) var product Product err := row.Scan(&product.ID, &product.Name) if err != nil { - // Use errors.Errorf to wrap the library error with the message context and - // error attributes to be used for structured logging. + // Option 1: Use helper functions return nil, errors.Errorf( "%w: %v", errSQLError, err.Error(), errors.String("sql", findSQL), errors.Int("productID", id), ) + + // Option 2: Use slog.Attr directly (more idiomatic) + return nil, errors.Errorf( + "%w: %v", errSQLError, err.Error(), + slog.String("sql", findSQL), + slog.Int("productID", id), + ) } ``` @@ -98,65 +106,97 @@ if err != nil { `errors.Wrap()` returns an error annotating err with a stack trace at the point `errors.Wrap()` is called. If the wrapped error contains a stack trace then a new one will not be added to a chain. -If err is nil, Wrap returns nil. Also, you can pass options to set structured attributes or to skip a caller -in a stack trace. +If err is nil, Wrap returns nil. + +You can pass options to set structured attributes or to skip a caller in a stack trace. +Both helper functions like `errors.String()` and `slog.Attr` directly are supported. ```golang data, err := service.Handle(ctx, userID, message) if err != nil { - // Adds a stack trace to the line that was called (if there is no stack trace in the chain already) - // and adds attributes for structured logging. + // Option 1: Use helper functions return nil, errors.Wrap( err, errors.Int("userID", userID), errors.String("userMessage", message), ) + + // Option 2: Use slog.Attr directly (recommended) + return nil, errors.Wrap( + err, + slog.Int("userID", userID), + slog.String("userMessage", message), + ) + + // Option 3: Mix both styles + return nil, errors.Wrap( + err, + errors.SkipCaller(), // Option for stack trace + slog.String("userMessage", message), // slog.Attr for logging + ) } ``` -### Working with grouped attributes +### Working with slog attributes -The package supports grouped attributes via `slog.Group`, allowing you to organize related attributes: +The package has native slog integration - you can pass `slog.Attr` directly to `Wrap()` and `Errorf()`: ```golang +// Use slog attributes directly (recommended) err := errors.Wrap( dbErr, - errors.Group("request", - slog.String("method", "POST"), - slog.String("path", "/api/users"), - slog.Int("status", 500), - ), - errors.Group("database", - slog.String("query", "INSERT INTO users..."), - slog.Duration("duration", 150*time.Millisecond), - ), + slog.String("table", "users"), + slog.Int("id", 123), + slog.Duration("query_time", 50*time.Millisecond), ) -``` -You can use all slog attribute types directly: +// Or use helper functions (equivalent) +err := errors.Wrap( + dbErr, + errors.String("table", "users"), + errors.Int("id", 123), + errors.Duration("query_time", 50*time.Millisecond), +) -```golang +// All slog types are supported err := errors.Wrap( err, - errors.Int64("timestamp", time.Now().Unix()), - errors.Uint64("bytes_written", uint64(1024*1024*500)), - errors.Float64("cpu_usage", 0.85), - errors.Any("metadata", map[string]interface{}{ + slog.Bool("cached", false), + slog.Int64("timestamp", time.Now().Unix()), + slog.Uint64("bytes_written", uint64(1024*1024*500)), + slog.Float64("cpu_usage", 0.85), + slog.Any("metadata", map[string]interface{}{ "version": "v1.2.3", "region": "us-west-1", }), ) ``` -Or pass `slog.Attr` directly for maximum flexibility: +### Working with grouped attributes + +Organize related attributes using `slog.Group` directly: ```golang err := errors.Wrap( - err, - errors.Attr(slog.Group("metadata", - slog.String("version", "v1.2.3"), - slog.Bool("production", true), - )), + dbErr, + slog.Group("request", + slog.String("method", "POST"), + slog.String("path", "/api/users"), + slog.Int("status", 500), + ), + slog.Group("database", + slog.String("query", "INSERT INTO users..."), + slog.Duration("duration", 150*time.Millisecond), + ), +) + +// Or use the errors.Group helper +err := errors.Wrap( + dbErr, + errors.Group("request", + slog.String("method", "POST"), + slog.String("path", "/api/users"), + ), ) ``` diff --git a/errors.go b/errors.go index 58febd2..3105c14 100644 --- a/errors.go +++ b/errors.go @@ -115,10 +115,17 @@ func Unwrap(err error) error { // Errorf formats according to a format specifier and returns the string // as a value that satisfies error. You can wrap an error using %w modifier as it // does fmt.Errorf function. +// // Errorf also records the stack trace at the point it was called. If the wrapped error // contains a stack trace then a new one will not be added to a chain. -// Also, you can pass an options to set a structured fields or to skip a caller -// in a stack trace. Options must be specified after formatting arguments. +// +// You can pass options to set structured attributes or to skip a caller in a stack trace. +// Both Option functions and slog.Attr values are accepted. +// Options/attributes must be specified after formatting arguments: +// +// errors.Errorf("failed: %w", err, errors.String("key", "value")) +// errors.Errorf("failed: %w", err, slog.String("key", "value")) +// errors.Errorf("failed: %w", err, errors.SkipCaller(), slog.Int("id", 123)) func Errorf(message string, argsAndOptions ...interface{}) error { args, options := splitArgsAndOptions(argsAndOptions) opts := newOptions(options...) @@ -138,12 +145,20 @@ func Errorf(message string, argsAndOptions ...interface{}) error { // Wrap returns an error annotating err with a stack trace at the point Wrap is called. // If the wrapped error contains a stack trace then a new one will not be added to a chain. // If err is nil, Wrap returns nil. -// Also, you can pass an options to set a structured fields or to skip a caller -// in a stack trace. -func Wrap(err error, options ...Option) error { +// +// You can pass options to set structured attributes or to skip a caller in a stack trace. +// Both Option functions and slog.Attr values are accepted: +// +// errors.Wrap(err, errors.String("key", "value")) // Using Option +// errors.Wrap(err, slog.String("key", "value")) // Using slog.Attr directly +// errors.Wrap(err, errors.SkipCaller(), slog.Int("id", 123)) // Mixed +func Wrap(err error, optsOrAttrs ...interface{}) error { if err == nil { return nil } + + options := convertToOptions(optsOrAttrs) + if isWrapper(err) { if len(options) == 0 { return err @@ -265,7 +280,7 @@ func (e *stacked) MarshalJSON() ([]byte, error) { func splitArgsAndOptions(argsAndOptions []interface{}) ([]interface{}, []Option) { argsCount := len(argsAndOptions) for i := argsCount - 1; i >= 0; i-- { - if _, ok := argsAndOptions[i].(Option); ok { + if isOptionOrAttr(argsAndOptions[i]) { argsCount-- } else { break @@ -273,14 +288,37 @@ func splitArgsAndOptions(argsAndOptions []interface{}) ([]interface{}, []Option) } args := argsAndOptions[:argsCount] - options := make([]Option, 0, len(argsAndOptions)-argsCount) - for i := argsCount; i < len(argsAndOptions); i++ { - options = append(options, argsAndOptions[i].(Option)) - } + optsOrAttrs := argsAndOptions[argsCount:] + options := convertToOptions(optsOrAttrs) return args, options } +// isOptionOrAttr checks if a value is either an Option or slog.Attr +func isOptionOrAttr(v interface{}) bool { + if _, ok := v.(Option); ok { + return true + } + if _, ok := v.(slog.Attr); ok { + return true + } + return false +} + +// convertToOptions converts a slice of Option and/or slog.Attr to []Option +func convertToOptions(items []interface{}) []Option { + options := make([]Option, 0, len(items)) + for _, item := range items { + switch v := item.(type) { + case Option: + options = append(options, v) + case slog.Attr: + options = append(options, Attr(v)) + } + } + return options +} + func getArgErrors(message string, args []interface{}) []error { indices := getErrorIndices(message) errs := make([]error, 0, len(indices)) @@ -361,7 +399,7 @@ func writeAttrs(w io.Writer, attrs []slog.Attr, prefix string) { // writeAttr writes a single attribute value to an io.Writer func writeAttr(w io.Writer, key string, value slog.Value) { io.WriteString(w, "\n"+key+": ") - + switch value.Kind() { case slog.KindBool: if value.Bool() { diff --git a/slog_attr_test.go b/slog_attr_test.go new file mode 100644 index 0000000..ff697c4 --- /dev/null +++ b/slog_attr_test.go @@ -0,0 +1,243 @@ +package errors_test + +import ( + "log/slog" + "reflect" + "testing" + + "github.com/muonsoft/errors" +) + +// TestSlogAttrDirectUsage tests that slog.Attr can be passed directly to Errorf and Wrap +func TestSlogAttrDirectUsage(t *testing.T) { + t.Run("Wrap with slog.Attr", func(t *testing.T) { + err := errors.Wrap( + errors.New("base error"), + slog.String("key1", "value1"), + slog.Int("key2", 42), + ) + + attrs := errors.Attrs(err) + if len(attrs) != 2 { + t.Fatalf("expected 2 attributes, got %d", len(attrs)) + } + + if attrs[0].Key != "key1" || attrs[0].Value.String() != "value1" { + t.Errorf("unexpected first attribute: %v", attrs[0]) + } + if attrs[1].Key != "key2" || attrs[1].Value.Any() != int64(42) { + t.Errorf("unexpected second attribute: %v", attrs[1]) + } + }) + + t.Run("Errorf with slog.Attr", func(t *testing.T) { + err := errors.Errorf( + "formatted error: %d", + 100, + slog.String("key1", "value1"), + slog.Bool("key2", true), + ) + + attrs := errors.Attrs(err) + if len(attrs) != 2 { + t.Fatalf("expected 2 attributes, got %d", len(attrs)) + } + + if attrs[0].Key != "key1" || attrs[0].Value.String() != "value1" { + t.Errorf("unexpected first attribute: %v", attrs[0]) + } + if attrs[1].Key != "key2" || attrs[1].Value.Bool() != true { + t.Errorf("unexpected second attribute: %v", attrs[1]) + } + }) + + t.Run("mixed Option and slog.Attr", func(t *testing.T) { + err := errors.Wrap( + errors.New("base error"), + errors.String("opt1", "value1"), + slog.String("attr1", "value2"), + errors.SkipCaller(), + slog.Int("attr2", 123), + errors.Int("opt2", 456), + ) + + attrs := errors.Attrs(err) + if len(attrs) != 4 { + t.Fatalf("expected 4 attributes, got %d", len(attrs)) + } + + expectedKeys := map[string]interface{}{ + "opt1": "value1", + "attr1": "value2", + "attr2": int64(123), + "opt2": int64(456), + } + + for _, attr := range attrs { + expected, ok := expectedKeys[attr.Key] + if !ok { + t.Errorf("unexpected attribute key: %s", attr.Key) + continue + } + actual := attr.Value.Any() + if !reflect.DeepEqual(actual, expected) { + t.Errorf("for key %s: expected %v, got %v", attr.Key, expected, actual) + } + } + }) + + t.Run("slog.Group directly in Wrap", func(t *testing.T) { + err := errors.Wrap( + errors.New("base error"), + slog.Group("request", + slog.String("method", "GET"), + slog.String("path", "/api"), + ), + ) + + attrs := errors.Attrs(err) + if len(attrs) != 1 { + t.Fatalf("expected 1 attribute (group), got %d", len(attrs)) + } + + if attrs[0].Key != "request" { + t.Errorf("expected group key 'request', got '%s'", attrs[0].Key) + } + + if attrs[0].Value.Kind() != slog.KindGroup { + t.Errorf("expected group kind, got %v", attrs[0].Value.Kind()) + } + }) + + t.Run("Errorf with wrapped error and slog.Attr", func(t *testing.T) { + baseErr := errors.Wrap(errors.New("base"), slog.String("base_key", "base_value")) + err := errors.Errorf( + "wrapped: %w", + baseErr, + slog.String("wrap_key", "wrap_value"), + ) + + attrs := errors.Attrs(err) + if len(attrs) != 2 { + t.Fatalf("expected 2 attributes from chain, got %d", len(attrs)) + } + + hasBase := false + hasWrap := false + for _, attr := range attrs { + if attr.Key == "base_key" && attr.Value.String() == "base_value" { + hasBase = true + } + if attr.Key == "wrap_key" && attr.Value.String() == "wrap_value" { + hasWrap = true + } + } + + if !hasBase || !hasWrap { + t.Errorf("expected both base_key and wrap_key attributes") + } + }) + + t.Run("all slog types directly", func(t *testing.T) { + err := errors.Wrap( + errors.New("test"), + slog.Bool("b", true), + slog.Int("i", 42), + slog.Int64("i64", 9223372036854775807), + slog.Uint64("u64", 18446744073709551615), + slog.Float64("f64", 3.14), + slog.String("s", "text"), + slog.Any("any", []int{1, 2, 3}), + ) + + attrs := errors.Attrs(err) + if len(attrs) != 7 { + t.Fatalf("expected 7 attributes, got %d", len(attrs)) + } + + // Verify all types are present + keys := make(map[string]bool) + for _, attr := range attrs { + keys[attr.Key] = true + } + + expectedKeys := []string{"b", "i", "i64", "u64", "f64", "s", "any"} + for _, key := range expectedKeys { + if !keys[key] { + t.Errorf("expected attribute with key '%s'", key) + } + } + }) +} + +// TestSlogAttrVsOption tests that slog.Attr and Option produce equivalent results +func TestSlogAttrVsOption(t *testing.T) { + tests := []struct { + name string + withOption func() error + withAttr func() error + }{ + { + name: "String", + withOption: func() error { + return errors.Wrap(errors.New("test"), errors.String("key", "value")) + }, + withAttr: func() error { + return errors.Wrap(errors.New("test"), slog.String("key", "value")) + }, + }, + { + name: "Int", + withOption: func() error { + return errors.Wrap(errors.New("test"), errors.Int("key", 123)) + }, + withAttr: func() error { + return errors.Wrap(errors.New("test"), slog.Int("key", 123)) + }, + }, + { + name: "Bool", + withOption: func() error { + return errors.Wrap(errors.New("test"), errors.Bool("key", true)) + }, + withAttr: func() error { + return errors.Wrap(errors.New("test"), slog.Bool("key", true)) + }, + }, + { + name: "Float64", + withOption: func() error { + return errors.Wrap(errors.New("test"), errors.Float64("key", 3.14)) + }, + withAttr: func() error { + return errors.Wrap(errors.New("test"), slog.Float64("key", 3.14)) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + errOption := test.withOption() + errAttr := test.withAttr() + + attrsOption := errors.Attrs(errOption) + attrsAttr := errors.Attrs(errAttr) + + if len(attrsOption) != 1 || len(attrsAttr) != 1 { + t.Fatalf("expected both to have 1 attribute, got Option=%d, Attr=%d", + len(attrsOption), len(attrsAttr)) + } + + opt := attrsOption[0] + attr := attrsAttr[0] + + if opt.Key != attr.Key { + t.Errorf("keys differ: Option=%s, Attr=%s", opt.Key, attr.Key) + } + + if !reflect.DeepEqual(opt.Value.Any(), attr.Value.Any()) { + t.Errorf("values differ: Option=%v, Attr=%v", opt.Value.Any(), attr.Value.Any()) + } + }) + } +} From b440d85fcefe2e0f0cd248e67b739886b8c0fefd Mon Sep 17 00:00:00 2001 From: Igor Lazarev Date: Mon, 9 Feb 2026 22:01:21 +0300 Subject: [PATCH 4/5] chore: update golangci-lint configuration and improve test formatting This commit modifies the .golangci.yml file to update the version and adjust the enabled linters, while also refining the exclusion rules for test files. Additionally, it enhances the formatting of test files by removing unnecessary blank lines and ensuring consistent comment formatting across various test cases. The GitHub Actions workflow is updated to use the latest Go version and golangci-lint action, improving the CI process. --- .github/workflows/tests.yml | 17 +++--- .golangci.yml | 119 ++++++++++++++++++++++-------------- assertions_test.go | 4 +- errors.go | 16 ++--- errors_test.go | 6 +- errorstest/mock.go | 4 +- example_log_test.go | 10 +-- example_loggable_test.go | 6 +- logging_test.go | 38 ++++++------ options.go | 2 +- slog_attr_test.go | 4 +- slog_test.go | 10 +-- 12 files changed, 132 insertions(+), 104 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8a4f09c..e9c9b14 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,22 +10,23 @@ jobs: test: runs-on: ubuntu-latest steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v5 with: - go-version: ^1.20 + go-version: ^1.21 + cache-dependency-path: go.sum id: go - - name: Checkout code - uses: actions/checkout@v2 - - name: Set up dependencies run: go mod download - name: Run golangci-lint - uses: golangci/golangci-lint-action@v2 + uses: golangci/golangci-lint-action@v9 with: - version: v1.52 + version: v2.6.1 - name: Run tests - run: go test -v $(go list ./... | grep -v vendor) + run: go test -race -v ./... diff --git a/.golangci.yml b/.golangci.yml index 17b24e2..3e2f146 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,6 +1,4 @@ -run: - skip-dirs: - - var +version: "2" linters: enable: - asciicheck @@ -11,7 +9,6 @@ linters: - dogsled - dupl - durationcheck - - exportloopref - forbidigo - funlen - gocognit @@ -20,16 +17,10 @@ linters: - gocyclo - godot - godox - - gofmt - - gofumpt - - goimports - gomodguard - goprintffuncname - gosec - - gosimple - - govet - importas - - ineffassign - lll - makezero - misspell @@ -37,51 +28,87 @@ linters: - nestif - nilerr - noctx - - noctx - nolintlint - prealloc - predeclared - promlinter - revive - - stylecheck - - tenv + - staticcheck - testpackage - thelper - tparallel - - typecheck - unconvert - unparam - - unused - whitespace - -issues: - exclude-rules: - # Exclude some linters from running on tests files. - - path: _test\.go - linters: - - contextcheck - - cyclop - - dupl - - errcheck - - exportloopref - - funlen - - gochecknoglobals - - goconst - - gocritic - - gocyclo - - gosec - - lll - - path: errors\.go - linters: - - errcheck - - path: stack\.go - linters: - - errcheck - - goconst - - gocritic - -linters-settings: - revive: + settings: + depguard: + rules: + main: + files: + - $all + - '!$test' + - '!**/test/**/*' + allow: + - $gostd + - github.com + test: + files: + - $test + allow: + - $gostd + - github.com + revive: + rules: + - name: var-naming + disabled: true + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling rules: - - name: var-naming - disabled: true + - linters: + - contextcheck + - cyclop + - dupl + - errcheck + - exportloopref + - funlen + - gochecknoglobals + - gocognit + - goconst + - gocritic + - gocyclo + - gosec + - lll + - nestif + path: _test\.go + - linters: + - errcheck + path: errors\.go + - linters: + - nestif + path: errorstest + - linters: + - errcheck + - goconst + - gocritic + path: stack\.go + paths: + - var + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofmt + - gofumpt + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/assertions_test.go b/assertions_test.go index a2542b6..6c8c005 100644 --- a/assertions_test.go +++ b/assertions_test.go @@ -34,8 +34,8 @@ func assertFormatRegexp(t *testing.T, arg interface{}, format, want string) { t.Helper() got := fmt.Sprintf(format, arg) - gotLines := strings.SplitN(got, "\n", -1) - wantLines := strings.SplitN(want, "\n", -1) + gotLines := strings.Split(got, "\n") + wantLines := strings.Split(want, "\n") if len(wantLines) > len(gotLines) { t.Errorf("wantLines(%d) > gotLines(%d):\n got: %q\nwant: %q", len(wantLines), len(gotLines), got, want) diff --git a/errors.go b/errors.go index 3105c14..f025b3c 100644 --- a/errors.go +++ b/errors.go @@ -250,16 +250,16 @@ func (e *stacked) Format(s fmt.State, verb rune) { switch verb { case 'v': if s.Flag('+') { - io.WriteString(s, e.wrapped.Error()) - writeAttrs(s, e.wrapped.Attrs(), "") + io.WriteString(s, e.Error()) + writeAttrs(s, e.Attrs(), "") e.stack.Format(s, verb) return } fallthrough case 's': - io.WriteString(s, e.wrapped.Error()) + io.WriteString(s, e.Error()) case 'q': - fmt.Fprintf(s, "%q", e.wrapped.Error()) + fmt.Fprintf(s, "%q", e.Error()) } } @@ -294,7 +294,7 @@ func splitArgsAndOptions(argsAndOptions []interface{}) ([]interface{}, []Option) return args, options } -// isOptionOrAttr checks if a value is either an Option or slog.Attr +// isOptionOrAttr checks if a value is either an Option or slog.Attr. func isOptionOrAttr(v interface{}) bool { if _, ok := v.(Option); ok { return true @@ -305,7 +305,7 @@ func isOptionOrAttr(v interface{}) bool { return false } -// convertToOptions converts a slice of Option and/or slog.Attr to []Option +// convertToOptions converts a slice of Option and/or slog.Attr to []Option. func convertToOptions(items []interface{}) []Option { options := make([]Option, 0, len(items)) for _, item := range items { @@ -396,7 +396,7 @@ func writeAttrs(w io.Writer, attrs []slog.Attr, prefix string) { } } -// writeAttr writes a single attribute value to an io.Writer +// writeAttr writes a single attribute value to an io.Writer. func writeAttr(w io.Writer, key string, value slog.Value) { io.WriteString(w, "\n"+key+": ") @@ -433,7 +433,7 @@ func writeAttr(w io.Writer, key string, value slog.Value) { } case json.RawMessage: // Format JSON as string - io.WriteString(w, string(typed)) + w.Write(typed) default: // Default formatting io.WriteString(w, fmt.Sprintf("%v", v)) diff --git a/errors_test.go b/errors_test.go index 0da88c5..a2d308e 100644 --- a/errors_test.go +++ b/errors_test.go @@ -316,7 +316,7 @@ func TestFields(t *testing.T) { if len(attrs) == 0 { t.Fatalf("expected %#v to have attributes", test.err) } - + // Find the "key" attribute var found bool var value interface{} @@ -327,11 +327,11 @@ func TestFields(t *testing.T) { break } } - + if !found { t.Fatalf("expected %#v to have attribute with key 'key'", test.err) } - + if !reflect.DeepEqual(value, test.expected) { t.Errorf("want value %v (%T), got %v (%T)", test.expected, test.expected, value, value) } diff --git a/errorstest/mock.go b/errorstest/mock.go index e911ab5..a18684a 100644 --- a/errorstest/mock.go +++ b/errorstest/mock.go @@ -51,7 +51,7 @@ func (m *Logger) AssertField(t *testing.T, key string, expected interface{}) { } } -// findAttr recursively searches for an attribute by key, handling groups with dot notation +// findAttr recursively searches for an attribute by key, handling groups with dot notation. func (m *Logger) findAttr(key string, attrs []slog.Attr, prefix string) (interface{}, bool) { for _, attr := range attrs { if attr.Value.Kind() == slog.KindGroup { @@ -157,7 +157,7 @@ func (m *Logger) AssertStackTrace(t *testing.T, want StackTrace) { } } -// attrsEqual compares two slog.Attr values for equality +// attrsEqual compares two slog.Attr values for equality. func attrsEqual(a, b slog.Attr) bool { if a.Key != b.Key { return false diff --git a/example_log_test.go b/example_log_test.go index 7102530..1309055 100644 --- a/example_log_test.go +++ b/example_log_test.go @@ -125,7 +125,7 @@ func ExampleLog_typicalErrorHandling() { // Get attributes from error attrs := errors.Attrs(notFoundError) - + // Get stack trace var stackTrace errors.StackTrace for e := notFoundError; e != nil; e = errors.Unwrap(e) { @@ -134,7 +134,7 @@ func ExampleLog_typicalErrorHandling() { break } } - + fmt.Println(`log repository error, attrs count:`, len(attrs)) fmt.Printf( "log repository error, first line of stack trace: %s %s:%d\n", @@ -165,13 +165,13 @@ func ExampleLog_typicalErrorHandling() { // Get attributes from error attrs := errors.Attrs(sqlError) - + // Create a map for display fields := make(map[string]interface{}) for _, attr := range attrs { fields[attr.Key] = attr.Value.Any() } - + // Get stack trace var stackTrace errors.StackTrace for e := sqlError; e != nil; e = errors.Unwrap(e) { @@ -180,7 +180,7 @@ func ExampleLog_typicalErrorHandling() { break } } - + fmt.Println(`log repository error, fields:`, fields) fmt.Printf( "log repository error, first line of stack trace: %s %s:%d\n", diff --git a/example_loggable_test.go b/example_loggable_test.go index 50aacf9..64729a8 100644 --- a/example_loggable_test.go +++ b/example_loggable_test.go @@ -40,13 +40,13 @@ func ExampleLog_loggableError() { // Get attributes from error attrs := errors.Attrs(err) - + // Create a map for display fields := make(map[string]interface{}) for _, attr := range attrs { fields[attr.Key] = attr.Value.Any() } - + // Get stack trace var stackTrace errors.StackTrace for e := err; e != nil; e = errors.Unwrap(e) { @@ -55,7 +55,7 @@ func ExampleLog_loggableError() { break } } - + fmt.Println(`error message:`, err.Error()) fmt.Println(`error fields:`, fields) fmt.Printf( diff --git a/logging_test.go b/logging_test.go index 6805d5d..dc90b2e 100644 --- a/logging_test.go +++ b/logging_test.go @@ -33,18 +33,18 @@ func TestAttrs_errorWithStack(t *testing.T) { ), errors.String("key", "value"), ) - + attrs := errors.Attrs(err) - + // Should have 3 attributes if len(attrs) != 3 { t.Fatalf("expected 3 attrs, got %d", len(attrs)) } - + // Create a mock logger to use assertion helpers logger := errorstest.NewLogger() logger.Attrs = attrs - + logger.AssertField(t, "key", "value") logger.AssertField(t, "deepKey", "deepValue") logger.AssertField(t, "deepestKey", "deepestValue") @@ -64,18 +64,18 @@ func TestAttrs_joinedErrors(t *testing.T) { ), ), ) - + attrs := errors.Attrs(err) - + // Should have 5 attributes if len(attrs) < 5 { t.Fatalf("expected at least 5 attrs, got %d", len(attrs)) } - + // Create a mock logger to use assertion helpers logger := errorstest.NewLogger() logger.Attrs = attrs - + logger.AssertField(t, "key1", "value1") logger.AssertField(t, "key2", "value2") logger.AssertField(t, "key3", "value3") @@ -88,7 +88,7 @@ func TestLog(t *testing.T) { var capturedAttrs []slog.Attr var capturedMsg string var capturedLevel slog.Level - + handler := &testHandler{ onHandle: func(ctx context.Context, r slog.Record) error { capturedMsg = r.Message @@ -100,32 +100,32 @@ func TestLog(t *testing.T) { return nil }, } - + logger := slog.New(handler) - + err := errors.Wrap( errors.Errorf("test error", errors.String("user", "john"), errors.Int("id", 123)), ) - + errors.Log(context.Background(), logger, slog.LevelError, err) - + if capturedMsg != "test error" { t.Errorf("expected message 'test error', got '%s'", capturedMsg) } - + if capturedLevel != slog.LevelError { t.Errorf("expected level Error, got %v", capturedLevel) } - + if len(capturedAttrs) < 2 { t.Fatalf("expected at least 2 attrs, got %d", len(capturedAttrs)) } - + // Check for user and id attributes hasUser := false hasID := false hasStackTrace := false - + for _, attr := range capturedAttrs { if attr.Key == "user" && attr.Value.String() == "john" { hasUser = true @@ -137,7 +137,7 @@ func TestLog(t *testing.T) { hasStackTrace = true } } - + if !hasUser { t.Error("expected 'user' attribute") } @@ -149,7 +149,7 @@ func TestLog(t *testing.T) { } } -// testHandler is a simple slog.Handler for testing +// testHandler is a simple slog.Handler for testing. type testHandler struct { onHandle func(ctx context.Context, r slog.Record) error } diff --git a/options.go b/options.go index 7e108a4..92f9116 100644 --- a/options.go +++ b/options.go @@ -199,7 +199,7 @@ func Group(key string, attrs ...slog.Attr) Option { } } -// attrsToAny converts []slog.Attr to []any for use with slog.Group +// attrsToAny converts []slog.Attr to []any for use with slog.Group. func attrsToAny(attrs []slog.Attr) []any { result := make([]any, len(attrs)) for i, attr := range attrs { diff --git a/slog_attr_test.go b/slog_attr_test.go index ff697c4..0cd99be 100644 --- a/slog_attr_test.go +++ b/slog_attr_test.go @@ -8,7 +8,7 @@ import ( "github.com/muonsoft/errors" ) -// TestSlogAttrDirectUsage tests that slog.Attr can be passed directly to Errorf and Wrap +// TestSlogAttrDirectUsage tests that slog.Attr can be passed directly to Errorf and Wrap. func TestSlogAttrDirectUsage(t *testing.T) { t.Run("Wrap with slog.Attr", func(t *testing.T) { err := errors.Wrap( @@ -170,7 +170,7 @@ func TestSlogAttrDirectUsage(t *testing.T) { }) } -// TestSlogAttrVsOption tests that slog.Attr and Option produce equivalent results +// TestSlogAttrVsOption tests that slog.Attr and Option produce equivalent results. func TestSlogAttrVsOption(t *testing.T) { tests := []struct { name string diff --git a/slog_test.go b/slog_test.go index 78030a6..1d99a68 100644 --- a/slog_test.go +++ b/slog_test.go @@ -10,7 +10,7 @@ import ( "github.com/muonsoft/errors" ) -// TestGroupAttributes tests error attributes with slog groups +// TestGroupAttributes tests error attributes with slog groups. func TestGroupAttributes(t *testing.T) { t.Run("simple group", func(t *testing.T) { err := errors.Wrap( @@ -212,7 +212,7 @@ func TestGroupAttributes(t *testing.T) { }) } -// TestGroupJSON tests JSON marshaling of grouped attributes +// TestGroupJSON tests JSON marshaling of grouped attributes. func TestGroupJSON(t *testing.T) { t.Run("group with key creates nested object", func(t *testing.T) { err := errors.Wrap( @@ -312,7 +312,7 @@ func TestGroupJSON(t *testing.T) { }) } -// TestGroupFormatting tests %+v formatting of grouped attributes +// TestGroupFormatting tests %+v formatting of grouped attributes. func TestGroupFormatting(t *testing.T) { t.Run("group with key uses dot notation", func(t *testing.T) { err := errors.Wrap( @@ -376,7 +376,7 @@ func TestGroupFormatting(t *testing.T) { }) } -// TestSlogLogValuer tests that wrapped errors implement slog.LogValuer +// TestSlogLogValuer tests that wrapped errors implement slog.LogValuer. func TestSlogLogValuer(t *testing.T) { t.Run("error implements LogValuer", func(t *testing.T) { err := errors.Wrap( @@ -415,7 +415,7 @@ func TestSlogLogValuer(t *testing.T) { }) } -// TestLogAttrs tests the LogAttrs convenience function +// TestLogAttrs tests the LogAttrs convenience function. func TestLogAttrsComplete(t *testing.T) { t.Run("logs all attributes and stack trace", func(t *testing.T) { var capturedAttrs []slog.Attr From 172a28e39e7ffa7cdc2d943499d5241a52d3134a Mon Sep 17 00:00:00 2001 From: Igor Lazarev Date: Tue, 10 Feb 2026 17:20:42 +0300 Subject: [PATCH 5/5] refactor: simplify logging functions and introduce LogLevel This commit refactors the logging functions by removing the level parameter from the Log function, making it a shorthand for logging at the Error level. A new LogLevel function is introduced to allow logging at specified levels. Additionally, tests are updated to reflect these changes, and documentation is revised to guide users on the new logging approach. --- MIGRATION.md | 12 ++++++------ README.md | 6 +++--- logging.go | 22 +++++++++++++++++----- logging_test.go | 22 +++++++++++++++++++++- slog_test.go | 6 +++--- 5 files changed, 50 insertions(+), 18 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index dfab1d8..8bb4218 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -14,14 +14,14 @@ Version 0.5.0 is a **breaking change** release that replaces the custom field sy - ✅ **Added**: `slog.LogValuer` implementation - ✅ **Added**: Grouped attributes via `slog.Group` - ✅ **Added**: `errors.Attrs(err)` to extract attributes -- ✅ **Added**: `errors.Log(ctx, logger, level, err)` for slog logging +- ✅ **Added**: `errors.Log(ctx, logger, err)` and `errors.LogLevel(ctx, logger, level, err)` for slog logging - 📦 **Minimum Go version**: 1.21 (for `log/slog` support) ## Quick Migration Checklist - [ ] Update Go version to 1.21 or higher - [ ] Remove imports of `errors/logging/logrusadapter` -- [ ] Replace old `errors.Log(err, logger)` calls with new `errors.Log(ctx, logger, level, err)` or direct slog usage +- [ ] Replace old `errors.Log(err, logger)` calls with new `errors.Log(ctx, logger, err)` or `errors.LogLevel(ctx, logger, level, err)` or direct slog usage - [ ] Update custom error types implementing `LoggableError` - [ ] Update mock loggers in tests to use `errorstest.Logger` - [ ] Consider using grouped attributes for better structure @@ -326,7 +326,7 @@ If you were using the logrus adapter: - logrusadapter.Log(err, logrusLogger) // Option 1: Switch to slog -+ errors.Log(ctx, slog.Default(), slog.LevelError, err) ++ errors.Log(ctx, slog.Default(), err) // Option 2: Create your own logrus adapter + // See "Custom Logger Adapters" section below @@ -378,7 +378,7 @@ Replace `errors.Log()` calls: - errors.Log(err, myLogger) + // Option 1: Use Log -+ errors.Log(ctx, slog.Default(), slog.LevelError, err) ++ errors.Log(ctx, slog.Default(), err) + // Option 2: Extract and log + attrs := errors.Attrs(err) @@ -516,7 +516,7 @@ logger := otelslog.NewHandler(...) logger := slog.New(myHandler) // Works the same way -errors.Log(ctx, logger, slog.LevelError, err) +errors.Log(ctx, logger, err) ``` ### 4. Simplified Testing @@ -562,7 +562,7 @@ func (e *MyError) Attrs() []slog.Attr { **Solution**: Convert to `[]any` or use `Log()`: ```go // Option 1: Use Log -errors.Log(ctx, logger, level, err) +errors.LogLevel(ctx, logger, level, err) // Option 2: Convert manually attrs := errors.Attrs(err) diff --git a/README.md b/README.md index c6c4f86..cb64f4e 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Key differences and features: * `errors.IsOfType[T any](err error)` to test for error types. * `errors.Attrs(err error) []slog.Attr` to extract all attributes from error chain. -* `errors.Log(ctx, logger, level, err)` convenience function for logging with slog. +* `errors.Log(ctx, logger, err)` and `errors.LogLevel(ctx, logger, level, err)` for logging with slog. ## Installation @@ -318,8 +318,8 @@ err := errors.Errorf( ), ) -// Log error with all attributes and stack trace -errors.Log(ctx, slog.Default(), slog.LevelError, err) +// Log error at Error level with all attributes and stack trace +errors.Log(ctx, slog.Default(), err) ``` #### Extracting attributes manually diff --git a/logging.go b/logging.go index 87eee6d..de72f5d 100644 --- a/logging.go +++ b/logging.go @@ -43,17 +43,29 @@ func attrsFromError(err error) []slog.Attr { return attrs } -// Log logs an error with all its structured attributes and stack trace -// using the provided slog.Logger. This is a convenience function for logging -// errors with slog. +// Log logs an error at Error level with all its structured attributes and stack trace +// using the provided slog.Logger. It is a shorthand for LogLevel(ctx, logger, slog.LevelError, err). // // If err is nil, this function does nothing. // // Example: // // err := errors.Wrap(dbErr, errors.String("query", sql), errors.Int("userID", 123)) -// errors.Log(ctx, slog.Default(), slog.LevelError, err) -func Log(ctx context.Context, logger *slog.Logger, level slog.Level, err error) { +// errors.Log(ctx, slog.Default(), err) +func Log(ctx context.Context, logger *slog.Logger, err error) { + LogLevel(ctx, logger, slog.LevelError, err) +} + +// LogLevel logs an error at the specified level with all its structured attributes +// and stack trace using the provided slog.Logger. +// +// If err is nil, this function does nothing. +// +// Example: +// +// errors.LogLevel(ctx, slog.Default(), slog.LevelWarn, err) +// errors.LogLevel(ctx, slog.Default(), slog.LevelError, err) +func LogLevel(ctx context.Context, logger *slog.Logger, level slog.Level, err error) { if err == nil { return } diff --git a/logging_test.go b/logging_test.go index dc90b2e..e27e8ca 100644 --- a/logging_test.go +++ b/logging_test.go @@ -107,7 +107,7 @@ func TestLog(t *testing.T) { errors.Errorf("test error", errors.String("user", "john"), errors.Int("id", 123)), ) - errors.Log(context.Background(), logger, slog.LevelError, err) + errors.Log(context.Background(), logger, err) if capturedMsg != "test error" { t.Errorf("expected message 'test error', got '%s'", capturedMsg) @@ -149,6 +149,26 @@ func TestLog(t *testing.T) { } } +func TestLogLevel(t *testing.T) { + for _, level := range []slog.Level{slog.LevelDebug, slog.LevelInfo, slog.LevelWarn, slog.LevelError} { + t.Run(level.String(), func(t *testing.T) { + var capturedLevel slog.Level + handler := &testHandler{ + onHandle: func(ctx context.Context, r slog.Record) error { + capturedLevel = r.Level + return nil + }, + } + logger := slog.New(handler) + err := errors.New("test") + errors.LogLevel(context.Background(), logger, level, err) + if capturedLevel != level { + t.Errorf("expected level %v, got %v", level, capturedLevel) + } + }) + } +} + // testHandler is a simple slog.Handler for testing. type testHandler struct { onHandle func(ctx context.Context, r slog.Record) error diff --git a/slog_test.go b/slog_test.go index 1d99a68..fbeb9e5 100644 --- a/slog_test.go +++ b/slog_test.go @@ -443,7 +443,7 @@ func TestLogAttrsComplete(t *testing.T) { ), ) - errors.Log(context.Background(), logger, slog.LevelError, err) + errors.Log(context.Background(), logger, err) if capturedMsg != "database error" { t.Errorf("expected message 'database error', got '%s'", capturedMsg) @@ -497,7 +497,7 @@ func TestLogAttrsComplete(t *testing.T) { logger := slog.New(handler) - errors.Log(context.Background(), logger, slog.LevelError, nil) + errors.Log(context.Background(), logger, nil) if handlerCalled { t.Error("expected handler not to be called for nil error") @@ -527,7 +527,7 @@ func TestLogAttrsComplete(t *testing.T) { ), ) - errors.Log(context.Background(), logger, slog.LevelError, err) + errors.Log(context.Background(), logger, err) // Should have request group and stackTrace hasRequestGroup := false