Merge pull request #74 from gigaroby/sentry-hook
Added sentry hook
diff --git a/.travis.yml b/.travis.yml
index d5a559f..c3af3ce 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -7,3 +7,4 @@
- go get github.com/stretchr/testify
- go get github.com/stvp/go-udp-testing
- go get github.com/tobi/airbrake-go
+ - go get github.com/getsentry/raven-go
diff --git a/hooks/sentry/README.md b/hooks/sentry/README.md
new file mode 100644
index 0000000..a409f3b
--- /dev/null
+++ b/hooks/sentry/README.md
@@ -0,0 +1,61 @@
+# Sentry Hook for Logrus <img src="http://i.imgur.com/hTeVwmJ.png" width="40" height="40" alt=":walrus:" class="emoji" title=":walrus:" />
+
+[Sentry](https://getsentry.com) provides both self-hosted and hosted
+solutions for exception tracking.
+Both client and server are
+[open source](https://github.com/getsentry/sentry).
+
+## Usage
+
+Every sentry application defined on the server gets a different
+[DSN](https://www.getsentry.com/docs/). In the example below replace
+`YOUR_DSN` with the one created for your application.
+
+```go
+import (
+ "github.com/Sirupsen/logrus"
+ "github.com/Sirupsen/logrus/hooks/sentry"
+)
+
+func main() {
+ log := logrus.New()
+ hook, err := logrus_sentry.NewSentryHook(YOUR_DSN, []logrus.Level{
+ logrus.PanicLevel,
+ logrus.FatalLevel,
+ logrus.ErrorLevel,
+ })
+
+ if err == nil {
+ log.Hooks.Add(hook)
+ }
+}
+```
+
+## Special fields
+
+Some logrus fields have a special meaning in this hook,
+these are server_name and logger.
+When logs are sent to sentry these fields are treated differently.
+- server_name (also known as hostname) is the name of the server which
+is logging the event (hostname.example.com)
+- logger is the part of the application which is logging the event.
+In go this usually means setting it to the name of the package.
+
+## Timeout
+
+`Timeout` is the time the sentry hook will wait for a response
+from the sentry server.
+
+If this time elapses with no response from
+the server an error will be returned.
+
+If `Timeout` is set to 0 the SentryHook will not wait for a reply
+and will assume a correct delivery.
+
+The SentryHook has a default timeout of `100 milliseconds` when created
+with a call to `NewSentryHook`. This can be changed by assigning a value to the `Timeout` field:
+
+```go
+hook, _ := logrus_sentry.NewSentryHook(...)
+hook.Timeout = 20*time.Seconds
+```
diff --git a/hooks/sentry/sentry.go b/hooks/sentry/sentry.go
new file mode 100644
index 0000000..379f281
--- /dev/null
+++ b/hooks/sentry/sentry.go
@@ -0,0 +1,100 @@
+package logrus_sentry
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/Sirupsen/logrus"
+ "github.com/getsentry/raven-go"
+)
+
+var (
+ severityMap = map[logrus.Level]raven.Severity{
+ logrus.DebugLevel: raven.DEBUG,
+ logrus.InfoLevel: raven.INFO,
+ logrus.WarnLevel: raven.WARNING,
+ logrus.ErrorLevel: raven.ERROR,
+ logrus.FatalLevel: raven.FATAL,
+ logrus.PanicLevel: raven.FATAL,
+ }
+)
+
+func getAndDel(d logrus.Fields, key string) (string, bool) {
+ var (
+ ok bool
+ v interface{}
+ val string
+ )
+ if v, ok = d[key]; !ok {
+ return "", false
+ }
+
+ if val, ok = v.(string); !ok {
+ return "", false
+ }
+ delete(d, key)
+ return val, true
+}
+
+// SentryHook delivers logs to a sentry server.
+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
+
+ client *raven.Client
+ levels []logrus.Level
+}
+
+// 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.
+func NewSentryHook(DSN string, levels []logrus.Level) (*SentryHook, error) {
+ client, err := raven.NewClient(DSN, nil)
+ if err != nil {
+ return nil, err
+ }
+ return &SentryHook{100 * time.Millisecond, client, levels}, nil
+}
+
+// Called when an event should be sent to sentry
+// Special fields that sentry uses to give more information to the server
+// are extracted from entry.Data (if they are found)
+// These fields are: logger and server_name
+func (hook *SentryHook) Fire(entry *logrus.Entry) error {
+ packet := &raven.Packet{
+ Message: entry.Message,
+ Timestamp: raven.Timestamp(entry.Time),
+ Level: severityMap[entry.Level],
+ Platform: "go",
+ }
+
+ d := entry.Data
+
+ if logger, ok := getAndDel(d, "logger"); ok {
+ packet.Logger = logger
+ }
+ if serverName, ok := getAndDel(d, "server_name"); ok {
+ packet.ServerName = serverName
+ }
+ packet.Extra = map[string]interface{}(d)
+
+ _, errCh := hook.client.Capture(packet, nil)
+ timeout := hook.Timeout
+ if timeout != 0 {
+ timeoutCh := time.After(timeout)
+ select {
+ case err := <-errCh:
+ return err
+ case <-timeoutCh:
+ return fmt.Errorf("no response from sentry server in %s", timeout)
+ }
+ }
+ return nil
+}
+
+// Levels returns the available logging levels.
+func (hook *SentryHook) Levels() []logrus.Level {
+ return hook.levels
+}
diff --git a/hooks/sentry/sentry_test.go b/hooks/sentry/sentry_test.go
new file mode 100644
index 0000000..45f18d1
--- /dev/null
+++ b/hooks/sentry/sentry_test.go
@@ -0,0 +1,97 @@
+package logrus_sentry
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/Sirupsen/logrus"
+ "github.com/getsentry/raven-go"
+)
+
+const (
+ message = "error message"
+ server_name = "testserver.internal"
+ logger_name = "test.logger"
+)
+
+func getTestLogger() *logrus.Logger {
+ l := logrus.New()
+ l.Out = ioutil.Discard
+ return l
+}
+
+func WithTestDSN(t *testing.T, tf func(string, <-chan *raven.Packet)) {
+ pch := make(chan *raven.Packet, 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{}
+ err := d.Decode(p)
+ if err != nil {
+ t.Fatal(err.Error())
+ }
+
+ pch <- p
+ }))
+ defer s.Close()
+
+ fragments := strings.SplitN(s.URL, "://", 2)
+ dsn := fmt.Sprintf(
+ "%s://public:secret@%s/sentry/project-id",
+ fragments[0],
+ fragments[1],
+ )
+ tf(dsn, pch)
+}
+
+func TestSpecialFields(t *testing.T) {
+ WithTestDSN(t, func(dsn string, pch <-chan *raven.Packet) {
+ logger := getTestLogger()
+
+ hook, err := NewSentryHook(dsn, []logrus.Level{
+ logrus.ErrorLevel,
+ })
+
+ if err != nil {
+ t.Fatal(err.Error())
+ }
+ logger.Hooks.Add(hook)
+ logger.WithFields(logrus.Fields{
+ "server_name": server_name,
+ "logger": logger_name,
+ }).Error(message)
+
+ packet := <-pch
+ if packet.Logger != logger_name {
+ t.Errorf("logger should have been %s, was %s", logger_name, packet.Logger)
+ }
+
+ if packet.ServerName != server_name {
+ t.Errorf("server_name should have been %s, was %s", server_name, packet.ServerName)
+ }
+ })
+}
+
+func TestSentryHandler(t *testing.T) {
+ WithTestDSN(t, func(dsn string, pch <-chan *raven.Packet) {
+ logger := getTestLogger()
+ hook, err := NewSentryHook(dsn, []logrus.Level{
+ logrus.ErrorLevel,
+ })
+ if err != nil {
+ t.Fatal(err.Error())
+ }
+ logger.Hooks.Add(hook)
+
+ logger.Error(message)
+ packet := <-pch
+ if packet.Message != message {
+ t.Errorf("message should have been %s, was %s", message, packet.Message)
+ }
+ })
+}