diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d65074 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.crush \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index f5c63e6..d3b14cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Overview -Shared logging library for Fishbrain's Go services. Single-package Go module (`package logging`) that wraps [logrus](https://github.com/sirupsen/logrus) with Bugsnag error reporting, Sentry error reporting, Datadog trace correlation, and NSQ log-level bridging. +Shared logging library for Fishbrain's Go services. Single-package Go module (`package logging`) that wraps [logrus](https://github.com/sirupsen/logrus) with Sentry error reporting, Datadog trace correlation, and NSQ log-level bridging. **Module path**: `github.com/fishbrain/logging-go` @@ -18,7 +18,7 @@ There is no linter, formatter, or Makefile configured. CI (`go.yml`) runs `go bu ## Project Structure ``` -logging.go # All library code — types, logger init, entry helpers, Bugsnag/Sentry hooks +logging.go # All library code — types, logger init, entry helpers, Sentry hook logging_test.go # All tests go.mod / go.sum # Module definition (Go 1.24+, toolchain 1.26) .tool-versions # asdf version pinning (go 1.26.0) @@ -37,17 +37,14 @@ This is a **single-file library** — everything lives in `logging.go` and `logg - **`Logger`** — wraps `*logrus.Logger`. Provides `WithField`, `WithError`, `WithDDTrace`, `NewEntry`, and `NSQLogger`. - **`Entry`** — wraps `*logrus.Entry`. Provides domain-specific field helpers (`WithUser`, `WithEvent`, `WithChannel`, `WithDuration`, etc.) that return `*Entry` for chaining. - **`NSQLogger`** — adaptor that implements `Output(int, string) error` so it can be passed to `nsq.SetLogger`. -- **`bugsnagHook`** — logrus hook that fires on Error/Fatal/Panic levels, forwarding to Bugsnag with metadata. - **`sentryHook`** — logrus hook that fires on Error/Fatal/Panic levels, forwarding to Sentry with metadata and extra fields. ### Initialization flow ``` Init(config) → - 1. bugsnag.Configure(...) — sets up Bugsnag client - 2. bugsnag.OnBeforeNotify(...) — unwraps *fmt.wrapError to get real error class - 3. sentry.Init(...) — sets up Sentry client (if SentryDSN is set and environment matches ErrorNotifyReleaseStages) - 4. Log = new(true, withSentry, config) — creates Logger with Bugsnag and optionally Sentry hooks attached + 1. sentry.Init(...) — sets up Sentry client (if SentryDSN is set and environment matches ErrorNotifyReleaseStages) + 2. Log = new(withSentry, config) — creates Logger with optionally Sentry hook attached ``` ## Key Dependencies @@ -55,7 +52,6 @@ Init(config) → | Dependency | Purpose | |---|---| | `github.com/sirupsen/logrus` | Structured logging (JSON formatter) | -| `github.com/bugsnag/bugsnag-go/v2` | Error reporting to Bugsnag | | `github.com/DataDog/dd-trace-go/v2` | Datadog APM trace/span ID injection | | `github.com/getsentry/sentry-go` | Error reporting to Sentry | | `github.com/nsqio/go-nsq` | NSQ message queue log-level bridging | @@ -73,10 +69,6 @@ Log.WithDDTrace(ctx).WithUser(userID).WithDuration(d).Info("processed request") When adding new field helpers, follow this pattern: method on `*Entry`, return `*Entry`, delegate to `e.WithField(...)`. -### Error wrapping - -Errors passed to `WithError` are wrapped with `bugsnag_errors.New(err, 1)` to capture stack traces. The `1` parameter controls stack frame skipping. The standalone `Errorf` and `ErrorWithStacktrace` functions also use this pattern. - ### JSON log output Logrus is configured with `JSONFormatter` and custom field mapping: @@ -102,18 +94,16 @@ The `LogLevel` config string must be uppercase: `"ERROR"`, `"WARNING"`, `"INFO"` - **Concurrency test**: `TestConcurrentUseOfEntry` verifies entries are safe for concurrent use across goroutines - **Table-driven tests**: `TestGetLogrusLogLevel` uses a table-driven approach with a package-level test data slice - **Sentry hook tests**: `TestSentryHookFire`, `TestSentryHookLevels`, `TestNewWithSentry`, and `TestNewWithoutSentry` cover the Sentry hook and its integration into the logger -- **Release-stage gating tests**: `TestShouldNotify` verifies the `shouldNotify` helper used for conditional Sentry/Bugsnag activation +- **Release-stage gating tests**: `TestShouldNotify` verifies the `shouldNotify` helper used for conditional Sentry activation ## Gotchas 1. **No CI test step**: The GitHub Actions workflow builds but does not run tests. Running `go test ./...` locally is essential before pushing. 2. **Singleton guard is not sync.Once**: `Init` uses `if nil == Log` — safe for single-goroutine init, but not for concurrent callers. In practice this is fine since `Init` is called once at service startup. 3. **`ioutil.ReadAll` in tests**: Tests use the deprecated `io/ioutil` package. New code should use `io.ReadAll` instead. -4. **Bugsnag error unwrapping limit**: The `OnBeforeNotify` handler unwraps `*fmt.wrapError` chains up to 11 levels deep, then logs and stops. -5. **`logrus.ErrorKey` is mutated globally**: `new()` sets `logrus.ErrorKey = "error.message"` as a side effect — this affects all logrus loggers in the process, not just this one. -6. **Reversed nil check style**: The codebase uses Yoda conditions (`nil == Log`) in the `Init` function. -7. **`BugsnagNotifyReleaseStages` renamed**: The config field was renamed to `ErrorNotifyReleaseStages` and is now shared between Bugsnag and Sentry for release-stage gating. -8. **Sentry is conditional**: Sentry is only initialized when `SentryDSN` is non-empty and the current `Environment` is in `ErrorNotifyReleaseStages`. If `sentry.Init` fails, it logs to stderr and proceeds without the Sentry hook. +4. **`logrus.ErrorKey` is mutated globally**: `new()` sets `logrus.ErrorKey = "error.message"` as a side effect — this affects all logrus loggers in the process, not just this one. +5. **Reversed nil check style**: The codebase uses Yoda conditions (`nil == Log`) in the `Init` function. +6. **Sentry is conditional**: Sentry is only initialized when `SentryDSN` is non-empty and the current `Environment` is in `ErrorNotifyReleaseStages`. If `sentry.Init` fails, it logs to stderr and proceeds without the Sentry hook. ## Releasing diff --git a/go.mod b/go.mod index f7c4e10..37e3591 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,7 @@ go 1.25.0 toolchain go1.26.3 require ( - github.com/DataDog/dd-trace-go/v2 v2.8.1 - github.com/bugsnag/bugsnag-go/v2 v2.6.4 + github.com/DataDog/dd-trace-go/v2 v2.6.0 github.com/getsentry/sentry-go v0.43.0 github.com/nsqio/go-nsq v1.1.0 github.com/sirupsen/logrus v1.9.4 @@ -35,7 +34,6 @@ require ( github.com/DataDog/go-tuf v1.1.1-0.5.2 // indirect github.com/DataDog/sketches-go v1.4.8 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/bugsnag/panicwrap v1.3.4 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 // indirect diff --git a/go.sum b/go.sum index da7dc42..f46e18f 100644 --- a/go.sum +++ b/go.sum @@ -75,12 +75,6 @@ github.com/DataDog/sketches-go v1.4.8/go.mod h1:a/wjRUqzqtGS8qRHRPDCs4EAQfmvPDZG github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow= -github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q= -github.com/bugsnag/bugsnag-go/v2 v2.6.4 h1:9LHfaCsTtMOr2/2MxacdgUEaQe9ejtVeopQtQ827nKo= -github.com/bugsnag/bugsnag-go/v2 v2.6.4/go.mod h1:S9njhE7l6XCiKycOZ2zp0x1zoEE5nL3HjROCSsKc/3c= -github.com/bugsnag/panicwrap v1.3.4 h1:A6sXFtDGsgU/4BLf5JT0o5uYg3EeKgGx3Sfs+/uk3pU= -github.com/bugsnag/panicwrap v1.3.4/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -137,8 +131,6 @@ github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bP github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= -github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= diff --git a/logging.go b/logging.go index d4d665a..f85e030 100644 --- a/logging.go +++ b/logging.go @@ -11,8 +11,6 @@ import ( "time" "github.com/DataDog/dd-trace-go/v2/ddtrace/tracer" - "github.com/bugsnag/bugsnag-go/v2" - bugsnag_errors "github.com/bugsnag/bugsnag-go/v2/errors" "github.com/getsentry/sentry-go" nsq "github.com/nsqio/go-nsq" "github.com/sirupsen/logrus" @@ -30,9 +28,7 @@ type LoggingConfig struct { LogLevel string Environment string AppVersion string - BugsnagAPIKey string ErrorNotifyReleaseStages []string - BugsnagProjectPackages []string SentryDSN string } @@ -40,10 +36,21 @@ type Logger struct { *logrus.Logger } -type bugsnagHook struct{} - type sentryHook struct{} +type errorWithStacktrace struct { + err error + stacktrace *sentry.Stacktrace +} + +func (e *errorWithStacktrace) Error() string { + return e.err.Error() +} + +func (e *errorWithStacktrace) Unwrap() error { + return e.err +} + func (l Logger) getNSQLogLevel() nsq.LogLevel { switch l.Level { case logrus.DebugLevel: @@ -111,7 +118,7 @@ func (l *Logger) WithDDTrace(ctx context.Context) *Entry { } func (l *Logger) WithError(err error) *Entry { - return l.NewEntry().WithError(bugsnag_errors.New(err, 1)) + return l.NewEntry().WithError(err) } func (e *Entry) WithField(field string, value interface{}) *Entry { @@ -200,14 +207,13 @@ func (e *Entry) WithChannel(channel string) *Entry { } func (e *Entry) WithError(err error) *Entry { - return &Entry{e.Entry.WithError(bugsnag_errors.New(err, 1))} + return &Entry{e.Entry.WithError(err)} } func (e *Entry) WithDDTrace(ctx context.Context) *Entry { var traceID, spanID uint64 span, ok := tracer.SpanFromContext(ctx) if ok { - // there was a span in the context traceID, spanID = span.Context().TraceIDLower(), span.Context().SpanID() return &Entry{e.Entry.WithFields(logrus.Fields{ "dd.trace_id": traceID, @@ -217,12 +223,15 @@ func (e *Entry) WithDDTrace(ctx context.Context) *Entry { return e } -func Errorf(format string, a ...interface{}) *bugsnag_errors.Error { - return bugsnag_errors.New(fmt.Errorf(format, a...), 1) -} - -func ErrorWithStacktrace(err error) *bugsnag_errors.Error { - return bugsnag_errors.New(err, 1) +func ErrorWithStacktrace(err error) error { + st := sentry.NewStacktrace() + if st != nil && len(st.Frames) > 0 { + st.Frames = st.Frames[:len(st.Frames)-1] + } + return &errorWithStacktrace{ + err: err, + stacktrace: st, + } } func getLogrusLogLevel(level string) logrus.Level { @@ -242,53 +251,12 @@ func getLogrusLogLevel(level string) logrus.Level { return loglevel } -func (b *bugsnagHook) Fire(entry *logrus.Entry) error { - var notifyErr error - switch err := entry.Data[logrus.ErrorKey].(type) { - case *bugsnag_errors.Error: - notifyErr = err - case error: - if entry.Message != "" { - notifyErr = fmt.Errorf("%s: %w", entry.Message, err) - } else { - notifyErr = err - } - default: - notifyErr = fmt.Errorf("%s", entry.Message) - } - - metadata := bugsnag.MetaData{} - metadata["metadata"] = make(map[string]interface{}) - for key, val := range entry.Data { - if key != logrus.ErrorKey { - metadata["metadata"][key] = val - } - } - - skipStackFrames := 4 - errWithStack := bugsnag_errors.New(notifyErr, skipStackFrames) - bugsnagErr := bugsnag.Notify(errWithStack, metadata) - if bugsnagErr != nil { - return bugsnagErr - } - - return nil -} - -func (b *bugsnagHook) Levels() []logrus.Level { - return []logrus.Level{ - logrus.ErrorLevel, - logrus.FatalLevel, - logrus.PanicLevel, - } -} - func (s *sentryHook) Fire(entry *logrus.Entry) error { var notifyErr error + var origErr error switch err := entry.Data[logrus.ErrorKey].(type) { - case *bugsnag_errors.Error: - notifyErr = err case error: + origErr = err if entry.Message != "" { notifyErr = fmt.Errorf("%s: %w", entry.Message, err) } else { @@ -296,6 +264,7 @@ func (s *sentryHook) Fire(entry *logrus.Entry) error { } default: notifyErr = fmt.Errorf("%s", entry.Message) + origErr = notifyErr } event := sentry.NewEvent() @@ -304,9 +273,16 @@ 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: reflect.TypeOf(notifyErr).String(), - Value: notifyErr.Error(), + Type: errorClass(origErr), + Value: notifyErr.Error(), + Stacktrace: stacktrace, }} extra := make(map[string]interface{}) @@ -318,9 +294,38 @@ func (s *sentryHook) Fire(entry *logrus.Entry) error { event.Extra = extra sentry.CaptureEvent(event) + + if entry.Level == logrus.FatalLevel || entry.Level == logrus.PanicLevel { + sentry.Flush(2 * time.Second) + } return nil } +// errorClass returns a stable type name for Sentry grouping by unwrapping +// transparent wrappers (fmt.Errorf with %w, errorWithStacktrace) up to a +// bounded depth so events group on the underlying error type rather than the +// wrapper. +func errorClass(err error) string { + for i := 0; i < 11; i++ { + t := reflect.TypeOf(err).String() + if t != "*fmt.wrapError" && t != "*logging.errorWithStacktrace" { + return t + } + unwrapped := errors.Unwrap(err) + if unwrapped == nil { + return t + } + err = unwrapped + } + return reflect.TypeOf(err).String() +} + +// Flush blocks up to timeout waiting for queued Sentry events to be delivered. +// Call at service shutdown so the last events aren't lost when the process exits. +func Flush(timeout time.Duration) bool { + return sentry.Flush(timeout) +} + func (s *sentryHook) Levels() []logrus.Level { return []logrus.Level{ logrus.ErrorLevel, @@ -329,7 +334,7 @@ func (s *sentryHook) Levels() []logrus.Level { } } -func new(withBugsnag bool, withSentry bool, config LoggingConfig) *Logger { +func new(withSentry bool, config LoggingConfig) *Logger { log := logrus.New() logrus.ErrorKey = "error.message" log.Formatter = &logrus.JSONFormatter{ @@ -342,10 +347,6 @@ func new(withBugsnag bool, withSentry bool, config LoggingConfig) *Logger { } log.Level = getLogrusLogLevel(config.LogLevel) - if withBugsnag { - log.Hooks.Add(&bugsnagHook{}) - } - if withSentry { log.Hooks.Add(&sentryHook{}) } @@ -364,40 +365,6 @@ func shouldNotify(releaseStages []string, environment string) bool { func Init(config LoggingConfig) { if nil == Log { - bugsnag.Configure(bugsnag.Configuration{ - APIKey: config.BugsnagAPIKey, - ReleaseStage: config.Environment, - AppVersion: config.AppVersion, - NotifyReleaseStages: config.ErrorNotifyReleaseStages, - ProjectPackages: config.BugsnagProjectPackages, - Logger: stdlog.New(new(false, false, config).Writer(), "bugsnag: ", 0), - }) - bugsnag.OnBeforeNotify( - func(event *bugsnag.Event, config *bugsnag.Configuration) error { - errClass := event.ErrorClass - count := 0 - wrappedError := event.Error.Err - for { - if errClass != "*fmt.wrapError" { - break - } - - wrappedError = errors.Unwrap(wrappedError) - if wrappedError != nil { - errClass = reflect.TypeOf(wrappedError).String() - } else { - break - } - count++ - if count >= 11 { - stdlog.Printf("Failed to unwrap error %s %s %+v", event.ErrorClass, errClass, event.Error) - break - } - } - event.ErrorClass = errClass - return nil - }) - withSentry := false if config.SentryDSN != "" && shouldNotify(config.ErrorNotifyReleaseStages, config.Environment) { err := sentry.Init(sentry.ClientOptions{ @@ -412,6 +379,6 @@ func Init(config LoggingConfig) { } } - Log = new(true, withSentry, config) + Log = new(withSentry, config) } } diff --git a/logging_test.go b/logging_test.go index 129678e..f699aa8 100644 --- a/logging_test.go +++ b/logging_test.go @@ -1,6 +1,7 @@ package logging import ( + "errors" "fmt" "io/ioutil" "os" @@ -214,7 +215,7 @@ func TestShouldNotify(t *testing.T) { } func TestNewWithSentry(t *testing.T) { - logger := new(false, true, LoggingConfig{LogLevel: "INFO"}) + logger := new(true, LoggingConfig{LogLevel: "INFO"}) assert.NotNil(t, logger) hasSentryHook := false @@ -229,7 +230,7 @@ func TestNewWithSentry(t *testing.T) { } func TestNewWithoutSentry(t *testing.T) { - logger := new(false, false, LoggingConfig{LogLevel: "INFO"}) + logger := new(false, LoggingConfig{LogLevel: "INFO"}) assert.NotNil(t, logger) hasSentryHook := false @@ -243,6 +244,115 @@ func TestNewWithoutSentry(t *testing.T) { assert.False(t, hasSentryHook, "logger should not have sentry hook") } +func TestErrorWithStacktrace(t *testing.T) { + t.Run("wraps error and preserves message", func(t *testing.T) { + original := fmt.Errorf("something broke") + wrapped := ErrorWithStacktrace(original) + + assert.Equal(t, "something broke", wrapped.Error()) + assert.True(t, errors.Is(wrapped, original)) + }) + + t.Run("captures a sentry stacktrace", func(t *testing.T) { + original := fmt.Errorf("boom") + wrapped := ErrorWithStacktrace(original) + + var errSt *errorWithStacktrace + assert.True(t, errors.As(wrapped, &errSt)) + assert.NotNil(t, errSt.stacktrace) + assert.NotEmpty(t, errSt.stacktrace.Frames) + }) + + t.Run("sentry hook attaches stacktrace to event", func(t *testing.T) { + original := fmt.Errorf("traced error") + wrapped := ErrorWithStacktrace(original) + + hook := &sentryHook{} + entry := &logrus.Entry{ + Level: logrus.ErrorLevel, + Message: "test", + Data: logrus.Fields{ + logrus.ErrorKey: wrapped, + }, + } + err := hook.Fire(entry) + assert.NoError(t, err) + }) + + t.Run("sentry hook works without stacktrace", func(t *testing.T) { + hook := &sentryHook{} + entry := &logrus.Entry{ + Level: logrus.ErrorLevel, + Message: "plain error", + Data: logrus.Fields{ + logrus.ErrorKey: fmt.Errorf("no stack"), + }, + } + err := hook.Fire(entry) + assert.NoError(t, err) + }) +} + +func TestErrorWithStacktracePreservesUnwrap(t *testing.T) { + inner := fmt.Errorf("inner") + outer := fmt.Errorf("outer: %w", inner) + wrapped := ErrorWithStacktrace(outer) + + assert.True(t, errors.Is(wrapped, inner)) +} + +func TestSentryHookExtractsStacktraceFromWrappedMessage(t *testing.T) { + original := fmt.Errorf("deep error") + wrapped := ErrorWithStacktrace(original) + + hook := &sentryHook{} + entry := &logrus.Entry{ + Level: logrus.ErrorLevel, + Message: "context message", + Data: logrus.Fields{ + logrus.ErrorKey: wrapped, + }, + } + + err := hook.Fire(entry) + assert.NoError(t, err) + + var errSt *errorWithStacktrace + wrappedEntry := fmt.Errorf("%s: %w", entry.Message, wrapped) + assert.True(t, errors.As(wrappedEntry, &errSt)) + assert.NotNil(t, errSt.stacktrace) + assert.NotEmpty(t, errSt.stacktrace.Frames) +} + +type customError struct{ msg string } + +func (e *customError) Error() string { return e.msg } + +func TestErrorClassUnwrapsWrappers(t *testing.T) { + t.Run("returns underlying type for fmt-wrapped error", func(t *testing.T) { + base := &customError{msg: "boom"} + wrapped := fmt.Errorf("context: %w", base) + assert.Equal(t, "*logging.customError", errorClass(wrapped)) + }) + + t.Run("returns underlying type for errorWithStacktrace", func(t *testing.T) { + base := &customError{msg: "boom"} + wrapped := ErrorWithStacktrace(base) + assert.Equal(t, "*logging.customError", errorClass(wrapped)) + }) + + t.Run("returns underlying type for nested wrappers", func(t *testing.T) { + base := &customError{msg: "boom"} + wrapped := fmt.Errorf("outer: %w", ErrorWithStacktrace(fmt.Errorf("inner: %w", base))) + assert.Equal(t, "*logging.customError", errorClass(wrapped)) + }) + + t.Run("returns wrapper type when chain terminates without unwrapping", func(t *testing.T) { + bare := fmt.Errorf("just a string") + assert.Equal(t, "*errors.errorString", errorClass(bare)) + }) +} + func TestNSQLogger(t *testing.T) { Log.Level = logrus.DebugLevel _, level := Log.NSQLogger()