diff --git a/CHANGELOG.md b/CHANGELOG.md index c3ab9e5b0..95462247a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,30 @@ ## 0.36.0 +The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.36.0. + ### Breaking Changes - Behavioral change for the `MaxBreadcrumbs` client option. Removed the hard limit of 100 breadcrumbs, allowing users to set a larger limit and also changed the default limit from 30 to 100 ([#1106](https://github.com/getsentry/sentry-go/pull/1106))) +- The changes to error handling ([#1075](https://github.com/getsentry/sentry-go/pull/1075)) will affect issue grouping. It is expected that any wrapped and complex errors will be grouped under a new issue group. + +### Features + +- Add support for improved issue grouping with enhanced error chain handling ([#1075](https://github.com/getsentry/sentry-go/pull/1075)) + + The SDK now provides better handling of complex error scenarios, particularly when dealing with multiple related errors or error chains. This feature automatically detects and properly structures errors created with Go's `errors.Join()` function and other multi-error patterns. + + ```go + // Multiple errors are now properly grouped and displayed in Sentry + err1 := errors.New("err1") + err2 := errors.New("err2") + combinedErr := errors.Join(err1, err2) + + // When captured, these will be shown as related exceptions in Sentry + sentry.CaptureException(combinedErr) + ``` + ## 0.35.3 The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.35.3. diff --git a/client.go b/client.go index cb0767ae5..346230223 100644 --- a/client.go +++ b/client.go @@ -28,7 +28,7 @@ const ( // is of little use when debugging production errors with Sentry. The Sentry UI // is not optimized for long chains either. The top-level error together with a // stack trace is often the most useful information. - maxErrorDepth = 10 + maxErrorDepth = 100 // defaultMaxSpans limits the default number of recorded spans per transaction. The limit is // meant to bound memory usage and prevent too large transaction events that diff --git a/client_test.go b/client_test.go index 3eda5f1c9..de9d43418 100644 --- a/client_test.go +++ b/client_test.go @@ -164,12 +164,15 @@ func TestCaptureException(t *testing.T) { err: pkgErrors.WithStack(&customErr{}), want: []Exception{ { - Type: "*sentry.customErr", - Value: "wat", + Type: "*sentry.customErr", + Value: "wat", + Stacktrace: nil, Mechanism: &Mechanism{ - Type: "generic", - ExceptionID: 0, - IsExceptionGroup: true, + Type: MechanismTypeChained, + ExceptionID: 1, + ParentID: Pointer(0), + Source: MechanismTypeUnwrap, + IsExceptionGroup: false, }, }, { @@ -177,10 +180,11 @@ func TestCaptureException(t *testing.T) { Value: "wat", Stacktrace: &Stacktrace{Frames: []Frame{}}, Mechanism: &Mechanism{ - Type: "generic", - ExceptionID: 1, - ParentID: Pointer(0), - IsExceptionGroup: true, + Type: MechanismTypeGeneric, + ExceptionID: 0, + ParentID: nil, + Source: "", + IsExceptionGroup: false, }, }, }, @@ -201,12 +205,15 @@ func TestCaptureException(t *testing.T) { err: &customErrWithCause{cause: &customErr{}}, want: []Exception{ { - Type: "*sentry.customErr", - Value: "wat", + Type: "*sentry.customErr", + Value: "wat", + Stacktrace: nil, Mechanism: &Mechanism{ - Type: "generic", - ExceptionID: 0, - IsExceptionGroup: true, + Type: MechanismTypeChained, + ExceptionID: 1, + ParentID: Pointer(0), + Source: "cause", + IsExceptionGroup: false, }, }, { @@ -214,10 +221,11 @@ func TestCaptureException(t *testing.T) { Value: "err", Stacktrace: &Stacktrace{Frames: []Frame{}}, Mechanism: &Mechanism{ - Type: "generic", - ExceptionID: 1, - ParentID: Pointer(0), - IsExceptionGroup: true, + Type: MechanismTypeGeneric, + ExceptionID: 0, + ParentID: nil, + Source: "", + IsExceptionGroup: false, }, }, }, @@ -227,12 +235,15 @@ func TestCaptureException(t *testing.T) { err: wrappedError{original: errors.New("original")}, want: []Exception{ { - Type: "*errors.errorString", - Value: "original", + Type: "*errors.errorString", + Value: "original", + Stacktrace: nil, Mechanism: &Mechanism{ - Type: "generic", - ExceptionID: 0, - IsExceptionGroup: true, + Type: MechanismTypeChained, + ExceptionID: 1, + ParentID: Pointer(0), + Source: MechanismTypeUnwrap, + IsExceptionGroup: false, }, }, { @@ -240,10 +251,11 @@ func TestCaptureException(t *testing.T) { Value: "wrapped: original", Stacktrace: &Stacktrace{Frames: []Frame{}}, Mechanism: &Mechanism{ - Type: "generic", - ExceptionID: 1, - ParentID: Pointer(0), - IsExceptionGroup: true, + Type: MechanismTypeGeneric, + ExceptionID: 0, + ParentID: nil, + Source: "", + IsExceptionGroup: false, }, }, }, diff --git a/exception.go b/exception.go new file mode 100644 index 000000000..46e8ba868 --- /dev/null +++ b/exception.go @@ -0,0 +1,103 @@ +package sentry + +import ( + "fmt" + "reflect" + "slices" +) + +const ( + MechanismTypeGeneric string = "generic" + MechanismTypeChained string = "chained" + MechanismTypeUnwrap string = "unwrap" + MechanismSourceCause string = "cause" +) + +func convertErrorToExceptions(err error, maxErrorDepth int) []Exception { + var exceptions []Exception + visited := make(map[error]bool) + convertErrorDFS(err, &exceptions, nil, "", visited, maxErrorDepth, 0) + + // mechanism type is used for debugging purposes, but since we can't really distinguish the origin of who invoked + // captureException, we set it to nil if the error is not chained. + if len(exceptions) == 1 { + exceptions[0].Mechanism = nil + } + + slices.Reverse(exceptions) + + // Add a trace of the current stack to the top level(outermost) error in a chain if + // it doesn't have a stack trace yet. + // We only add to the most recent error to avoid duplication and because the + // current stack is most likely unrelated to errors deeper in the chain. + if len(exceptions) > 0 && exceptions[len(exceptions)-1].Stacktrace == nil { + exceptions[len(exceptions)-1].Stacktrace = NewStacktrace() + } + + return exceptions +} + +func convertErrorDFS(err error, exceptions *[]Exception, parentID *int, source string, visited map[error]bool, maxErrorDepth int, currentDepth int) { + if err == nil { + return + } + + if visited[err] { + return + } + visited[err] = true + + _, isExceptionGroup := err.(interface{ Unwrap() []error }) + + exception := Exception{ + Value: err.Error(), + Type: reflect.TypeOf(err).String(), + Stacktrace: ExtractStacktrace(err), + } + + currentID := len(*exceptions) + + var mechanismType string + + if parentID == nil { + mechanismType = MechanismTypeGeneric + source = "" + } else { + mechanismType = MechanismTypeChained + } + + exception.Mechanism = &Mechanism{ + Type: mechanismType, + ExceptionID: currentID, + ParentID: parentID, + Source: source, + IsExceptionGroup: isExceptionGroup, + } + + *exceptions = append(*exceptions, exception) + + if maxErrorDepth >= 0 && currentDepth >= maxErrorDepth { + return + } + + switch v := err.(type) { + case interface{ Unwrap() []error }: + unwrapped := v.Unwrap() + for i := range unwrapped { + if unwrapped[i] != nil { + childSource := fmt.Sprintf("errors[%d]", i) + convertErrorDFS(unwrapped[i], exceptions, ¤tID, childSource, visited, maxErrorDepth, currentDepth+1) + } + } + case interface{ Unwrap() error }: + unwrapped := v.Unwrap() + if unwrapped != nil { + convertErrorDFS(unwrapped, exceptions, ¤tID, MechanismTypeUnwrap, visited, maxErrorDepth, currentDepth+1) + } + case interface{ Cause() error }: + cause := v.Cause() + if cause != nil { + convertErrorDFS(cause, exceptions, ¤tID, MechanismSourceCause, visited, maxErrorDepth, currentDepth+1) + } + } +} diff --git a/exception_test.go b/exception_test.go new file mode 100644 index 000000000..e8970f9ff --- /dev/null +++ b/exception_test.go @@ -0,0 +1,379 @@ +package sentry + +import ( + "errors" + "fmt" + "testing" + "time" + + "github.com/google/go-cmp/cmp" +) + +func TestConvertErrorToExceptions(t *testing.T) { + tests := []struct { + name string + err error + expected []Exception + }{ + { + name: "nil error", + err: nil, + expected: nil, + }, + { + name: "single error", + err: errors.New("single error"), + expected: []Exception{ + { + Value: "single error", + Type: "*errors.errorString", + Stacktrace: &Stacktrace{Frames: []Frame{}}, + }, + }, + }, + { + name: "errors.Join with multiple errors", + err: errors.Join(errors.New("error A"), errors.New("error B")), + expected: []Exception{ + { + Value: "error B", + Type: "*errors.errorString", + Stacktrace: nil, + Mechanism: &Mechanism{ + Type: "chained", + Source: "errors[1]", + ExceptionID: 2, + ParentID: Pointer(0), + IsExceptionGroup: false, + }, + }, + { + Value: "error A", + Type: "*errors.errorString", + Mechanism: &Mechanism{ + Type: "chained", + Source: "errors[0]", + ExceptionID: 1, + ParentID: Pointer(0), + IsExceptionGroup: false, + }, + }, + { + Value: "error A\nerror B", + Type: "*errors.joinError", + Stacktrace: &Stacktrace{Frames: []Frame{}}, + Mechanism: &Mechanism{ + Type: "generic", + Source: "", + ExceptionID: 0, + ParentID: nil, + IsExceptionGroup: true, + }, + }, + }, + }, + { + name: "nested wrapped error with errors.Join", + err: fmt.Errorf("wrapper: %w", errors.Join(errors.New("error A"), errors.New("error B"))), + expected: []Exception{ + { + Value: "error B", + Type: "*errors.errorString", + Stacktrace: nil, + Mechanism: &Mechanism{ + Type: "chained", + Source: "errors[1]", + ExceptionID: 3, + ParentID: Pointer(1), + IsExceptionGroup: false, + }, + }, + { + Value: "error A", + Type: "*errors.errorString", + Mechanism: &Mechanism{ + Type: "chained", + Source: "errors[0]", + ExceptionID: 2, + ParentID: Pointer(1), + IsExceptionGroup: false, + }, + }, + { + Value: "error A\nerror B", + Type: "*errors.joinError", + Mechanism: &Mechanism{ + Type: "chained", + Source: MechanismTypeUnwrap, + ExceptionID: 1, + ParentID: Pointer(0), + IsExceptionGroup: true, + }, + }, + { + Value: "wrapper: error A\nerror B", + Type: "*fmt.wrapError", + Stacktrace: &Stacktrace{Frames: []Frame{}}, + Mechanism: &Mechanism{ + Type: "generic", + Source: "", + ExceptionID: 0, + ParentID: nil, + IsExceptionGroup: false, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := convertErrorToExceptions(tt.err, -1) + + if tt.expected == nil { + if result != nil { + t.Errorf("expected nil result, got %+v", result) + } + return + } + + if diff := cmp.Diff(tt.expected, result); diff != "" { + t.Errorf("Exception mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// AggregateError represents multiple errors occurring together +// This simulates JavaScript's AggregateError for testing purposes. +type AggregateError struct { + Message string + Errors []error +} + +func (e *AggregateError) Error() string { + if e.Message != "" { + return e.Message + } + return "Multiple errors occurred" +} + +func (e *AggregateError) Unwrap() []error { + return e.Errors +} + +func TestExceptionGroupsWithAggregateError(t *testing.T) { + tests := []struct { + name string + err error + expected []Exception + }{ + { + name: "AggregateError with custom message", + err: &AggregateError{ + Message: "Request failed due to multiple errors", + Errors: []error{ + errors.New("network timeout"), + errors.New("authentication failed"), + errors.New("rate limit exceeded"), + }, + }, + expected: []Exception{ + { + Value: "rate limit exceeded", + Type: "*errors.errorString", + Stacktrace: nil, + Mechanism: &Mechanism{ + Type: "chained", + Source: "errors[2]", + ExceptionID: 3, + ParentID: Pointer(0), + IsExceptionGroup: false, + }, + }, + { + Value: "authentication failed", + Type: "*errors.errorString", + Mechanism: &Mechanism{ + Type: "chained", + Source: "errors[1]", + ExceptionID: 2, + ParentID: Pointer(0), + IsExceptionGroup: false, + }, + }, + { + Value: "network timeout", + Type: "*errors.errorString", + Mechanism: &Mechanism{ + Type: "chained", + Source: "errors[0]", + ExceptionID: 1, + ParentID: Pointer(0), + IsExceptionGroup: false, + }, + }, + { + Value: "Request failed due to multiple errors", + Type: "*sentry.AggregateError", + Stacktrace: &Stacktrace{Frames: []Frame{}}, + Mechanism: &Mechanism{ + Type: "generic", + Source: "", + ExceptionID: 0, + ParentID: nil, + IsExceptionGroup: true, + }, + }, + }, + }, + { + name: "Nested AggregateError with wrapper", + err: fmt.Errorf("operation failed: %w", &AggregateError{ + Message: "Multiple validation errors", + Errors: []error{ + errors.New("field 'email' is required"), + errors.New("field 'password' is too short"), + }, + }), + expected: []Exception{ + { + Value: "field 'password' is too short", + Type: "*errors.errorString", + Stacktrace: nil, + Mechanism: &Mechanism{ + Type: "chained", + Source: "errors[1]", + ExceptionID: 3, + ParentID: Pointer(1), + IsExceptionGroup: false, + }, + }, + { + Value: "field 'email' is required", + Type: "*errors.errorString", + Mechanism: &Mechanism{ + Type: "chained", + Source: "errors[0]", + ExceptionID: 2, + ParentID: Pointer(1), + IsExceptionGroup: false, + }, + }, + { + Value: "Multiple validation errors", + Type: "*sentry.AggregateError", + Mechanism: &Mechanism{ + Type: "chained", + Source: MechanismTypeUnwrap, + ExceptionID: 1, + ParentID: Pointer(0), + IsExceptionGroup: true, + }, + }, + { + Value: "operation failed: Multiple validation errors", + Type: "*fmt.wrapError", + Stacktrace: &Stacktrace{Frames: []Frame{}}, + Mechanism: &Mechanism{ + Type: "generic", + Source: "", + ExceptionID: 0, + ParentID: nil, + IsExceptionGroup: false, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := &Event{} + event.SetException(tt.err, 10) // Use high max depth + + if diff := cmp.Diff(tt.expected, event.Exception); diff != "" { + t.Errorf("Exception mismatch (-want +got):\n%s", diff) + } + }) + } +} + +type CircularError struct { + Message string + Next error +} + +func (e *CircularError) Error() string { + return e.Message +} + +func (e *CircularError) Unwrap() error { + return e.Next +} + +func TestCircularReferenceProtection(t *testing.T) { + tests := []struct { + name string + setupError func() error + description string + maxDepth int + }{ + { + name: "self-reference", + setupError: func() error { + err := &CircularError{Message: "self-referencing error"} + err.Next = err + return err + }, + description: "Error that directly references itself", + maxDepth: 1, + }, + { + name: "chain-loop", + setupError: func() error { + err1 := &CircularError{Message: "error A"} + err2 := &CircularError{Message: "error B"} + err1.Next = err2 + err2.Next = err1 // Creates A -> B -> A cycle + return err1 + }, + description: "Two errors that reference each other in a cycle", + maxDepth: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.setupError() + + start := time.Now() + exceptions := convertErrorToExceptions(err, -1) + duration := time.Since(start) + + if duration > 100*time.Millisecond { + t.Errorf("convertErrorToExceptions took too long: %v, possible infinite recursion", duration) + } + + if len(exceptions) == 0 { + t.Error("Expected at least one exception, got none") + return + } + + if len(exceptions) != tt.maxDepth { + t.Errorf("Expected exactly %d exceptions (before cycle detection), got %d", tt.maxDepth, len(exceptions)) + } + + for i, exception := range exceptions { + if exception.Value == "" { + t.Errorf("Exception %d has empty value", i) + } + if exception.Type == "" { + t.Errorf("Exception %d has empty type", i) + } + } + + t.Logf("✓ Successfully handled %s: got %d exceptions in %v", tt.description, len(exceptions), duration) + }) + } +} diff --git a/interfaces.go b/interfaces.go index 2cec1cca9..33d569977 100644 --- a/interfaces.go +++ b/interfaces.go @@ -3,12 +3,9 @@ package sentry import ( "context" "encoding/json" - "errors" "fmt" "net" "net/http" - "reflect" - "slices" "strings" "time" @@ -296,7 +293,7 @@ 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"` + Type string `json:"type"` Description string `json:"description,omitempty"` HelpLink string `json:"help_link,omitempty"` Source string `json:"source,omitempty"` @@ -425,64 +422,12 @@ func (e *Event) SetException(exception error, maxErrorDepth int) { return } - err := exception - - for i := 0; err != nil && (i < maxErrorDepth || maxErrorDepth == -1); 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), - }) - - // 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 - // it doesn't have a stack trace yet. - // We only add to the most recent error to avoid duplication and because the - // current stack is most likely unrelated to errors deeper in the chain. - if e.Exception[0].Stacktrace == nil { - e.Exception[0].Stacktrace = NewStacktrace() - } - - if len(e.Exception) <= 1 { + exceptions := convertErrorToExceptions(exception, maxErrorDepth) + if len(exceptions) == 0 { return } - // event.Exception should be sorted such that the most recent error is last. - slices.Reverse(e.Exception) - - for i := range e.Exception { - e.Exception[i].Mechanism = &Mechanism{ - IsExceptionGroup: true, - ExceptionID: i, - Type: "generic", - } - if i == 0 { - continue - } - e.Exception[i].Mechanism.ParentID = Pointer(i - 1) - } + e.Exception = exceptions } // TODO: Event.Contexts map[string]interface{} => map[string]EventContext, diff --git a/interfaces_test.go b/interfaces_test.go index c9eeb2a49..a9e31e54e 100644 --- a/interfaces_test.go +++ b/interfaces_test.go @@ -248,6 +248,7 @@ func TestSetException(t *testing.T) { Value: "simple error", Type: "*errors.errorString", Stacktrace: &Stacktrace{Frames: []Frame{}}, + Mechanism: nil, }, }, }, @@ -256,22 +257,26 @@ func TestSetException(t *testing.T) { maxErrorDepth: 3, expected: []Exception{ { - Value: "base error", - Type: "*errors.errorString", + Value: "base error", + Type: "*errors.errorString", + Stacktrace: nil, Mechanism: &Mechanism{ - Type: "generic", - ExceptionID: 0, - IsExceptionGroup: true, + Type: "chained", + Source: MechanismTypeUnwrap, + ExceptionID: 2, + ParentID: Pointer(1), + IsExceptionGroup: false, }, }, { Value: "level 1: base error", Type: "*fmt.wrapError", Mechanism: &Mechanism{ - Type: "generic", + Type: "chained", + Source: MechanismTypeUnwrap, ExceptionID: 1, ParentID: Pointer(0), - IsExceptionGroup: true, + IsExceptionGroup: false, }, }, { @@ -280,9 +285,10 @@ func TestSetException(t *testing.T) { Stacktrace: &Stacktrace{Frames: []Frame{}}, Mechanism: &Mechanism{ Type: "generic", - ExceptionID: 2, - ParentID: Pointer(1), - IsExceptionGroup: true, + Source: "", + ExceptionID: 0, + ParentID: nil, + IsExceptionGroup: false, }, }, }, @@ -297,6 +303,7 @@ func TestSetException(t *testing.T) { Value: "custom error message", Type: "*sentry.customError", Stacktrace: &Stacktrace{Frames: []Frame{}}, + Mechanism: nil, }, }, }, @@ -308,22 +315,26 @@ func TestSetException(t *testing.T) { maxErrorDepth: 3, expected: []Exception{ { - Value: "the cause", - Type: "*errors.errorString", + Value: "the cause", + Type: "*errors.errorString", + Stacktrace: nil, Mechanism: &Mechanism{ - Type: "generic", - ExceptionID: 0, - IsExceptionGroup: true, + Type: "chained", + Source: MechanismSourceCause, + ExceptionID: 2, + ParentID: Pointer(1), + IsExceptionGroup: false, }, }, { Value: "error with cause", Type: "*sentry.withCause", Mechanism: &Mechanism{ - Type: "generic", + Type: "chained", + Source: MechanismTypeUnwrap, ExceptionID: 1, ParentID: Pointer(0), - IsExceptionGroup: true, + IsExceptionGroup: false, }, }, { @@ -332,11 +343,116 @@ func TestSetException(t *testing.T) { Stacktrace: &Stacktrace{Frames: []Frame{}}, Mechanism: &Mechanism{ Type: "generic", + Source: "", + ExceptionID: 0, + ParentID: nil, + IsExceptionGroup: false, + }, + }, + }, + }, + "errors.Join with multiple errors": { + exception: errors.Join(errors.New("error 1"), errors.New("error 2"), errors.New("error 3")), + maxErrorDepth: 5, + expected: []Exception{ + { + Value: "error 3", + Type: "*errors.errorString", + Stacktrace: nil, + Mechanism: &Mechanism{ + Type: "chained", + Source: "errors[2]", + ExceptionID: 3, + ParentID: Pointer(0), + IsExceptionGroup: false, + }, + }, + { + Value: "error 2", + Type: "*errors.errorString", + Mechanism: &Mechanism{ + Type: "chained", + Source: "errors[1]", + ExceptionID: 2, + ParentID: Pointer(0), + IsExceptionGroup: false, + }, + }, + { + Value: "error 1", + Type: "*errors.errorString", + Mechanism: &Mechanism{ + Type: "chained", + Source: "errors[0]", + ExceptionID: 1, + ParentID: Pointer(0), + IsExceptionGroup: false, + }, + }, + { + Value: "error 1\nerror 2\nerror 3", + Type: "*errors.joinError", + Stacktrace: &Stacktrace{Frames: []Frame{}}, + Mechanism: &Mechanism{ + Type: "generic", + Source: "", + ExceptionID: 0, + ParentID: nil, + IsExceptionGroup: true, + }, + }, + }, + }, + "Nested errors.Join with fmt.Errorf": { + exception: fmt.Errorf("wrapper: %w", errors.Join(errors.New("error A"), errors.New("error B"))), + maxErrorDepth: 5, + expected: []Exception{ + { + Value: "error B", + Type: "*errors.errorString", + Stacktrace: nil, + Mechanism: &Mechanism{ + Type: "chained", + Source: "errors[1]", + ExceptionID: 3, + ParentID: Pointer(1), + IsExceptionGroup: false, + }, + }, + { + Value: "error A", + Type: "*errors.errorString", + Mechanism: &Mechanism{ + Type: "chained", + Source: "errors[0]", ExceptionID: 2, ParentID: Pointer(1), + IsExceptionGroup: false, + }, + }, + { + Value: "error A\nerror B", + Type: "*errors.joinError", + Mechanism: &Mechanism{ + Type: "chained", + Source: MechanismTypeUnwrap, + ExceptionID: 1, + ParentID: Pointer(0), IsExceptionGroup: true, }, }, + { + Value: "wrapper: error A\nerror B", + Type: "*fmt.wrapError", + Stacktrace: &Stacktrace{Frames: []Frame{}}, + Mechanism: &Mechanism{ + Type: "generic", + Source: "", + ExceptionID: 0, + ParentID: nil, + IsExceptionGroup: false, + }, + }, }, }, } diff --git a/logrus/logrusentry.go b/logrus/logrusentry.go index 50f85d3f4..995df3a45 100644 --- a/logrus/logrusentry.go +++ b/logrus/logrusentry.go @@ -22,6 +22,8 @@ const ( sdkIdentifier = "sentry.go.logrus" // the name of the logger. name = "logrus" + + maxErrorDepth = 100 ) // These default log field keys are used to pass specific metadata in a way that @@ -183,7 +185,14 @@ func (h *eventHook) entryToEvent(l *logrus.Entry) *sentry.Event { if err, ok := s.Extra[logrus.ErrorKey].(error); ok { delete(s.Extra, logrus.ErrorKey) - s.SetException(err, -1) + + errorDepth := maxErrorDepth + if hub := h.hubProvider(); hub != nil { + if client := hub.Client(); client != nil { + errorDepth = client.Options().MaxErrorDepth + } + } + s.SetException(err, errorDepth) } key = h.key(FieldUser) diff --git a/logrus/logrusentry_test.go b/logrus/logrusentry_test.go index f26122d56..d1822658b 100644 --- a/logrus/logrusentry_test.go +++ b/logrus/logrusentry_test.go @@ -373,12 +373,15 @@ func TestEventHook_entryToEvent(t *testing.T) { Extra: map[string]any{}, Exception: []sentry.Exception{ { - Type: "*errors.errorString", - Value: "failure", + Type: "*errors.errorString", + Value: "failure", + Stacktrace: nil, Mechanism: &sentry.Mechanism{ - ExceptionID: 0, - IsExceptionGroup: true, - Type: "generic", + ExceptionID: 1, + IsExceptionGroup: false, + ParentID: sentry.Pointer(0), + Type: sentry.MechanismTypeChained, + Source: sentry.MechanismTypeUnwrap, }, }, { @@ -388,10 +391,11 @@ func TestEventHook_entryToEvent(t *testing.T) { Frames: []sentry.Frame{}, }, Mechanism: &sentry.Mechanism{ - ExceptionID: 1, - IsExceptionGroup: true, - ParentID: sentry.Pointer(0), - Type: "generic", + ExceptionID: 0, + IsExceptionGroup: false, + ParentID: nil, + Type: sentry.MechanismTypeGeneric, + Source: "", }, }, }, diff --git a/slog/converter.go b/slog/converter.go index 8c8104044..b5340c26f 100644 --- a/slog/converter.go +++ b/slog/converter.go @@ -13,6 +13,8 @@ import ( "github.com/getsentry/sentry-go/internal/debuglog" ) +const maxErrorDepth = 100 + var ( sourceKey = "source" errorKeys = map[string]struct{}{ @@ -24,7 +26,7 @@ var ( type Converter func(addSource bool, replaceAttr func(groups []string, a slog.Attr) slog.Attr, loggerAttr []slog.Attr, groups []string, record *slog.Record, hub *sentry.Hub) *sentry.Event -func DefaultConverter(addSource bool, replaceAttr func(groups []string, a slog.Attr) slog.Attr, loggerAttr []slog.Attr, groups []string, record *slog.Record, _ *sentry.Hub) *sentry.Event { +func DefaultConverter(addSource bool, replaceAttr func(groups []string, a slog.Attr) slog.Attr, loggerAttr []slog.Attr, groups []string, record *slog.Record, hub *sentry.Hub) *sentry.Event { // aggregate all attributes attrs := appendRecordAttrsToAttrs(loggerAttr, groups, record) @@ -42,7 +44,14 @@ func DefaultConverter(addSource bool, replaceAttr func(groups []string, a slog.A event.Level = LogLevels[record.Level] event.Message = record.Message event.Logger = name - event.SetException(err, 10) + + errorDepth := maxErrorDepth + if hub != nil { + if client := hub.Client(); client != nil { + errorDepth = client.Options().MaxErrorDepth + } + } + event.SetException(err, errorDepth) for i := range attrs { attrToSentryEvent(attrs[i], event)