Skip to content

Commit bc13c7a

Browse files
authored
fix: deep review improvements — bug fixes, docs, and test coverage (#107)
* fix: deep review improvements — bug fixes, docs, and test coverage - Fix README typos: undefined var `err` -> `err1`, `.Tag()` -> `.Tags()` - Deep-copy tags in builder.copy() with slices.Clone to prevent shared-array bugs - Add panic recovery to lazyValueEvaluation (produces descriptive string on panic) - Replace lo.Assign with maps.Clone in builder.copy() (stdlib, idiomatic) - Document all global config vars (AutoTraceID, DereferencePointers, Local) in README - Document Wrapf + %w foot-gun behavior in README - Add logrus formatter tests (was zero coverage) - Add AsOops/AsError[T] helper tests - Add Format %s/%q verb coverage - Add benchmark suite for core operations * oops * fix: resolve lint errors and test failures - fix unchecked errors on logger.Sync, AddObject, AddReflected - replace unsafe type assertion with AsOops + ok check in logrus test - normalize trailing whitespace on blank HTTP request lines in test - add missing slog.KindAny case in builder - modernize interface{} to any in zerolog formatter and kv test - add nolint directives for intentional testifylint/paralleltest cases * fix(builder): ensure context/userData/tenantData are never nil after copy() maps.Clone(nil) returns nil, causing panics when callers like With, WithContext, User, and Tenant attempt to assign into those maps. Add nil-guards after cloning so the maps are always initialized.
1 parent 13cd300 commit bc13c7a

19 files changed

Lines changed: 407 additions & 37 deletions

README.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ oops.SourceFragmentsHidden = false
348348

349349
err1 := oops.Errorf("permission denied")
350350
// ...
351-
err2 := oops.Wrapf(err, "something failed")
351+
err2 := oops.Wrapf(err1, "something failed")
352352

353353
fmt.Println(err2.(oops.OopsError).Sources())
354354
```
@@ -473,12 +473,15 @@ attr := slog.Error(err.Error(),
473473
// slog.Group("error", ...)
474474
```
475475

476-
#### Custom timezone
476+
#### Global configuration
477477

478-
```go
479-
loc, _ := time.LoadLocation("Europe/Paris")
480-
oops.Local = loc
481-
```
478+
| Variable | Default | Description |
479+
|---|---|---|
480+
| `oops.StackTraceMaxDepth` | `10` | Max stack trace depth |
481+
| `oops.SourceFragmentsHidden` | `true` | Hide source code fragments |
482+
| `oops.Local` | `time.UTC` | Timezone for error timestamps |
483+
| `oops.AutoTraceID` | `true` | Auto-generate ULID trace IDs |
484+
| `oops.DereferencePointers` | `true` | Auto-dereference pointers in context |
482485

483486
### Go context
484487

@@ -488,7 +491,7 @@ An `OopsErrorBuilder` can be transported in a go `context.Context` to reuse late
488491
func myFunc(ctx context.Context) {
489492
oops.
490493
FromContext(ctx).
491-
Tag("auth").
494+
Tags("auth").
492495
Errorf("not permitted")
493496
}
494497

@@ -536,6 +539,8 @@ userMessage := oops.GetPublic(err, "Unexpected error")
536539

537540
### Wrap/Wrapf shortcut
538541

542+
> **Note:** `oops.Wrapf(err, "msg: %w", otherErr)` does **not** chain `otherErr` — the `%w` verb is only used for string formatting. The error chain is always `result → err`. Use `oops.Errorf("msg: %w", otherErr)` if you need `%w` chaining.
543+
539544
`oops.Wrap(...)` and `oops.Wrapf(...)` returns nil if the provided `error` is nil.
540545

541546
❌ So don't write:

benchmarks/benchmark_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package benchmarks
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/samber/oops"
8+
)
9+
10+
func BenchmarkNew(b *testing.B) {
11+
for i := 0; i < b.N; i++ {
12+
_ = oops.New("an error")
13+
}
14+
}
15+
16+
func BenchmarkErrorfSimple(b *testing.B) {
17+
for i := 0; i < b.N; i++ {
18+
_ = oops.Errorf("an error: %s", "details")
19+
}
20+
}
21+
22+
func BenchmarkErrorfWrap(b *testing.B) {
23+
inner := errors.New("inner")
24+
for i := 0; i < b.N; i++ {
25+
_ = oops.Errorf("wrapped: %w", inner)
26+
}
27+
}
28+
29+
func BenchmarkWrap(b *testing.B) {
30+
inner := errors.New("inner")
31+
for i := 0; i < b.N; i++ {
32+
_ = oops.Wrap(inner)
33+
}
34+
}
35+
36+
func BenchmarkWrapf(b *testing.B) {
37+
inner := errors.New("inner")
38+
for i := 0; i < b.N; i++ {
39+
_ = oops.Wrapf(inner, "context: %s", "details")
40+
}
41+
}
42+
43+
func BenchmarkBuilderWithContext(b *testing.B) {
44+
inner := errors.New("inner")
45+
for i := 0; i < b.N; i++ {
46+
_ = oops.
47+
In("database").
48+
Code("db_error").
49+
Tags("critical", "database").
50+
With("query", "SELECT 1", "duration_ms", 42).
51+
Hint("check connection pool").
52+
Owner("backend-team").
53+
Wrap(inner)
54+
}
55+
}
56+
57+
func BenchmarkChainTraversal(b *testing.B) {
58+
err := oops.In("layer1").Wrap(
59+
oops.In("layer2").Wrap(
60+
oops.In("layer3").Errorf("root cause"),
61+
),
62+
)
63+
oopsErr, _ := oops.AsOops(err)
64+
65+
b.ResetTimer()
66+
for i := 0; i < b.N; i++ {
67+
_ = oopsErr.Domain()
68+
_ = oopsErr.Context()
69+
_ = oopsErr.Trace()
70+
_ = oopsErr.Tags()
71+
}
72+
}
73+
74+
func BenchmarkToMap(b *testing.B) {
75+
err := oops.
76+
In("database").
77+
Code("db_error").
78+
Tags("critical").
79+
With("query", "SELECT 1").
80+
Owner("backend-team").
81+
User("user-123", "name", "john").
82+
Errorf("connection failed")
83+
oopsErr, _ := oops.AsOops(err)
84+
85+
b.ResetTimer()
86+
for i := 0; i < b.N; i++ {
87+
_ = oopsErr.ToMap()
88+
}
89+
}
90+
91+
func BenchmarkWrapNil(b *testing.B) {
92+
for i := 0; i < b.N; i++ {
93+
_ = oops.Wrap(nil)
94+
}
95+
}

