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/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..8bb4218 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,605 @@ +# 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, 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, 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 + +## 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. 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: + +```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 +``` + +### 3. Direct slog.Attr Usage + +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, + 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 +) +``` + +### 4. 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...)) +``` + +### 5. 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(), 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(), 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, 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.LogLevel(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..cb64f4e 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, err)` and `errors.LogLevel(ctx, logger, level, err)` 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,54 +69,141 @@ 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. -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 fields 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), + ) } ``` -### `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 -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 fields 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 slog 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, + slog.String("table", "users"), + slog.Int("id", 123), + slog.Duration("query_time", 50*time.Millisecond), +) + +// Or use helper functions (equivalent) +err := errors.Wrap( + dbErr, + errors.String("table", "users"), + errors.Int("id", 123), + errors.Duration("query_time", 50*time.Millisecond), +) + +// All slog types are supported +err := errors.Wrap( + err, + 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", + }), +) +``` + +### Working with grouped attributes + +Organize related attributes using `slog.Group` directly: + +```golang +err := errors.Wrap( + 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"), + ), +) +``` + ### 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 +216,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 +229,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 +243,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 +256,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 +275,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 +300,126 @@ 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 at Error level with all attributes and stack trace +errors.Log(ctx, slog.Default(), 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) ``` -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}]" + +### 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) +``` + +## Available attribute options + +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) // 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) + +// 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) // 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 + +// Deprecated +errors.Value(key string, value interface{}) // Use Any instead ``` ## Contributing 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 8c5942e..f025b3c 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. @@ -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...) @@ -126,11 +133,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), } } @@ -138,24 +145,32 @@ 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 } - 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 +192,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 +210,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 +226,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() } } @@ -236,27 +250,27 @@ func (e *stacked) Format(s fmt.State, verb rune) { switch verb { case 'v': if s.Flag('+') { - io.WriteString(s, e.wrapped.Error()) - e.wrapped.LogFields(&stringWriter{writer: s}) + 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()) } } 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()) } } @@ -266,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 @@ -274,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)) @@ -317,72 +354,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)) -} +// 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+": ") -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)) + 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 + w.Write(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..a2d308e 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..a18684a 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..1309055 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..64729a8 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..de72f5d 100644 --- a/logging.go +++ b/logging.go @@ -1,154 +1,91 @@ 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) -} - -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) -} + return attrs +} + +// 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(), 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 + } -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..e27e8ca 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,131 @@ 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, 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") + } +} + +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 +} + +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..92f9116 100644 --- a/options.go +++ b/options.go @@ -3,96 +3,209 @@ 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. +// The value is converted to int64 for slog compatibility. func Int(key string, value int) Option { return func(options *Options) { - options.AddField(IntField{Key: key, Value: value}) + 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 { return func(options *Options) { - options.AddField(UintField{Key: key, Value: value}) + options.addAttr(slog.Any(key, value)) } } -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.AddField(FloatField{Key: key, Value: value}) + 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) { - 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)) } } -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.AddField(ValueField{Key: key, Value: value}) + 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) { - 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/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()) + } +} diff --git a/slog_attr_test.go b/slog_attr_test.go new file mode 100644 index 0000000..0cd99be --- /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()) + } + }) + } +} diff --git a/slog_test.go b/slog_test.go new file mode 100644 index 0000000..fbeb9e5 --- /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, 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, 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, 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) + } + }) +}