diff --git a/reporter/sentry/options.go b/reporter/sentry/options.go index a40811b..6dafe4f 100644 --- a/reporter/sentry/options.go +++ b/reporter/sentry/options.go @@ -1,38 +1,55 @@ package sentry import ( + "context" + "io" "os" "strings" "time" "github.com/getsentry/sentry-go" + uerrors "github.com/upfluence/errors" + "github.com/upfluence/errors/base" "github.com/upfluence/errors/reporter" ) -var defaultOptions = Options{ - Tags: make(map[string]string), - SentryOptions: sentry.ClientOptions{ - Dsn: os.Getenv("SENTRY_DSN"), - Environment: os.Getenv("ENV"), - SendDefaultPII: true, - }, - TagWhitelist: toStringMap( - []string{reporter.RemoteIP, reporter.RemotePort, reporter.DomainKey}, - ), - Timeout: time.Minute, - TagBlacklist: []func(string) bool{ - stringEqual(reporter.TransactionKey), - stringEqual(reporter.UserEmailKey), - stringEqual(reporter.UserIDKey), - stringEqual(reporter.HTTPRequestProtoKey), - stringEqual(reporter.HTTPRequestPathKey), - stringEqual(reporter.HTTPRequestHostKey), - stringEqual(reporter.HTTPRequestMethodKey), - stringEqual(reporter.HTTPRequestBodyKey), - stringPrefix(reporter.HTTPRequestHeaderKeyPrefix), - stringPrefix(reporter.HTTPRequestQueryValuesKeyPrefix), - }, -} +type ErrorLevelMapper func(error) sentry.Level + +var ( + defaultErrorLevelMappers = []ErrorLevelMapper{ + ErrorIsLevel(context.DeadlineExceeded, sentry.LevelWarning), + ErrorIsLevel(context.Canceled, sentry.LevelWarning), + ErrorIsLevel(io.EOF, sentry.LevelWarning), + ErrorCauseTextContainsLevel("net/http: TLS handshake timeout", sentry.LevelWarning), + ErrorCauseTextContainsLevel("operation was canceled", sentry.LevelWarning), + ErrorCauseTextContainsLevel("EOF", sentry.LevelWarning), + } + defaultOptions = Options{ + Tags: make(map[string]string), + SentryOptions: sentry.ClientOptions{ + Dsn: os.Getenv("SENTRY_DSN"), + Environment: os.Getenv("ENV"), + SendDefaultPII: true, + }, + TagWhitelist: toStringMap( + []string{reporter.RemoteIP, reporter.RemotePort, reporter.DomainKey}, + ), + Timeout: time.Minute, + TagBlacklist: []func(string) bool{ + stringEqual(reporter.TransactionKey), + stringEqual(reporter.UserEmailKey), + stringEqual(reporter.UserIDKey), + stringEqual(reporter.HTTPRequestProtoKey), + stringEqual(reporter.HTTPRequestPathKey), + stringEqual(reporter.HTTPRequestHostKey), + stringEqual(reporter.HTTPRequestMethodKey), + stringEqual(reporter.HTTPRequestBodyKey), + stringPrefix(reporter.HTTPRequestHeaderKeyPrefix), + stringPrefix(reporter.HTTPRequestQueryValuesKeyPrefix), + }, + ErrorLevelMappers: defaultErrorLevelMappers, + } +) func stringEqual(x string) func(string) bool { return func(y string) bool { return x == y } @@ -52,6 +69,44 @@ func toStringMap(vs []string) map[string]struct{} { return res } +// ErrorIsLevel creates an ErrorLevelMapper of the passed level that checks if +// reported errors are the same as the given sentinel error +func ErrorIsLevel(sentinel error, level sentry.Level) ErrorLevelMapper { + return func(err error) sentry.Level { + if uerrors.Is(err, sentinel) { + return level + } + + return "" + } +} + +// ErrorIsOfTypeLevel creates an ErrorLevelMapper of the passed level that checks +// if reported errors are of the passed generic type +func ErrorIsOfTypeLevel[T error](level sentry.Level) ErrorLevelMapper { + return func(err error) sentry.Level { + if uerrors.IsOfType[T](err) { + return level + } + + return "" + } +} + +// ErrorCauseTextContainsLevel creates an ErrorLevelMapper of the passed level that checks +// if reported errors' cause's Error() text contains the passed string +func ErrorCauseTextContainsLevel(errorText string, level sentry.Level) ErrorLevelMapper { + return func(err error) sentry.Level { + rootCause := base.UnwrapAll(err).Error() + + if strings.Contains(rootCause, errorText) { + return level + } + + return "" + } +} + type Options struct { Tags map[string]string @@ -60,6 +115,8 @@ type Options struct { TagWhitelist map[string]struct{} TagBlacklist []func(string) bool + + ErrorLevelMappers []ErrorLevelMapper } func (o Options) client() (*sentry.Client, error) { @@ -74,6 +131,7 @@ func (o Options) client() (*sentry.Client, error) { type Option func(*Options) +// WithTags allows for custom tags to be given to the Reporter func WithTags(tags map[string]string) Option { return func(opts *Options) { for k, v := range tags { @@ -81,3 +139,17 @@ func WithTags(tags map[string]string) Option { } } } + +// AppendErrorLevelMappers adds the passed funcs to the ErrorLevelMappers of the Reporter +func AppendErrorLevelMappers(funcs ...ErrorLevelMapper) Option { + return func(opts *Options) { + opts.ErrorLevelMappers = append(opts.ErrorLevelMappers, funcs...) + } +} + +// ReplaceErrorLevelMappers replaces the ErrorLevelMappers of the Reporter with the passed ones +func ReplaceErrorLevelMappers(funcs []ErrorLevelMapper) Option { + return func(opts *Options) { + opts.ErrorLevelMappers = funcs + } +} diff --git a/reporter/sentry/reporter.go b/reporter/sentry/reporter.go index edc8579..02d671c 100644 --- a/reporter/sentry/reporter.go +++ b/reporter/sentry/reporter.go @@ -28,8 +28,9 @@ import ( type Reporter struct { cl *sentry.Client - tagWhitelist []func(string) bool - tagBlacklist []func(string) bool + tagWhitelist []func(string) bool + tagBlacklist []func(string) bool + errorLevelMappers []ErrorLevelMapper timeout time.Duration } @@ -56,8 +57,9 @@ func NewReporter(os ...Option) (*Reporter, error) { return ok }, }, - tagBlacklist: opts.TagBlacklist, - timeout: opts.Timeout, + tagBlacklist: opts.TagBlacklist, + timeout: opts.Timeout, + errorLevelMappers: opts.ErrorLevelMappers, }, nil } @@ -121,26 +123,26 @@ func (r *Reporter) buildEvent(err error, opts reporter.ReportOptions) *sentry.Ev return nil } - ts := tags.GetTags(err) + errorTags := tags.GetTags(err) - if ts == nil && len(opts.Tags) > 0 { - ts = make(map[string]interface{}, len(opts.Tags)) + if errorTags == nil && len(opts.Tags) > 0 { + errorTags = make(map[string]interface{}, len(opts.Tags)) } for k, v := range opts.Tags { - if _, ok := ts[k]; !ok { - ts[k] = v + if _, ok := errorTags[k]; !ok { + errorTags[k] = v } } evt := sentry.NewEvent() - evt.Level = sentry.LevelError + evt.Level = r.computeErrorLevel(err) evt.Timestamp = time.Now() evt.Message = err.Error() - evt.Transaction = transactionName(ts) - evt.User = buildUser(ts) - evt.Request = buildRequest(ts) + evt.Transaction = transactionName(errorTags) + evt.User = buildUser(errorTags) + evt.Request = buildRequest(errorTags) cause := base.UnwrapAll(err) @@ -153,7 +155,7 @@ func (r *Reporter) buildEvent(err error, opts reporter.ReportOptions) *sentry.Ev }, } - for k, v := range ts { + for k, v := range errorTags { r.appendTag(k, v, evt) } @@ -164,6 +166,16 @@ func (r *Reporter) buildEvent(err error, opts reporter.ReportOptions) *sentry.Ev return evt } +func (r *Reporter) computeErrorLevel(err error) sentry.Level { + for _, errFunc := range r.errorLevelMappers { + if level := errFunc(err); level != "" { + return level + } + } + + return sentry.LevelError +} + func extractStacktrace(err error, n int) *sentry.Stacktrace { var s sentry.Stacktrace diff --git a/reporter/sentry/reporter_test.go b/reporter/sentry/reporter_test.go index 5b23201..2546394 100644 --- a/reporter/sentry/reporter_test.go +++ b/reporter/sentry/reporter_test.go @@ -1,6 +1,7 @@ package sentry import ( + "context" "io" "regexp" "testing" @@ -13,6 +14,10 @@ import ( "github.com/upfluence/errors/reporter" ) +type mockError struct{} + +func (*mockError) Error() string { return "mock" } + func TestBuildEvent(t *testing.T) { for _, tt := range []struct { name string @@ -163,6 +168,95 @@ func TestBuildEvent(t *testing.T) { assert.Equal(t, evt.Tags, map[string]string{"foo": "bar", "domain": "github.com/upfluence/errors/reporter/sentry"}) }, }, + { + name: "simple sentinel error with severity func", + err: io.EOF, + modifiers: []func(*Reporter){}, + evtfn: func(t *testing.T, evt *sentry.Event) { + assert.Equal( + t, + sentry.LevelWarning, + evt.Level, + ) + }, + }, + { + name: "wrapped sentinel error with severity func", + err: errors.Wrap(context.DeadlineExceeded, "it took too long, boss"), + modifiers: []func(*Reporter){}, + evtfn: func(t *testing.T, evt *sentry.Event) { + assert.Equal( + t, + sentry.LevelWarning, + evt.Level, + ) + }, + }, + { + name: "simple text error with severity func", + err: errors.New("net/http: TLS handshake timeout with extra bells and whistles"), + modifiers: []func(*Reporter){}, + evtfn: func(t *testing.T, evt *sentry.Event) { + assert.Equal( + t, + sentry.LevelWarning, + evt.Level, + ) + }, + }, + { + name: "wrapped text error with severity func", + err: errors.Wrap( + errors.New("net/http: TLS handshake timeout with extra bells and whistles"), + "more bells and whistles", + ), + modifiers: []func(*Reporter){}, + evtfn: func(t *testing.T, evt *sentry.Event) { + assert.Equal( + t, + sentry.LevelWarning, + evt.Level, + ) + }, + }, + { + name: "simple error type", + err: &mockError{}, + modifiers: []func(*Reporter){ + func(r *Reporter) { + r.errorLevelMappers = append( + r.errorLevelMappers, + ErrorIsOfTypeLevel[*mockError](sentry.LevelDebug), + ) + }, + }, + evtfn: func(t *testing.T, evt *sentry.Event) { + assert.Equal( + t, + sentry.LevelDebug, + evt.Level, + ) + }, + }, + { + name: "wrapped error type", + err: errors.Wrap(&mockError{}, "i am being mocked"), + modifiers: []func(*Reporter){ + func(r *Reporter) { + r.errorLevelMappers = append( + r.errorLevelMappers, + ErrorIsOfTypeLevel[*mockError](sentry.LevelDebug), + ) + }, + }, + evtfn: func(t *testing.T, evt *sentry.Event) { + assert.Equal( + t, + sentry.LevelDebug, + evt.Level, + ) + }, + }, } { t.Run(tt.name, func(t *testing.T) { r, err := NewReporter(tt.opts...)