diff --git a/README.md b/README.md index 1096cf6..dfcfe98 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ hook, err := logrus_sentry.NewWithTagsSentryHook(YOUR_DSN, tags, levels) ``` -If you wish to initialize a SentryHook with an already initialized raven client, you can use +If you wish to initialize a SentryHook with an already initialized raven client, you can use the `NewWithClientSentryHook` constructor: ```go @@ -114,3 +114,21 @@ with a call to `NewSentryHook`. This can be changed by assigning a value to the hook, _ := logrus_sentry.NewSentryHook(...) hook.Timeout = 20*time.Second ``` + +## Enabling Stacktraces + +By default the hook will not send any stacktraces. However, this can be enabled +with: + +```go +hook, _ := logrus_sentry.NewSentryHook(...) +hook.StacktraceConfiguration.Enable = true +``` + +Subsequent calls to `logger.Error` and above will create a stacktrace. + +Other configuration options are: +- `StacktraceConfiguration.Level` the logrus level at which to start capturing stacktraces. +- `StacktraceConfiguration.Skip` how many stack frames to skip before stacktrace starts recording. +- `StacktraceConfiguration.Context` the number of lines to include around a stack frame for context. +- `StacktraceConfiguration.InAppPrefixes` the prefixes that will be matched against the stack frame to identify it as in_app diff --git a/sentry.go b/sentry.go index cf88098..1f49cea 100644 --- a/sentry.go +++ b/sentry.go @@ -58,12 +58,29 @@ type SentryHook struct { // Timeout sets the time to wait for a delivery error from the sentry server. // If this is set to zero the server will not wait for any response and will // consider the message correctly sent - Timeout time.Duration + Timeout time.Duration + StacktraceConfiguration stacktraceConfiguration client *raven.Client levels []logrus.Level } +// stacktraceConfiguration allows for configuring stacktraces +type stacktraceConfiguration struct { + // whether stacktraces should be enabled + Enable bool + // the level at which to start capturing stacktraces + Level logrus.Level + // how many stack frames to skip before stacktrace starts recording + Skip int + // the number of lines to include around a stack frame for context + Context int + // the prefixes that will be matched against the stack frame. + // if the stack frame's package matches one of these prefixes + // sentry will identify the stack frame as "in_app" + InAppPrefixes []string +} + // NewSentryHook creates a hook to be added to an instance of logger // and initializes the raven client. // This method sets the timeout to 100 milliseconds. @@ -72,7 +89,7 @@ func NewSentryHook(DSN string, levels []logrus.Level) (*SentryHook, error) { if err != nil { return nil, err } - return &SentryHook{100 * time.Millisecond, client, levels}, nil + return NewWithClientSentryHook(client, levels) } // NewWithTagsSentryHook creates a hook with tags to be added to an instance @@ -83,13 +100,24 @@ func NewWithTagsSentryHook(DSN string, tags map[string]string, levels []logrus.L if err != nil { return nil, err } - return &SentryHook{100 * time.Millisecond, client, levels}, nil + return NewWithClientSentryHook(client, levels) } // NewWithClientSentryHook creates a hook using an initialized raven client. // This method sets the timeout to 100 milliseconds. func NewWithClientSentryHook(client *raven.Client, levels []logrus.Level) (*SentryHook, error) { - return &SentryHook{100 * time.Millisecond, client, levels}, nil + return &SentryHook{ + Timeout: 100 * time.Millisecond, + StacktraceConfiguration: stacktraceConfiguration{ + Enable: false, + Level: logrus.ErrorLevel, + Skip: 5, + Context: 0, + InAppPrefixes: nil, + }, + client: client, + levels: levels, + }, nil } // Called when an event should be sent to sentry @@ -115,6 +143,11 @@ func (hook *SentryHook) Fire(entry *logrus.Entry) error { if req, ok := getAndDelRequest(d, "http_request"); ok { packet.Interfaces = append(packet.Interfaces, raven.NewHttp(req)) } + stConfig := &hook.StacktraceConfiguration + if stConfig.Enable && entry.Level <= stConfig.Level { + currentStacktrace := raven.NewStacktrace(stConfig.Skip, stConfig.Context, stConfig.InAppPrefixes) + packet.Interfaces = append(packet.Interfaces, currentStacktrace) + } packet.Extra = map[string]interface{}(d) _, errCh := hook.client.Capture(packet, nil) diff --git a/sentry_test.go b/sentry_test.go index 4a97bc6..d3b063f 100644 --- a/sentry_test.go +++ b/sentry_test.go @@ -1,8 +1,11 @@ package logrus_sentry import ( + "compress/zlib" + "encoding/base64" "encoding/json" "fmt" + "io" "io/ioutil" "net/http" "net/http/httptest" @@ -26,12 +29,27 @@ func getTestLogger() *logrus.Logger { return l } -func WithTestDSN(t *testing.T, tf func(string, <-chan *raven.Packet)) { - pch := make(chan *raven.Packet, 1) +// raven.Packet does not have a json directive for deserializing stacktrace +// so need to explicitly construct one for purpose of test +type resultPacket struct { + raven.Packet + Stacktrace raven.Stacktrace `json:stacktrace` +} + +func WithTestDSN(t *testing.T, tf func(string, <-chan *resultPacket)) { + pch := make(chan *resultPacket, 1) s := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { defer req.Body.Close() - d := json.NewDecoder(req.Body) - p := &raven.Packet{} + contentType := req.Header.Get("Content-Type") + var bodyReader io.Reader = req.Body + // underlying client will compress and encode payload above certain size + if contentType == "application/octet-stream" { + bodyReader = base64.NewDecoder(base64.StdEncoding, bodyReader) + bodyReader, _ = zlib.NewReader(bodyReader) + } + + d := json.NewDecoder(bodyReader) + p := &resultPacket{} err := d.Decode(p) if err != nil { t.Fatal(err.Error()) @@ -51,7 +69,7 @@ func WithTestDSN(t *testing.T, tf func(string, <-chan *raven.Packet)) { } func TestSpecialFields(t *testing.T) { - WithTestDSN(t, func(dsn string, pch <-chan *raven.Packet) { + WithTestDSN(t, func(dsn string, pch <-chan *resultPacket) { logger := getTestLogger() hook, err := NewSentryHook(dsn, []logrus.Level{ @@ -82,7 +100,7 @@ func TestSpecialFields(t *testing.T) { } func TestSentryHandler(t *testing.T) { - WithTestDSN(t, func(dsn string, pch <-chan *raven.Packet) { + WithTestDSN(t, func(dsn string, pch <-chan *resultPacket) { logger := getTestLogger() hook, err := NewSentryHook(dsn, []logrus.Level{ logrus.ErrorLevel, @@ -101,7 +119,7 @@ func TestSentryHandler(t *testing.T) { } func TestSentryWithClient(t *testing.T) { - WithTestDSN(t, func(dsn string, pch <-chan *raven.Packet) { + WithTestDSN(t, func(dsn string, pch <-chan *resultPacket) { logger := getTestLogger() client, _ := raven.New(dsn) @@ -123,7 +141,7 @@ func TestSentryWithClient(t *testing.T) { } func TestSentryTags(t *testing.T) { - WithTestDSN(t, func(dsn string, pch <-chan *raven.Packet) { + WithTestDSN(t, func(dsn string, pch <-chan *resultPacket) { logger := getTestLogger() tags := map[string]string{ "site": "test", @@ -152,3 +170,65 @@ func TestSentryTags(t *testing.T) { } }) } + +func TestSentryStacktrace(t *testing.T) { + WithTestDSN(t, func(dsn string, pch <-chan *resultPacket) { + logger := getTestLogger() + hook, err := NewSentryHook(dsn, []logrus.Level{ + logrus.ErrorLevel, + logrus.InfoLevel, + }) + if err != nil { + t.Fatal(err.Error()) + } + logger.Hooks.Add(hook) + + logger.Error(message) + packet := <-pch + stacktraceSize := len(packet.Stacktrace.Frames) + if stacktraceSize != 0 { + t.Error("Stacktrace should be empty as it is not enabled") + } + + hook.StacktraceConfiguration.Enable = true + + logger.Error(message) // this is the call that the last frame of stacktrace should capture + expectedLineno := 195 //this should be the line number of the previous line + + packet = <-pch + stacktraceSize = len(packet.Stacktrace.Frames) + if stacktraceSize == 0 { + t.Error("Stacktrace should not be empty") + } + lastFrame := packet.Stacktrace.Frames[stacktraceSize-1] + expectedSuffix := "logrus_sentry/sentry_test.go" + if !strings.HasSuffix(lastFrame.Filename, expectedSuffix) { + t.Errorf("File name should have ended with %s, was %s", expectedSuffix, lastFrame.Filename) + } + if lastFrame.Lineno != expectedLineno { + t.Errorf("Line number should have been %s, was %s", expectedLineno, lastFrame.Lineno) + } + if lastFrame.InApp { + t.Error("Frame should not be identified as in_app without prefixes") + } + + hook.StacktraceConfiguration.InAppPrefixes = []string{"github.com/Sirupsen/logrus"} + hook.StacktraceConfiguration.Context = 2 + hook.StacktraceConfiguration.Skip = 2 + + logger.Error(message) + packet = <-pch + stacktraceSize = len(packet.Stacktrace.Frames) + if stacktraceSize == 0 { + t.Error("Stacktrace should not be empty") + } + lastFrame = packet.Stacktrace.Frames[stacktraceSize-1] + expectedFilename := "github.com/Sirupsen/logrus/entry.go" + if lastFrame.Filename != expectedFilename { + t.Errorf("File name should have been %s, was %s", expectedFilename, lastFrame.Filename) + } + if !lastFrame.InApp { + t.Error("Frame should be identified as in_app") + } + }) +}