Skip to content

Commit 810e050

Browse files
lobocvCalvin Lobo
andauthored
Added methods for returning a structured logger with attached auxiliary info (#15)
* Added methods for returning a structured logger with attached auxiliary info * updated README --------- Co-authored-by: Calvin Lobo <[email protected]>
1 parent 8ca320b commit 810e050

File tree

4 files changed

+169
-6
lines changed

4 files changed

+169
-6
lines changed

Makefile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11

22

33
test:
4-
go test ./... -v -count=1 -cover
4+
go test ./... -v -count=1 -race -coverprofile coverage_tmp.out && cat coverage_tmp.out | grep -v ping > coverage.out
5+
6+
lint:
7+
golangci-lint run
8+
9+
view-coverage: test
10+
go tool cover -html=coverage.out

README.md

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ The `SimpleError` allows you to easily:
2424

2525
- Apply an error code to any error. Choose from a list of standard codes or [register](https://pkg.go.dev/github.com/lobocv/simplerr#Registry) your own.
2626
- Automatically translate `simplerr` (including custom codes) error codes to other standardized codes such as `HTTP/gRPC` via middleware.
27-
- Attach key-value pairs to errors that can be used with structured loggers.
27+
- Attach key-value pairs to errors
28+
- Easily log key-value information from errors in structured loggers (slog)
2829
- Attach and check for custom attributes similar to the `context` package.
2930
- Automatically capture stack traces at the point the error is raised.
3031
- Mark errors as [`silent`](https://pkg.go.dev/github.com/lobocv/simplerr#SimpleError.Silence) so they can be skipped by logging middleware.
@@ -188,10 +189,30 @@ there is a lack of control when dealing only with the simple `string`-backed err
188189

189190
### Logging with Structured Loggers
190191

191-
It is good practice to use structured logging to improve observability. However, the standard library error does
192-
not allow for attaching and retrieving key-value pairs on errors. With `simplerr` you can retrieve a superset of all attached
193-
key-value data on errors in the chain using [`ExtractAuxiliary()`](https://pkg.go.dev/github.com/lobocv/simplerr#ExtractAuxiliary)
194-
or on the individual error with [`GetAuxiliary()`](https://pkg.go.dev/github.com/lobocv/simplerr#SimpleError.GetAuxiliary).
192+
It is good practice to use structured logging to improve observability. With `simplerr` you can attach key-value pairs
193+
to the `SimplerError` and generate a pre-populated structured logger right from the error:
194+
195+
```go
196+
197+
serr := simplerr.New("not enough credits").Aux("current_credits", 10, "requested_credits", 5)
198+
log := serr.GetLogger()
199+
log.Error(serr.Error())
200+
```
201+
202+
This outputs a log line with the structured key-value information from the SimpleError.
203+
204+
> {"time":"2025-01-24T13:12:12.924564-05:00","level":"ERROR","msg":"not enough credits","requested_credits":50,"current_credits":10}
205+
206+
You can also attach an existing structured logger to a SimpleError:
207+
208+
```go
209+
log := slog.Default().With("flavour", "vanilla")
210+
serr := simplerr.New("we do not sell that flavor").Logger(log)
211+
```
212+
213+
When calling the `GetLogger()` method, the returned logger is built by combining key-value pairs for all errors in the
214+
chain. This means that wrapping multiple errors that each attach key-value information will return a structured logger
215+
preserving all the key-value pairs.
195216

196217
### Benign Errors
197218

errors.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package simplerr
22

33
import (
44
"fmt"
5+
"log/slog"
6+
"slices"
57
)
68

79
// attribute is a key value pair for attributes on errors
@@ -30,6 +32,8 @@ type SimpleError struct {
3032
retriable bool
3133
// auxiliary are auxiliary informational fields that can be attached to the error
3234
auxiliary map[string]interface{}
35+
// logger is a scoped logger that can be attached to the error
36+
logger *slog.Logger
3337
// attr is a list of custom attributes attached the error
3438
attr []attribute
3539
// stackTrace is the call stack trace for the error
@@ -183,6 +187,41 @@ func (e *SimpleError) Attr(key, value interface{}) *SimpleError {
183187
return e
184188
}
185189

190+
// Logger attaches a structured logger to the error
191+
func (e *SimpleError) Logger(l *slog.Logger) *SimpleError {
192+
e.logger = l
193+
return e
194+
}
195+
196+
// GetLogger returns a structured logger with auxiliary information preset
197+
// It uses the first found attached structured logger and otherwise uses the default logger
198+
func (e *SimpleError) GetLogger() *slog.Logger {
199+
l := e.logger
200+
201+
fields := make([]any, 0, 2*len(e.auxiliary))
202+
var errInChain = e
203+
for errInChain != nil {
204+
if l == nil && errInChain.logger != nil {
205+
l = errInChain.logger
206+
}
207+
208+
for k, v := range errInChain.GetAuxiliary() {
209+
fields = append(fields, v, k) // append backwards because of the slice reversal
210+
}
211+
212+
errInChain = As(errInChain.Unwrap())
213+
}
214+
// Reverse the slice so that the aux values at the top of the stack take precedent over the lower ones
215+
// For cases where there are conflicting keys
216+
slices.Reverse(fields)
217+
218+
if l == nil {
219+
l = slog.Default()
220+
}
221+
222+
return l.With(fields...)
223+
}
224+
186225
// GetDescription returns the description of the error code on the error.
187226
func (e *SimpleError) GetDescription() string {
188227
return registry.CodeDescription(e.code)

errors_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package simplerr
22

33
import (
4+
"bytes"
5+
"encoding/json"
46
"errors"
57
"fmt"
8+
"log/slog"
69
"strings"
710
"testing"
811

@@ -519,6 +522,100 @@ func (s *TestSuite) TestErrorFormatting() {
519522

520523
}
521524

525+
func setupTestLogger() *bytes.Buffer {
526+
var output bytes.Buffer
527+
logger := slog.New(slog.NewJSONHandler(&output, nil))
528+
slog.SetDefault(logger)
529+
return &output
530+
}
531+
532+
func (s *TestSuite) TestGetLogger() {
533+
serr := New("something")
534+
s.Equal(slog.Default(), serr.GetLogger(), "without attaching a logger, return the default logger")
535+
536+
var output bytes.Buffer
537+
logger := slog.New(slog.NewJSONHandler(&output, nil))
538+
firstLogger := logger.With("first", 1)
539+
_ = serr.Logger(firstLogger)
540+
s.Equal(firstLogger, serr.GetLogger(), "return the attached logger")
541+
542+
secondLogger := logger.With("second", 2)
543+
serr2 := Wrapf(serr, "wrapped").Logger(secondLogger)
544+
s.Equal(secondLogger, serr2.GetLogger(), "return the first attached logger in the chain")
545+
546+
serr3 := Wrapf(serr, "wrapped")
547+
s.Equal(firstLogger, serr3.GetLogger(), "return the first attached logger in the chain")
548+
549+
}
550+
551+
func (s *TestSuite) TestAttachedLogger() {
552+
output := setupTestLogger()
553+
logger := slog.Default().With("scoped", "from_attached_logger")
554+
original := fmt.Errorf("original")
555+
serr1 := Wrapf(original, "wrapper %d", 1).
556+
Aux(
557+
"aux_first", 1,
558+
).Logger(logger.With("scoped_not_used", "this logger is not used because its deeper in the stack"))
559+
560+
serr2 := Wrapf(serr1, "wrapper %d", 2).
561+
Aux("aux_second", 2).Logger(logger)
562+
563+
l := serr2.GetLogger()
564+
l.Info("Write a log")
565+
566+
got := map[string]any{}
567+
err := json.NewDecoder(output).Decode(&got)
568+
s.NoError(err)
569+
570+
s.Contains(got, "scoped")
571+
s.Equal(got["scoped"], "from_attached_logger")
572+
573+
s.NotContains(got, "scoped_not_used")
574+
575+
s.Contains(got, "aux_first")
576+
s.Equal(got["aux_first"], float64(1))
577+
578+
s.Contains(got, "aux_second")
579+
s.Equal(got["aux_second"], float64(2))
580+
581+
}
582+
583+
func (s *TestSuite) TestAuxLogger() {
584+
output := setupTestLogger()
585+
original := fmt.Errorf("original")
586+
serr1 := Wrapf(original, "wrapper %d", 1).
587+
Aux(
588+
"first_a", 1,
589+
"first_b", 2,
590+
)
591+
592+
serr2 := Wrapf(serr1, "wrapper %d", 2).
593+
Aux("first_a", 10,
594+
"second_a", 0,
595+
"second_a", 20, // should overwrite the previous key
596+
"second_b", 30,
597+
)
598+
599+
l := serr2.GetLogger()
600+
l.Info("Write a log")
601+
602+
got := map[string]any{}
603+
err := json.NewDecoder(output).Decode(&got)
604+
s.NoError(err)
605+
606+
s.Contains(got, "first_a")
607+
s.Equal(got["first_a"], float64(10), "first_a should be overwritten with 10 due to name conflict")
608+
609+
s.Contains(got, "first_b")
610+
s.Equal(got["first_b"], float64(2), "first_b should not be overwritten")
611+
612+
s.Contains(got, "second_a")
613+
s.Equal(got["second_a"], float64(20), "second_a should use the latest value due to name conflict")
614+
615+
s.Contains(got, "second_b")
616+
s.Equal(got["second_b"], float64(30))
617+
}
618+
522619
func (s *TestSuite) TestStackTrace() {
523620

524621
e := First()

0 commit comments

Comments
 (0)