diff --git a/example/logrus/main.go b/example/logrus/main.go new file mode 100644 index 00000000..a2870d7e --- /dev/null +++ b/example/logrus/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "time" + + "github.com/sirupsen/logrus" + + "github.com/getsentry/sentry-go" + sentrylogrus "github.com/getsentry/sentry-go/logrus" +) + +func main() { + logger := logrus.New() + + // Log DEBUG and higher level logs to STDERR + logger.Level = logrus.DebugLevel + logger.Out = os.Stderr + + // Send only ERROR and higher level logs to Sentry + sentryLevels := []logrus.Level{logrus.ErrorLevel, logrus.FatalLevel, logrus.PanicLevel} + + sentryHook, err := sentrylogrus.New(sentryLevels, sentry.ClientOptions{ + Dsn: "", + BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { + if hint.Context != nil { + if req, ok := hint.Context.Value(sentry.RequestContextKey).(*http.Request); ok { + // You have access to the original Request + fmt.Println(req) + } + } + fmt.Println(event) + return event + }, + Debug: true, + AttachStacktrace: true, + }) + if err != nil { + panic(err) + } + defer sentryHook.Flush(5 * time.Second) + logger.AddHook(sentryHook) + + // The following line is logged to STDERR, but not to Sentry + logger.Infof("Application has started") + + // The following line is logged to STDERR and also sent to Sentry + logger.Errorf("oh no!") +} diff --git a/go.mod b/go.mod index 77b83feb..dc63964d 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/labstack/echo/v4 v4.9.0 github.com/pingcap/errors v0.11.4 github.com/pkg/errors v0.9.1 + github.com/sirupsen/logrus v1.9.0 github.com/urfave/negroni v1.0.0 github.com/valyala/fasthttp v1.40.0 golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec @@ -59,7 +60,6 @@ require ( github.com/pelletier/go-toml/v2 v2.0.5 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/schollz/closestmatch v2.1.0+incompatible // indirect - github.com/sirupsen/logrus v1.9.0 // indirect github.com/tdewolff/minify/v2 v2.12.4 // indirect github.com/tdewolff/parse/v2 v2.6.4 // indirect github.com/ugorji/go/codec v1.2.7 // indirect diff --git a/logrus/logrusentry.go b/logrus/logrusentry.go new file mode 100644 index 00000000..0a3a7221 --- /dev/null +++ b/logrus/logrusentry.go @@ -0,0 +1,223 @@ +// Package sentrylogrus provides a simple Logrus hook for Sentry. +package sentrylogrus + +import ( + "errors" + "net/http" + "time" + + sentry "github.com/getsentry/sentry-go" + "github.com/sirupsen/logrus" +) + +// These default log field keys are used to pass specific metadata in a way that +// Sentry understands. If they are found in the log fields, and the value is of +// the expected datatype, it will be converted from a generic field, into Sentry +// metadata. +// +// These keys may be overridden by calling SetKey on the hook object. +const ( + // FieldRequest holds an *http.Request. + FieldRequest = "request" + // FieldUser holds a User or *User value. + FieldUser = "user" + // FieldTransaction holds a transaction ID as a string. + FieldTransaction = "transaction" + // FieldFingerprint holds a string slice ([]string), used to dictate the + // grouping of this event. + FieldFingerprint = "fingerprint" + + // These fields are simply omitted, as they are duplicated by the Sentry SDK. + FieldGoVersion = "go_version" + FieldMaxProcs = "go_maxprocs" +) + +// Hook is the logrus hook for Sentry. +// +// It is not safe to configure the hook while logging is happening. Please +// perform all configuration before using it. +type Hook struct { + hub *sentry.Hub + fallback FallbackFunc + keys map[string]string + levels []logrus.Level +} + +var _ logrus.Hook = &Hook{} + +// New initializes a new Logrus hook which sends logs to a new Sentry client +// configured according to opts. +func New(levels []logrus.Level, opts sentry.ClientOptions) (*Hook, error) { + client, err := sentry.NewClient(opts) + if err != nil { + return nil, err + } + return NewFromClient(levels, client), nil +} + +// NewFromClient initializes a new Logrus hook which sends logs to the provided +// sentry client. +func NewFromClient(levels []logrus.Level, client *sentry.Client) *Hook { + h := &Hook{ + levels: levels, + hub: sentry.NewHub(client, sentry.NewScope()), + keys: make(map[string]string), + } + return h +} + +// AddTags adds tags to the hook's scope. +func (h *Hook) AddTags(tags map[string]string) { + h.hub.Scope().SetTags(tags) +} + +// A FallbackFunc can be used to attempt to handle any errors in logging, before +// resorting to Logrus's standard error reporting. +type FallbackFunc func(*logrus.Entry) error + +// SetFallback sets a fallback function, which will be called in case logging to +// sentry fails. In case of a logging failure in the Fire() method, the +// fallback function is called with the original logrus entry. If the +// fallback function returns nil, the error is considered handled. If it returns +// an error, that error is passed along to logrus as the return value from the +// Fire() call. If no fallback function is defined, a default error message is +// returned to Logrus in case of failure to send to Sentry. +func (h *Hook) SetFallback(fb FallbackFunc) { + h.fallback = fb +} + +// SetKey sets an alternate field key. Use this if the default values conflict +// with other loggers, for instance. You may pass "" for new, to unset an +// existing alternate. +func (h *Hook) SetKey(oldKey, newKey string) { + if oldKey == "" { + return + } + if newKey == "" { + delete(h.keys, oldKey) + return + } + delete(h.keys, newKey) + h.keys[oldKey] = newKey +} + +func (h *Hook) key(key string) string { + if val := h.keys[key]; val != "" { + return val + } + return key +} + +// Levels returns the list of logging levels that will be sent to +// Sentry. +func (h *Hook) Levels() []logrus.Level { + return h.levels +} + +// Fire sends entry to Sentry. +func (h *Hook) Fire(entry *logrus.Entry) error { + event := h.entryToEvent(entry) + if id := h.hub.CaptureEvent(event); id == nil { + if h.fallback != nil { + return h.fallback(entry) + } + return errors.New("failed to send to sentry") + } + return nil +} + +var levelMap = map[logrus.Level]sentry.Level{ + logrus.TraceLevel: sentry.LevelDebug, + logrus.DebugLevel: sentry.LevelDebug, + logrus.InfoLevel: sentry.LevelInfo, + logrus.WarnLevel: sentry.LevelWarning, + logrus.ErrorLevel: sentry.LevelError, + logrus.FatalLevel: sentry.LevelFatal, + logrus.PanicLevel: sentry.LevelFatal, +} + +func (h *Hook) entryToEvent(l *logrus.Entry) *sentry.Event { + data := make(logrus.Fields, len(l.Data)) + for k, v := range l.Data { + data[k] = v + } + s := &sentry.Event{ + Level: levelMap[l.Level], + Extra: data, + Message: l.Message, + Timestamp: l.Time, + } + key := h.key(FieldRequest) + if req, ok := s.Extra[key].(*http.Request); ok { + delete(s.Extra, key) + s.Request = sentry.NewRequest(req) + } + if err, ok := s.Extra[logrus.ErrorKey].(error); ok { + delete(s.Extra, logrus.ErrorKey) + ex := h.exceptions(err) + s.Exception = ex + } + key = h.key(FieldUser) + if user, ok := s.Extra[key].(sentry.User); ok { + delete(s.Extra, key) + s.User = user + } + if user, ok := s.Extra[key].(*sentry.User); ok { + delete(s.Extra, key) + s.User = *user + } + key = h.key(FieldTransaction) + if txn, ok := s.Extra[key].(string); ok { + delete(s.Extra, key) + s.Transaction = txn + } + key = h.key(FieldFingerprint) + if fp, ok := s.Extra[key].([]string); ok { + delete(s.Extra, key) + s.Fingerprint = fp + } + delete(s.Extra, FieldGoVersion) + delete(s.Extra, FieldMaxProcs) + return s +} + +func (h *Hook) exceptions(err error) []sentry.Exception { + if !h.hub.Client().Options().AttachStacktrace { + return []sentry.Exception{{ + Type: "error", + Value: err.Error(), + }} + } + excs := []sentry.Exception{} + var last *sentry.Exception + for ; err != nil; err = errors.Unwrap(err) { + exc := sentry.Exception{ + Type: "error", + Value: err.Error(), + Stacktrace: sentry.ExtractStacktrace(err), + } + if last != nil && exc.Value == last.Value { + if last.Stacktrace == nil { + last.Stacktrace = exc.Stacktrace + continue + } + if exc.Stacktrace == nil { + continue + } + } + excs = append(excs, exc) + last = &excs[len(excs)-1] + } + // reverse + for i, j := 0, len(excs)-1; i < j; i, j = i+1, j-1 { + excs[i], excs[j] = excs[j], excs[i] + } + return excs +} + +// Flush waits until the underlying Sentry transport sends any buffered events, +// blocking for at most the given timeout. It returns false if the timeout was +// reached, in which case some events may not have been sent. +func (h *Hook) Flush(timeout time.Duration) bool { + return h.hub.Client().Flush(timeout) +} diff --git a/logrus/logrusentry_test.go b/logrus/logrusentry_test.go new file mode 100644 index 00000000..5e30e55e --- /dev/null +++ b/logrus/logrusentry_test.go @@ -0,0 +1,485 @@ +package sentrylogrus + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + pkgerr "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "github.com/getsentry/sentry-go" +) + +const testDSN = "http://test:test@localhost/1234" + +type testResponder func(*http.Request) (*http.Response, error) + +func (t testResponder) RoundTrip(r *http.Request) (*http.Response, error) { + return t(r) +} + +func xport(req *http.Request) http.RoundTripper { + return testResponder(func(r *http.Request) (*http.Response, error) { + *req = *r + return &http.Response{}, nil + }) +} + +func TestNew(t *testing.T) { + t.Parallel() + t.Run("invalid DSN", func(t *testing.T) { + t.Parallel() + _, err := New(nil, sentry.ClientOptions{Dsn: "%xxx"}) + if err == nil || !strings.Contains(err.Error(), "invalid URL escape") { + t.Errorf("Unexpected error: %s", err) + } + }) + + t.Run("success", func(t *testing.T) { + t.Parallel() + req := new(http.Request) + h, err := New(nil, sentry.ClientOptions{ + Dsn: testDSN, + HTTPTransport: xport(req), + }) + if err != nil { + t.Fatal(err) + } + if id := h.hub.CaptureEvent(&sentry.Event{}); id == nil { + t.Error("CaptureEvent failed") + } + if !h.Flush(5 * time.Second) { + t.Error("flush failed") + } + testEvent(t, req.Body, map[string]interface{}{ + "level": "info", + }) + }) +} + +func TestFire(t *testing.T) { + t.Parallel() + + entry := &logrus.Entry{ + Level: logrus.ErrorLevel, + } + + req := new(http.Request) + opts := sentry.ClientOptions{} + opts.Dsn = testDSN + opts.HTTPTransport = xport(req) + hook, err := New([]logrus.Level{logrus.ErrorLevel}, opts) + if err != nil { + t.Fatal(err) + } + err = hook.Fire(entry) + if err != nil { + t.Fatal(err) + } + + if !hook.Flush(5 * time.Second) { + t.Error("flush failed") + } + testEvent(t, req.Body, map[string]interface{}{ + "level": "error", + }) +} + +func Test_e2e(t *testing.T) { + t.Parallel() + tests := []struct { + name string + levels []logrus.Level + opts sentry.ClientOptions + init func(*Hook) + log func(*logrus.Logger) + skipped bool + want map[string]interface{} + }{ + { + name: "skip info", + levels: []logrus.Level{logrus.ErrorLevel}, + log: func(l *logrus.Logger) { + l.Info("foo") + }, + skipped: true, + }, + { + name: "error level", + levels: []logrus.Level{logrus.ErrorLevel}, + log: func(l *logrus.Logger) { + l.Error("foo") + }, + want: map[string]interface{}{ + "level": "error", + "message": "foo", + }, + }, + { + name: "metadata", + levels: []logrus.Level{logrus.ErrorLevel}, + opts: sentry.ClientOptions{ + Environment: "production", + ServerName: "localhost", + Release: "v1.2.3", + Dist: "beta", + }, + log: func(l *logrus.Logger) { + l.Error("foo") + }, + want: map[string]interface{}{ + "dist": "beta", + "environment": "production", + "level": "error", + "message": "foo", + }, + }, + { + name: "tags", + levels: []logrus.Level{logrus.ErrorLevel}, + opts: sentry.ClientOptions{ + AttachStacktrace: true, + }, + init: func(h *Hook) { + h.AddTags(map[string]string{ + "foo": "bar", + }) + }, + log: func(l *logrus.Logger) { + l.Error("foo") + }, + want: map[string]interface{}{ + "level": "error", + "message": "foo", + "tags": map[string]interface{}{"foo": "bar"}, + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + req := new(http.Request) + l := logrus.New() + opts := tt.opts + opts.Dsn = testDSN + opts.HTTPTransport = xport(req) + hook, err := New(tt.levels, opts) + if err != nil { + t.Fatal(err) + } + if init := tt.init; init != nil { + init(hook) + } + l.SetOutput(io.Discard) + l.AddHook(hook) + tt.log(l) + + if !hook.Flush(5 * time.Second) { + t.Fatal("failed to flush") + } + if tt.skipped { + if req.Method != "" { + t.Error("Got an unexpected request") + } + return + } + testEvent(t, req.Body, tt.want) + }) + } +} + +func testEvent(t *testing.T, r io.ReadCloser, want map[string]interface{}) { + t.Helper() + t.Cleanup(func() { + _ = r.Close() + }) + var event map[string]interface{} + if err := json.NewDecoder(r).Decode(&event); err != nil { + t.Fatal(err) + } + // delete static or non-deterministic fields + for _, k := range []string{"timestamp", "event_id", "contexts", "release", "server_name", "sdk", "platform", "user", "modules"} { + delete(event, k) + } + if d := cmp.Diff(want, event); d != "" { + t.Error(d) + } +} + +func Test_entry2event(t *testing.T) { + t.Parallel() + tests := []struct { + name string + entry *logrus.Entry + want *sentry.Event + }{ + { + name: "empty entry", + entry: &logrus.Entry{}, + want: &sentry.Event{ + Level: "fatal", + Extra: map[string]interface{}{}, + }, + }, + { + name: "data fields", + entry: &logrus.Entry{ + Data: map[string]interface{}{ + "foo": 123.4, + "bar": "oink", + }, + }, + want: &sentry.Event{ + Level: "fatal", + Extra: map[string]interface{}{"bar": "oink", "foo": 123.4}, + }, + }, + { + name: "info level", + entry: &logrus.Entry{ + Level: logrus.InfoLevel, + }, + want: &sentry.Event{ + Level: "info", + Extra: map[string]interface{}{}, + }, + }, + { + name: "message", + entry: &logrus.Entry{ + Message: "the only thing we have to fear is fear itself", + }, + want: &sentry.Event{ + Level: "fatal", + Extra: map[string]interface{}{}, + Message: "the only thing we have to fear is fear itself", + }, + }, + { + name: "timestamp", + entry: &logrus.Entry{ + Time: time.Unix(1, 2).UTC(), + }, + want: &sentry.Event{ + Level: "fatal", + Extra: map[string]interface{}{}, + Timestamp: time.Unix(1, 2).UTC(), + }, + }, + { + name: "http request", + entry: &logrus.Entry{ + Data: map[string]interface{}{ + FieldRequest: httptest.NewRequest("GET", "/", nil), + }, + }, + want: &sentry.Event{ + Level: "fatal", + Extra: map[string]interface{}{}, + Request: &sentry.Request{ + URL: "http://example.com/", + Method: http.MethodGet, + Headers: map[string]string{"Host": "example.com"}, + }, + }, + }, + { + name: "error", + entry: &logrus.Entry{ + Data: map[string]interface{}{ + logrus.ErrorKey: errors.New("things failed"), + }, + }, + want: &sentry.Event{ + Level: "fatal", + Extra: map[string]interface{}{}, + Exception: []sentry.Exception{ + {Type: "error", Value: "things failed"}, + }, + }, + }, + { + name: "non-error", + entry: &logrus.Entry{ + Data: map[string]interface{}{ + logrus.ErrorKey: "this isn't really an error", + }, + }, + want: &sentry.Event{ + Level: "fatal", + Extra: map[string]interface{}{ + "error": "this isn't really an error", + }, + }, + }, + { + name: "error with stack trace", + entry: &logrus.Entry{ + Data: map[string]interface{}{ + logrus.ErrorKey: pkgerr.WithStack(errors.New("failure")), + }, + }, + want: &sentry.Event{ + Level: "fatal", + Extra: map[string]interface{}{}, + Exception: []sentry.Exception{ + {Type: "error", Value: "failure", Stacktrace: &sentry.Stacktrace{Frames: []sentry.Frame{}}}, + }, + }, + }, + { + name: "user", + entry: &logrus.Entry{ + Data: map[string]interface{}{ + FieldUser: sentry.User{ + ID: "bob", + }, + }, + }, + want: &sentry.Event{ + Level: "fatal", + Extra: map[string]interface{}{}, + User: sentry.User{ + ID: "bob", + }, + }, + }, + { + name: "user pointer", + entry: &logrus.Entry{ + Data: map[string]interface{}{ + FieldUser: &sentry.User{ + ID: "alice", + }, + }, + }, + want: &sentry.Event{ + Level: "fatal", + Extra: map[string]interface{}{}, + User: sentry.User{ + ID: "alice", + }, + }, + }, + { + name: "non-user", + entry: &logrus.Entry{ + Data: map[string]interface{}{ + FieldUser: "just say no to drugs", + }, + }, + want: &sentry.Event{ + Level: "fatal", + Extra: map[string]interface{}{ + "user": "just say no to drugs", + }, + }, + }, + } + + h, err := New(nil, sentry.ClientOptions{ + Dsn: testDSN, + AttachStacktrace: true, + }) + if err != nil { + t.Fatal(err) + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := h.entryToEvent(tt.entry) + if d := cmp.Diff(tt.want, got); d != "" { + t.Error(d) + } + }) + } +} + +func Test_exceptions(t *testing.T) { + t.Parallel() + tests := []struct { + name string + trace bool + err error + want []sentry.Exception + }{ + { + name: "std error", + trace: true, + err: errors.New("foo"), + want: []sentry.Exception{ + {Type: "error", Value: "foo"}, + }, + }, + { + name: "wrapped, no stack", + trace: true, + err: fmt.Errorf("foo: %w", errors.New("bar")), + want: []sentry.Exception{ + {Type: "error", Value: "bar"}, + {Type: "error", Value: "foo: bar"}, + }, + }, + { + name: "ignored stack", + trace: false, + err: pkgerr.New("foo"), + want: []sentry.Exception{ + {Type: "error", Value: "foo"}, + }, + }, + { + name: "stack", + trace: true, + err: pkgerr.New("foo"), + want: []sentry.Exception{ + {Type: "error", Value: "foo", Stacktrace: &sentry.Stacktrace{Frames: []sentry.Frame{}}}, + }, + }, + { + name: "multi-wrapped error", + trace: true, + err: func() error { + err := errors.New("original") + err = fmt.Errorf("fmt: %w", err) + err = pkgerr.Wrap(err, "wrap") + err = pkgerr.WithStack(err) + return fmt.Errorf("wrapped: %w", err) + }(), + want: []sentry.Exception{ + {Type: "error", Value: "original"}, + {Type: "error", Value: "fmt: original"}, + {Type: "error", Value: "wrap: fmt: original", Stacktrace: &sentry.Stacktrace{Frames: []sentry.Frame{}}}, + {Type: "error", Value: "wrap: fmt: original", Stacktrace: &sentry.Stacktrace{Frames: []sentry.Frame{}}}, + {Type: "error", Value: "wrapped: wrap: fmt: original"}, + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + h, err := New(nil, sentry.ClientOptions{AttachStacktrace: tt.trace}) + if err != nil { + t.Fatal(err) + } + got := h.exceptions(tt.err) + + if d := cmp.Diff(tt.want, got); d != "" { + t.Error(d) + } + }) + } +}