diff --git a/CHANGELOG.md b/CHANGELOG.md index f810a4cc1..45d27a4e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Add `http.request.method` attribute for performance span data ([#786](https://github.com/getsentry/sentry-go/pull/786)) - Automatic transactions for Fasthttp integration ([#732](https://github.com/getsentry/sentry-go/pull/723)) - Add `Fiber` integration ([#795](https://github.com/getsentry/sentry-go/pull/795)) +- Use `errors.Unwrap()` to create exception groups ([#792](https://github.com/getsentry/sentry-go/pull/792)) ## 0.27.0 diff --git a/client_test.go b/client_test.go index ac24361f0..6a36c1018 100644 --- a/client_test.go +++ b/client_test.go @@ -164,14 +164,24 @@ func TestCaptureException(t *testing.T) { { Type: "*sentry.customErr", Value: "wat", - // No Stacktrace, because we can't tell where the error came - // from and because we have a stack trace in the most recent - // error in the chain. + Mechanism: &Mechanism{ + Data: map[string]any{ + "is_exception_group": true, + "exception_id": 0, + }, + }, }, { Type: "*errors.withStack", Value: "wat", Stacktrace: &Stacktrace{Frames: []Frame{}}, + Mechanism: &Mechanism{ + Data: map[string]any{ + "exception_id": 1, + "is_exception_group": true, + "parent_id": 0, + }, + }, }, }, }, @@ -193,11 +203,24 @@ func TestCaptureException(t *testing.T) { { Type: "*sentry.customErr", Value: "wat", + Mechanism: &Mechanism{ + Data: map[string]any{ + "is_exception_group": true, + "exception_id": 0, + }, + }, }, { Type: "*sentry.customErrWithCause", Value: "err", Stacktrace: &Stacktrace{Frames: []Frame{}}, + Mechanism: &Mechanism{ + Data: map[string]any{ + "exception_id": 1, + "is_exception_group": true, + "parent_id": 0, + }, + }, }, }, }, @@ -208,11 +231,24 @@ func TestCaptureException(t *testing.T) { { Type: "*errors.errorString", Value: "original", + Mechanism: &Mechanism{ + Data: map[string]any{ + "is_exception_group": true, + "exception_id": 0, + }, + }, }, { Type: "sentry.wrappedError", Value: "wrapped: original", Stacktrace: &Stacktrace{Frames: []Frame{}}, + Mechanism: &Mechanism{ + Data: map[string]any{ + "exception_id": 1, + "is_exception_group": true, + "parent_id": 0, + }, + }, }, }, }, diff --git a/interfaces.go b/interfaces.go index c769faa78..d57f753a8 100644 --- a/interfaces.go +++ b/interfaces.go @@ -3,6 +3,7 @@ package sentry import ( "context" "encoding/json" + "errors" "fmt" "net" "net/http" @@ -222,11 +223,12 @@ func NewRequest(r *http.Request) *Request { // Mechanism is the mechanism by which an exception was generated and handled. type Mechanism struct { - Type string `json:"type,omitempty"` - Description string `json:"description,omitempty"` - HelpLink string `json:"help_link,omitempty"` - Handled *bool `json:"handled,omitempty"` - Data map[string]interface{} `json:"data,omitempty"` + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + HelpLink string `json:"help_link,omitempty"` + Source string `json:"source,omitempty"` + Handled *bool `json:"handled,omitempty"` + Data map[string]any `json:"data,omitempty"` } // SetUnhandled indicates that the exception is an unhandled exception, i.e. @@ -341,25 +343,40 @@ type Event struct { // maxErrorDepth is the maximum depth of the error chain we will look // into while unwrapping the errors. func (e *Event) SetException(exception error, maxErrorDepth int) { - err := exception - if err == nil { + if exception == nil { return } - for i := 0; i < maxErrorDepth && err != nil; i++ { + err := exception + + for i := 0; err != nil && i < maxErrorDepth; i++ { + // Add the current error to the exception slice with its details e.Exception = append(e.Exception, Exception{ Value: err.Error(), Type: reflect.TypeOf(err).String(), Stacktrace: ExtractStacktrace(err), }) - switch previous := err.(type) { - case interface{ Unwrap() error }: - err = previous.Unwrap() - case interface{ Cause() error }: - err = previous.Cause() - default: - err = nil + + // Attempt to unwrap the error using the standard library's Unwrap method. + // If errors.Unwrap returns nil, it means either there is no error to unwrap, + // or the error does not implement the Unwrap method. + unwrappedErr := errors.Unwrap(err) + + if unwrappedErr != nil { + // The error was successfully unwrapped using the standard library's Unwrap method. + err = unwrappedErr + continue + } + + cause, ok := err.(interface{ Cause() error }) + if !ok { + // We cannot unwrap the error further. + break } + + // The error implements the Cause method, indicating it may have been wrapped + // using the github.com/pkg/errors package. + err = cause.Cause() } // Add a trace of the current stack to the most recent error in a chain if @@ -370,8 +387,25 @@ func (e *Event) SetException(exception error, maxErrorDepth int) { e.Exception[0].Stacktrace = NewStacktrace() } + if len(e.Exception) <= 1 { + return + } + // event.Exception should be sorted such that the most recent error is last. reverse(e.Exception) + + for i := range e.Exception { + e.Exception[i].Mechanism = &Mechanism{ + Data: map[string]any{ + "is_exception_group": true, + "exception_id": i, + }, + } + if i == 0 { + continue + } + e.Exception[i].Mechanism.Data["parent_id"] = i - 1 + } } // TODO: Event.Contexts map[string]interface{} => map[string]EventContext, diff --git a/interfaces_test.go b/interfaces_test.go index 0feb5b2fe..74bf797c8 100644 --- a/interfaces_test.go +++ b/interfaces_test.go @@ -2,6 +2,7 @@ package sentry import ( "encoding/json" + "errors" "flag" "fmt" "net/http/httptest" @@ -215,6 +216,153 @@ func TestEventWithDebugMetaMarshalJSON(t *testing.T) { } } +type withCause struct { + msg string + cause error +} + +func (w *withCause) Error() string { return w.msg } +func (w *withCause) Cause() error { return w.cause } + +type customError struct { + message string +} + +func (e *customError) Error() string { + return e.message +} + +func TestSetException(t *testing.T) { + testCases := map[string]struct { + exception error + maxErrorDepth int + expected []Exception + }{ + "Single error without unwrap": { + exception: errors.New("simple error"), + maxErrorDepth: 1, + expected: []Exception{ + { + Value: "simple error", + Type: "*errors.errorString", + Stacktrace: &Stacktrace{Frames: []Frame{}}, + }, + }, + }, + "Nested errors with Unwrap": { + exception: fmt.Errorf("level 2: %w", fmt.Errorf("level 1: %w", errors.New("base error"))), + maxErrorDepth: 3, + expected: []Exception{ + { + Value: "base error", + Type: "*errors.errorString", + Mechanism: &Mechanism{ + Data: map[string]any{ + "is_exception_group": true, + "exception_id": 0, + }, + }, + }, + { + Value: "level 1: base error", + Type: "*fmt.wrapError", + Mechanism: &Mechanism{ + Data: map[string]any{ + "exception_id": 1, + "is_exception_group": true, + "parent_id": 0, + }, + }, + }, + { + Value: "level 2: level 1: base error", + Type: "*fmt.wrapError", + Stacktrace: &Stacktrace{Frames: []Frame{}}, + Mechanism: &Mechanism{ + Data: map[string]any{ + "exception_id": 2, + "is_exception_group": true, + "parent_id": 1, + }, + }, + }, + }, + }, + "Custom error types": { + exception: &customError{ + message: "custom error message", + }, + maxErrorDepth: 1, + expected: []Exception{ + { + Value: "custom error message", + Type: "*sentry.customError", + Stacktrace: &Stacktrace{Frames: []Frame{}}, + }, + }, + }, + "Combination of Unwrap and Cause": { + exception: fmt.Errorf("outer error: %w", &withCause{ + msg: "error with cause", + cause: errors.New("the cause"), + }), + maxErrorDepth: 3, + expected: []Exception{ + { + Value: "the cause", + Type: "*errors.errorString", + Mechanism: &Mechanism{ + Data: map[string]any{ + "is_exception_group": true, + "exception_id": 0, + }, + }, + }, + { + Value: "error with cause", + Type: "*sentry.withCause", + Mechanism: &Mechanism{ + Data: map[string]any{ + "exception_id": 1, + "is_exception_group": true, + "parent_id": 0, + }, + }, + }, + { + Value: "outer error: error with cause", + Type: "*fmt.wrapError", + Stacktrace: &Stacktrace{Frames: []Frame{}}, + Mechanism: &Mechanism{ + Data: map[string]any{ + "exception_id": 2, + "is_exception_group": true, + "parent_id": 1, + }, + }, + }, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + e := &Event{} + e.SetException(tc.exception, tc.maxErrorDepth) + + if len(e.Exception) != len(tc.expected) { + t.Fatalf("Expected %d exceptions, got %d", len(tc.expected), len(e.Exception)) + } + + for i, exp := range tc.expected { + if diff := cmp.Diff(exp, e.Exception[i]); diff != "" { + t.Errorf("Event mismatch (-want +got):\n%s", diff) + } + } + }) + } +} + func TestMechanismMarshalJSON(t *testing.T) { mechanism := &Mechanism{ Type: "some type",