diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 884ca0e..134a403 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,8 +2,6 @@ name: Lint on: push: - tags: - branches: pull_request: jobs: @@ -17,28 +15,6 @@ jobs: stable: false - uses: actions/checkout@v2 - name: golangci-lint - uses: golangci/golangci-lint-action@v2 + uses: golangci/golangci-lint-action@v3 with: - # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version - version: latest - - # Optional: working directory, useful for monorepos - working-directory: ./ - - # Optional: golangci-lint command line arguments. - args: --timeout 60s --max-same-issues 50 - - # Optional: show only new issues if it's a pull request. The default value is `false`. - # only-new-issues: true - - # Optional: if set to true then the action will use pre-installed Go. - # skip-go-installation: true - - # Optional: if set to true then the action don't cache or restore ~/go/pkg. - # skip-pkg-cache: true - - # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. - # skip-build-cache: true - - # optionally use a specific version of Go rather than the latest one - go_version: '1.21.3' + args: --timeout 120s --max-same-issues 50 diff --git a/README.md b/README.md index 91c78d0..fea29bb 100644 --- a/README.md +++ b/README.md @@ -220,23 +220,24 @@ The library provides an error builder. Each method can be used standalone (eg: ` The `oops.OopsError` builder must finish with either `.Errorf(...)`, `.Wrap(...)` or `.Wrapf(...)`. -| Builder method | Getter | Description | -| --------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `.With(string, any)` | `err.Context() map[string]any` | Supply a list of attributes key+value. Values of type `func() any {}` are accepted and evaluated lazily. | -| `.Code(string)` | `err.Code() string` | Set a code or slug that describes the error. Error messages are intented to be read by humans, but such code is expected to be read by machines and be transported over different services | -| `.Time(time.Time)` | `err.Time() time.Time` | Set the error time (default: `time.Now()`) | -| `.Since(time.Time)` | `err.Duration() time.Duration` | Set the error duration | -| `.Duration(time.Duration)` | `err.Duration() time.Duration` | Set the error duration | -| `.In(string)` | `err.Domain() string` | Set the feature category or domain | -| `.Tags(...string)` | `err.Tags() []string` | Add multiple tags, describing the feature returning an error | -| `.Trace(string)` | `err.Trace() string` | Add a transaction id, trace id, correlation id... (default: ULID) | -| `.Span(string)` | `err.Span() string` | Add a span representing a unit of work or operation... (default: ULID) | -| `.Hint(string)` | `err.Hint() string` | Set a hint for faster debugging | -| `.Owner(string)` | `err.Owner() (string)` | Set the name/email of the collegue/team responsible for handling this error. Useful for alerting purpose | -| `.User(string, any...)` | `err.User() (string, map[string]any)` | Supply user id and a chain of key/value | -| `.Tenant(string, any...)` | `err.Tenant() (string, map[string]any)` | Supply tenant id and a chain of key/value | -| `.Request(*http.Request, bool)` | `err.Request() *http.Request` | Supply http request | -| `.Response(*http.Response, bool)` | `err.Response() *http.Response` | Supply http response | +| Builder method | Getter | Description | +| --------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `.With(string, any)` | `err.Context() map[string]any` | Supply a list of attributes key+value. Values of type `func() any {}` are accepted and evaluated lazily. | +| `.WithContext(context.Context, ...any)` | `err.Context() map[string]any` | Supply a list of values declared in context. Values of type `func() any {}` are accepted and evaluated lazily. | +| `.Code(string)` | `err.Code() string` | Set a code or slug that describes the error. Error messages are intented to be read by humans, but such code is expected to be read by machines and be transported over different services | +| `.Time(time.Time)` | `err.Time() time.Time` | Set the error time (default: `time.Now()`) | +| `.Since(time.Time)` | `err.Duration() time.Duration` | Set the error duration | +| `.Duration(time.Duration)` | `err.Duration() time.Duration` | Set the error duration | +| `.In(string)` | `err.Domain() string` | Set the feature category or domain | +| `.Tags(...string)` | `err.Tags() []string` | Add multiple tags, describing the feature returning an error | +| `.Trace(string)` | `err.Trace() string` | Add a transaction id, trace id, correlation id... (default: ULID) | +| `.Span(string)` | `err.Span() string` | Add a span representing a unit of work or operation... (default: ULID) | +| `.Hint(string)` | `err.Hint() string` | Set a hint for faster debugging | +| `.Owner(string)` | `err.Owner() (string)` | Set the name/email of the collegue/team responsible for handling this error. Useful for alerting purpose | +| `.User(string, any...)` | `err.User() (string, map[string]any)` | Supply user id and a chain of key/value | +| `.Tenant(string, any...)` | `err.Tenant() (string, map[string]any)` | Supply tenant id and a chain of key/value | +| `.Request(*http.Request, bool)` | `err.Request() *http.Request` | Supply http request | +| `.Response(*http.Response, bool)` | `err.Response() *http.Response` | Supply http response | #### Examples @@ -251,11 +252,13 @@ err2 := oops. Errorf("could not fetch user") // with custom attributes +ctx := context.WithContext(context.Background(), "a key", "value") err3 := oops. With("driver", "postgresql"). With("query", query). With("query.duration", queryDuration). With("lorem", func() string { return "ipsum" }). // lazy evaluation + WithContext(ctx, "a key", "another key"). Errorf("could not fetch user") // with trace+span diff --git a/builder.go b/builder.go index c6e5e00..542b983 100644 --- a/builder.go +++ b/builder.go @@ -1,6 +1,7 @@ package oops import ( + "context" "fmt" "net/http" "time" @@ -147,12 +148,6 @@ func (o OopsErrorBuilder) Recover(cb func()) (err error) { } else { err = o.Wrap(fmt.Errorf("%v", r)) } - - // without this, the stacktrace would have start to the Wrap() call - e := err.(OopsError) - if len(e.stacktrace.frames) > 0 { // just for safety, should always be true - e.stacktrace.frames = e.stacktrace.frames[1:] - } } }() @@ -162,24 +157,7 @@ func (o OopsErrorBuilder) Recover(cb func()) (err error) { // Recoverf handle panic and returns `oops.OopsError` object that satisfies `error` and formats an error message. func (o OopsErrorBuilder) Recoverf(cb func(), msg string, args ...any) (err error) { - defer func() { - if r := recover(); r != nil { - if e, ok := r.(error); ok { - err = o.Wrapf(e, msg, args...) - } else { - err = o.Wrapf(o.Errorf("%v", r), msg, args...) - } - - // without this, the stacktrace would have start to the Wrapf() call - e := err.(OopsError) - if len(e.stacktrace.frames) > 0 { // just for safety, should always be true - e.stacktrace.frames = e.stacktrace.frames[1:] - } - } - }() - - cb() - return + return o.Wrapf(o.Recover(cb), msg, args...) } // Assert panics if condition is false. Panic payload will be of type oops.OopsError. @@ -262,6 +240,26 @@ func (o OopsErrorBuilder) With(kv ...any) OopsErrorBuilder { return o2 } +// WithContext supplies a list of values declared in context. +func (o OopsErrorBuilder) WithContext(ctx context.Context, keys ...any) OopsErrorBuilder { + o2 := o.copy() + + for i := 0; i < len(keys); i++ { + switch k := keys[i].(type) { + case fmt.Stringer: + o2.context[k.String()] = contextValueOrNil(ctx, k.String()) + case string: + o2.context[k] = contextValueOrNil(ctx, k) + case *string: + o2.context[*k] = contextValueOrNil(ctx, *k) + default: + o2.context[fmt.Sprint(k)] = contextValueOrNil(ctx, k) + } + } + + return o2 +} + // Trace set a transaction id, trace id or correlation id... func (o OopsErrorBuilder) Trace(trace string) OopsErrorBuilder { o2 := o.copy() diff --git a/error.go b/error.go index ff2e3bf..5e306b7 100644 --- a/error.go +++ b/error.go @@ -16,6 +16,8 @@ import ( var SourceFragmentsHidden = true +var _ error = (*OopsError)(nil) + type OopsError struct { err error msg string diff --git a/examples/segfault/README.md b/examples/segfault/README.md new file mode 100644 index 0000000..cc716d3 --- /dev/null +++ b/examples/segfault/README.md @@ -0,0 +1,9 @@ + +# Example of segfault handling + +Playground: https://go.dev/play/p/66wkzJ-Rem1 + +```sh +go run examples/segfault/example.go 2>&1 | jq +go run examples/segfault/example.go 2>&1 | jq .stacktrace -r +``` diff --git a/examples/segfault/example.go b/examples/segfault/example.go new file mode 100644 index 0000000..a9c2a76 --- /dev/null +++ b/examples/segfault/example.go @@ -0,0 +1,40 @@ +package main + +import ( + "github.com/samber/oops" + oopslogrus "github.com/samber/oops/loggers/logrus" + "github.com/sirupsen/logrus" +) + +// go run examples/segfault/example.go 2>&1 | jq +// go run examples/segfault/example.go 2>&1 | jq .stacktrace -r + +func nilPointerException() { + var a *int + *a = 42 +} + +func handlePanic() error { + return oops. + Code("iam_authz_missing_permission"). + In("authz"). + With("permission", "post.create"). + Trace("6710668a-2b2a-4de6-b8cf-3272a476a1c9"). + Hint("Runbook: https://doc.acme.org/doc/abcd.md"). + Recoverf(func() { + // ... + nilPointerException() + // ... + }, "unexpected error") +} + +func main() { + logrus.SetFormatter(oopslogrus.NewOopsFormatter(&logrus.JSONFormatter{ + PrettyPrint: true, + })) + + err := handlePanic() + if err != nil { + logrus.WithError(err).Error(err) + } +} diff --git a/examples/segfault/go.mod b/examples/segfault/go.mod new file mode 100644 index 0000000..0dd3484 --- /dev/null +++ b/examples/segfault/go.mod @@ -0,0 +1,15 @@ +module github.com/samber/oops/examples/segfault + +go 1.21 + +require ( + github.com/stretchr/testify v1.8.2 + golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/samber/lo v1.38.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/examples/segfault/go.sum b/examples/segfault/go.sum new file mode 100644 index 0000000..61a5bea --- /dev/null +++ b/examples/segfault/go.sum @@ -0,0 +1,25 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= +github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= +github.com/samber/slog-formatter v0.3.3 h1:VCoKANbPtXf00CtnvKn/BZ7gcH1dBBnm48PAH854ynQ= +github.com/samber/slog-formatter v0.3.3/go.mod h1:C8LO3jmgtpSAxw7pm9xLjPcJ/h4qzw3OfVIMASyEKQ0= +github.com/samber/slog-multi v0.4.0 h1:QTQAo+9AP295irccqKdNwJ/2XflRMuL/aHqk7RblOhE= +github.com/samber/slog-multi v0.4.0/go.mod h1:QDicB1R5oTcbSSqlYmskphC7fNcjHgdxqZdns1eAvDc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o= +golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go.work b/go.work index 3c06e68..5305cb0 100644 --- a/go.work +++ b/go.work @@ -15,4 +15,5 @@ use ( ./examples/logrus ./examples/sources ./examples/panic + ./examples/segfault ) diff --git a/oops.go b/oops.go index 4b754c3..5311b15 100644 --- a/oops.go +++ b/oops.go @@ -1,6 +1,7 @@ package oops import ( + "context" "net/http" "time" ) @@ -100,6 +101,11 @@ func With(kv ...any) OopsErrorBuilder { return new().With(kv...) } +// With supplies a list of attributes declared by pair of key+value. +func WithContext(ctx context.Context, keys ...any) OopsErrorBuilder { + return new().WithContext(ctx, keys...) +} + // Hint set a hint for faster debugging. func Hint(hint string) OopsErrorBuilder { return new().Hint(hint) diff --git a/oops_test.go b/oops_test.go index f69ea65..898beff 100644 --- a/oops_test.go +++ b/oops_test.go @@ -1,6 +1,7 @@ package oops import ( + "context" "encoding/json" "fmt" "net/http" @@ -138,6 +139,46 @@ func TestOopsWith(t *testing.T) { is.Equal(err.(OopsError).context, map[string]any{"user_id": 1234, "foo": "bar"}) } +func TestOopsWithContext(t *testing.T) { + is := assert.New(t) + + type test string + const fooo test = "fooo" + + ctx := context.WithValue(context.Background(), "foo", "bar") //nolint:staticcheck + ctx = context.WithValue(ctx, fooo, "baz") + + // string + err := new().WithContext(ctx, "foo").Wrap(assert.AnError) + is.Error(err) + is.Equal(err.(OopsError).err, assert.AnError) + is.Equal(err.(OopsError).context, map[string]any{"foo": "bar"}) + + // type alias + err = new().WithContext(ctx, fooo).Wrap(assert.AnError) + is.Error(err) + is.Equal(err.(OopsError).err, assert.AnError) + is.Equal(err.(OopsError).context, map[string]any{"fooo": "baz"}) + + // multiple + err = new().WithContext(ctx, "foo", fooo).Wrap(assert.AnError) + is.Error(err) + is.Equal(err.(OopsError).err, assert.AnError) + is.Equal(err.(OopsError).context, map[string]any{"foo": "bar", "fooo": "baz"}) + + // not found + err = new().WithContext(ctx, "bar").Wrap(assert.AnError) + is.Error(err) + is.Equal(err.(OopsError).err, assert.AnError) + is.Equal(err.(OopsError).context, map[string]any{"bar": nil}) + + // none + err = new().WithContext(ctx).Wrap(assert.AnError) + is.Error(err) + is.Equal(err.(OopsError).err, assert.AnError) + is.Equal(err.(OopsError).context, map[string]any{}) +} + func TestOopsWithLazyEvaluation(t *testing.T) { is := assert.New(t) @@ -266,6 +307,7 @@ func TestOopsMixed(t *testing.T) { In("authz"). Trace("1234"). With("user_id", 1234). + WithContext(context.WithValue(context.Background(), "foo", "bar"), "foo"). //nolint:staticcheck Hint("Runbook: https://doc.acme.org/doc/abcd.md"). Owner("authz-team@acme.org"). User("user-123", "firstname", "john", "lastname", "doe"). @@ -278,7 +320,7 @@ func TestOopsMixed(t *testing.T) { is.Equal(err.(OopsError).duration, time.Second) is.Equal(err.(OopsError).domain, "authz") is.Equal(err.(OopsError).trace, "1234") - is.Equal(err.(OopsError).context, map[string]any{"user_id": 1234}) + is.Equal(err.(OopsError).context, map[string]any{"user_id": 1234, "foo": "bar"}) is.Equal(err.(OopsError).hint, "Runbook: https://doc.acme.org/doc/abcd.md") is.Equal(err.(OopsError).owner, "authz-team@acme.org") is.Equal(err.(OopsError).userID, "user-123") diff --git a/utils.go b/utils.go index 5c4a000..8b5705b 100644 --- a/utils.go +++ b/utils.go @@ -1,6 +1,8 @@ package oops import ( + "context" + "github.com/samber/lo" ) @@ -8,3 +10,13 @@ func coalesceOrEmpty[T comparable](v ...T) T { result, _ := lo.Coalesce(v...) return result } + +// convert (interface{})(nil) to nil +func contextValueOrNil(ctx context.Context, k any) any { + v := ctx.Value(k) + if v == nil { + return nil + } + + return v +}