benchmarks/benchmarks.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Package benchmarks contains performance benchmarks for the oops package.
2+
package benchmarks

benchmarks/go.mod

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
module github.com/samber/oops/benchmarks
2+
3+
go 1.21
4+
5+
replace github.com/samber/oops => ../
6+
7+
require github.com/samber/oops v0.0.0
8+
9+
require (
10+
github.com/oklog/ulid/v2 v2.1.1 // indirect
11+
github.com/samber/lo v1.53.0 // indirect
12+
go.opentelemetry.io/otel v1.29.0 // indirect
13+
go.opentelemetry.io/otel/trace v1.29.0 // indirect
14+
golang.org/x/text v0.22.0 // indirect
15+
)

benchmarks/go.sum

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
4+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
5+
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
6+
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
7+
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
8+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
9+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
10+
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
11+
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
12+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
13+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
14+
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
15+
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
16+
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
17+
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
18+
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
19+
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
20+
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
21+
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
22+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
23+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

builder.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import (
55
"errors"
66
"fmt"
77
"log/slog"
8+
"maps"
89
"net/http"
10+
"slices"
911
"time"
1012

1113
"github.com/oklog/ulid/v2"
@@ -97,16 +99,16 @@ func newBuilder() OopsErrorBuilder {
9799
// It performs deep copying of maps to ensure that modifications to the new
98100
// builder don't affect the original.
99101
func (o OopsErrorBuilder) copy() OopsErrorBuilder {
100-
return OopsErrorBuilder{
102+
o2 := OopsErrorBuilder{
101103
// err: err, // Not copied as it's set by error creation methods
102104
// msg: o.msg, // Not copied as it's set by error creation methods
103105
code: o.code,
104106
time: o.time,
105107
duration: o.duration,
106108

107109
domain: o.domain,
108-
tags: o.tags,
109-
context: lo.Assign(map[string]any{}, o.context), // Deep copy of context map (pointer values are not copied)
110+
tags: slices.Clone(o.tags),
111+
context: maps.Clone(o.context), // Deep copy of context map (pointer values are not copied)
110112

111113
trace: o.trace,
112114
traceAutoGenerated: o.traceAutoGenerated,
@@ -117,15 +119,27 @@ func (o OopsErrorBuilder) copy() OopsErrorBuilder {
117119
owner: o.owner,
118120

119121
userID: o.userID,
120-
userData: lo.Assign(map[string]any{}, o.userData), // Deep copy of user data (pointer values are not copied)
122+
userData: maps.Clone(o.userData), // Deep copy of user data (pointer values are not copied)
121123
tenantID: o.tenantID,
122-
tenantData: lo.Assign(map[string]any{}, o.tenantData), // Deep copy of tenant data (pointer values are not copied)
124+
tenantData: maps.Clone(o.tenantData), // Deep copy of tenant data (pointer values are not copied)
123125

124126
req: o.req,
125127
res: o.res,
126128

127129
// stacktrace: o.stacktrace, // Not copied as it's generated per error
128130
}
131+
// maps.Clone(nil) returns nil; ensure maps are never nil so callers can
132+
// assign into them without panicking (e.g. With, WithContext, User, Tenant).
133+
if o2.context == nil {
134+
o2.context = map[string]any{}
135+
}
136+
if o2.userData == nil {
137+
o2.userData = map[string]any{}
138+
}
139+
if o2.tenantData == nil {
140+
o2.tenantData = map[string]any{}
141+
}
142+
return o2
129143
}
130144

131145
// Wrap wraps an existing error into an OopsError with the current builder's context.
@@ -625,6 +639,8 @@ func slogValueToAny(value slog.Value) any {
625639
return group
626640
case slog.KindLogValuer:
627641
return slogValueToAny(value.LogValuer().LogValue())
642+
case slog.KindAny:
643+
return value.Any()
628644
default:
629645
return value.Any()
630646
}

error_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ func TestErrorsIs(t *testing.T) {
4343
// Previously, Is() returned true for any OopsError target regardless of identity.
4444
err1 := Errorf("error 1")
4545
err2 := Errorf("error 2")
46-
is.False(errors.Is(err1, err2))
47-
is.False(errors.Is(err2, err1))
46+
is.False(errors.Is(err1, err2)) //nolint:testifylint
47+
is.False(errors.Is(err2, err1)) //nolint:testifylint
4848
}
4949

5050
func TestErrorsAs(t *testing.T) {

examples/zap/example.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func main() {
4949
config.EncoderConfig.TimeKey = "timestamp"
5050
config.EncoderConfig.EncodeTime = zapcore.RFC3339TimeEncoder
5151
logger, _ := config.Build()
52-
defer logger.Sync()
52+
defer func() { _ = logger.Sync() }()
5353

5454
err := a()
5555

go.work

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,7 @@ use (
2020

2121
// recovery middlewares
2222
./recovery/gin
23+
24+
// benchmarks
25+
./benchmarks
2326
)

helpers_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package oops
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"io/fs"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestAsOops(t *testing.T) {
13+
t.Parallel()
14+
is := assert.New(t)
15+
16+
// OopsError is found
17+
err := In("test").With("key", "value").Errorf("oops error")
18+
oopsErr, ok := AsOops(err)
19+
is.True(ok)
20+
is.Equal("test", oopsErr.Domain())
21+
is.Equal(map[string]any{"key": "value"}, oopsErr.Context())
22+
23+
// Non-oops error returns false
24+
plainErr := errors.New("plain error")
25+
_, ok = AsOops(plainErr)
26+
is.False(ok)
27+
28+
// Nil error returns false
29+
_, ok = AsOops(nil)
30+
is.False(ok)
31+
32+
// OopsError wrapped by fmt.Errorf is found
33+
wrapped := fmt.Errorf("wrapper: %w", err)
34+
oopsErr, ok = AsOops(wrapped)
35+
is.True(ok)
36+
is.Equal("test", oopsErr.Domain())
37+
38+
// Deeply nested OopsError is found
39+
deepWrapped := fmt.Errorf("outer: %w", fmt.Errorf("inner: %w", err))
40+
oopsErr, ok = AsOops(deepWrapped)
41+
is.True(ok)
42+
is.Equal("test", oopsErr.Domain())
43+
}
44+
45+
func TestAsError(t *testing.T) {
46+
t.Parallel()
47+
is := assert.New(t)
48+
49+
// Matches concrete error type
50+
err := Wrapf(fs.ErrExist, "wrapped")
51+
fsErr, ok := AsError[*fs.PathError](fmt.Errorf("path: %w", &fs.PathError{Op: "open", Path: "/tmp", Err: fs.ErrExist}))
52+
is.True(ok)
53+
is.Equal("open", fsErr.Op)
54+
55+
// OopsError matched via AsError
56+
oopsErr, ok := AsError[OopsError](err)
57+
is.True(ok)
58+
is.NotEmpty(oopsErr.Span())
59+
60+
// Non-matching type returns false
61+
_, ok = AsError[*fs.PathError](errors.New("not a path error"))
62+
is.False(ok)
63+
64+
// Nil error returns false
65+
_, ok = AsError[OopsError](nil)
66+
is.False(ok)
67+
}

0 commit comments

Comments
 (0)