diff --git a/logging.go b/logging.go index c55d821..ec14bb5 100644 --- a/logging.go +++ b/logging.go @@ -262,7 +262,7 @@ func getLogrusLogLevel(level string) logrus.Level { return loglevel } -func (s *sentryHook) Fire(entry *logrus.Entry) error { +func (s *sentryHook) buildEvent(entry *logrus.Entry) *sentry.Event { var notifyErr error var origErr error switch err := entry.Data[logrus.ErrorKey].(type) { @@ -284,17 +284,23 @@ func (s *sentryHook) Fire(entry *logrus.Entry) error { event.Level = sentry.LevelFatal } event.Message = notifyErr.Error() - var stacktrace *sentry.Stacktrace - var errSt *errorWithStacktrace - if errors.As(notifyErr, &errSt) { - stacktrace = errSt.stacktrace - } - event.Exception = []sentry.Exception{{ - Type: errorClass(origErr), - Value: notifyErr.Error(), - Stacktrace: stacktrace, - }} + // SetException walks the error chain, calls ExtractStacktrace on each + // link, and falls back to a fresh NewStacktrace() on the outermost + // exception when none is found — so events always carry frames. + event.SetException(notifyErr, -1) + if len(event.Exception) > 0 { + outermost := &event.Exception[len(event.Exception)-1] + // Group on the underlying error type, not the fmt.Errorf wrapper + // the hook itself introduced above. + outermost.Type = errorClass(origErr) + // Prefer a stacktrace captured at the error's creation site over + // the fallback captured here inside the hook. + var errSt *errorWithStacktrace + if errors.As(notifyErr, &errSt) && errSt.stacktrace != nil { + outermost.Stacktrace = errSt.stacktrace + } + } extra := make(sentry.Context) for key, val := range entry.Data { @@ -305,8 +311,11 @@ func (s *sentryHook) Fire(entry *logrus.Entry) error { if len(extra) > 0 { event.Contexts["extra"] = extra } + return event +} - sentry.CaptureEvent(event) +func (s *sentryHook) Fire(entry *logrus.Entry) error { + sentry.CaptureEvent(s.buildEvent(entry)) if entry.Level == logrus.FatalLevel || entry.Level == logrus.PanicLevel { sentry.Flush(2 * time.Second) diff --git a/logging_test.go b/logging_test.go index b2fb9d6..550bd08 100644 --- a/logging_test.go +++ b/logging_test.go @@ -9,6 +9,7 @@ import ( "sync" "testing" + "github.com/getsentry/sentry-go" "github.com/nsqio/go-nsq" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" @@ -185,6 +186,97 @@ func TestSentryHookFire(t *testing.T) { }) } +func TestSentryHookBuildEvent(t *testing.T) { + hook := &sentryHook{} + + t.Run("plain error gets a fallback stacktrace", func(t *testing.T) { + event := hook.buildEvent(&logrus.Entry{ + Level: logrus.ErrorLevel, + Message: "context", + Data: logrus.Fields{logrus.ErrorKey: fmt.Errorf("plain")}, + }) + assert.NotEmpty(t, event.Exception) + outer := event.Exception[len(event.Exception)-1] + assert.NotNil(t, outer.Stacktrace) + assert.NotEmpty(t, outer.Stacktrace.Frames) + }) + + t.Run("entry without error key still gets a stacktrace", func(t *testing.T) { + event := hook.buildEvent(&logrus.Entry{ + Level: logrus.ErrorLevel, + Message: "no error attached", + Data: logrus.Fields{}, + }) + assert.NotEmpty(t, event.Exception) + outer := event.Exception[len(event.Exception)-1] + assert.NotNil(t, outer.Stacktrace) + assert.NotEmpty(t, outer.Stacktrace.Frames) + }) + + t.Run("ErrorWithStacktrace's captured frames win over fallback", func(t *testing.T) { + wrapped := ErrorWithStacktrace(fmt.Errorf("inner")) + var errSt *errorWithStacktrace + assert.True(t, errors.As(wrapped, &errSt)) + + event := hook.buildEvent(&logrus.Entry{ + Level: logrus.ErrorLevel, + Message: "ctx", + Data: logrus.Fields{logrus.ErrorKey: wrapped}, + }) + outer := event.Exception[len(event.Exception)-1] + assert.Same(t, errSt.stacktrace, outer.Stacktrace) + }) + + t.Run("outermost exception type uses errorClass for grouping", func(t *testing.T) { + base := &customError{msg: "boom"} + event := hook.buildEvent(&logrus.Entry{ + Level: logrus.ErrorLevel, + Message: "ctx", + Data: logrus.Fields{logrus.ErrorKey: base}, + }) + outer := event.Exception[len(event.Exception)-1] + assert.Equal(t, "*logging.customError", outer.Type) + }) + + t.Run("wrapped chain produces multiple exceptions", func(t *testing.T) { + base := &customError{msg: "boom"} + wrapped := fmt.Errorf("middle: %w", base) + event := hook.buildEvent(&logrus.Entry{ + Level: logrus.ErrorLevel, + Message: "outer", + Data: logrus.Fields{logrus.ErrorKey: wrapped}, + }) + // hook's fmt.Errorf wrapping + middle wrap + base + assert.Len(t, event.Exception, 3) + assert.Equal(t, "*logging.customError", event.Exception[len(event.Exception)-1].Type) + }) + + t.Run("fatal level maps to sentry fatal", func(t *testing.T) { + event := hook.buildEvent(&logrus.Entry{ + Level: logrus.FatalLevel, + Message: "dead", + Data: logrus.Fields{logrus.ErrorKey: fmt.Errorf("err")}, + }) + assert.Equal(t, sentry.LevelFatal, event.Level) + }) + + t.Run("non-error fields land in extra context", func(t *testing.T) { + event := hook.buildEvent(&logrus.Entry{ + Level: logrus.ErrorLevel, + Message: "msg", + Data: logrus.Fields{ + logrus.ErrorKey: fmt.Errorf("err"), + "user_id": 42, + }, + }) + extra, ok := event.Contexts["extra"] + assert.True(t, ok) + assert.Equal(t, 42, extra["user_id"]) + _, hasErr := extra[logrus.ErrorKey] + assert.False(t, hasErr) + }) +} + func TestSentryHookLevels(t *testing.T) { hook := &sentryHook{} levels := hook.Levels()