Merge pull request #152 from burke/bugsnag-hook

hooks: Add BugSnag hook
diff --git a/README.md b/README.md
index e3b5afa..61ba248 100644
--- a/README.md
+++ b/README.md
@@ -83,7 +83,7 @@
 
   // Use the Airbrake hook to report errors that have Error severity or above to
   // an exception tracker. You can create custom hooks, see the Hooks section.
-  log.AddHook(&logrus_airbrake.AirbrakeHook{})
+  log.AddHook(airbrake.NewHook("https://example.com", "xyz", "development"))
 
   // Use the Bugsnag hook to report errors that have Error severity or above to
   // an exception tracker. You can create custom hooks, see the Hooks section.
@@ -170,43 +170,8 @@
 tracking service on `Error`, `Fatal` and `Panic`, info to StatsD or log to
 multiple places simultaneously, e.g. syslog.
 
-```go
-// Not the real implementation of the Airbrake hook. Just a simple sample.
-import (
-  log "github.com/Sirupsen/logrus"
-)
-
-func init() {
-  log.AddHook(new(AirbrakeHook))
-}
-
-type AirbrakeHook struct{}
-
-// `Fire()` takes the entry that the hook is fired for. `entry.Data[]` contains
-// the fields for the entry. See the Fields section of the README.
-func (hook *AirbrakeHook) Fire(entry *logrus.Entry) error {
-  err := airbrake.Notify(entry.Data["error"].(error))
-  if err != nil {
-    log.WithFields(log.Fields{
-      "source":   "airbrake",
-      "endpoint": airbrake.Endpoint,
-    }).Info("Failed to send error to Airbrake")
-  }
-
-  return nil
-}
-
-// `Levels()` returns a slice of `Levels` the hook is fired for.
-func (hook *AirbrakeHook) Levels() []log.Level {
-  return []log.Level{
-    log.ErrorLevel,
-    log.FatalLevel,
-    log.PanicLevel,
-  }
-}
-```
-
-Logrus comes with built-in hooks. Add those, or your custom hook, in `init`:
+Logrus comes with [built-in hooks](hooks/). Add those, or your custom hook, in
+`init`:
 
 ```go
 import (
@@ -217,7 +182,7 @@
 )
 
 func init() {
-  log.AddHook(new(logrus_airbrake.AirbrakeHook))
+  log.AddHook(airbrake.NewHook("https://example.com", "xyz", "development"))
 
   hook, err := logrus_syslog.NewSyslogHook("udp", "localhost:514", syslog.LOG_INFO, "")
   if err != nil {
@@ -327,6 +292,11 @@
     field to `true`.  To force no colored output even if there is a TTY  set the
     `DisableColors` field to `true`
 * `logrus.JSONFormatter`. Logs fields as JSON.
+* `logrus_logstash.LogstashFormatter`. Logs fields as Logstash Events (http://logstash.net).
+
+    ```go
+      logrus.SetFormatter(&logrus_logstash.LogstashFormatter{Type: “application_name"})
+    ```
 
 Third party logging formatters:
 
diff --git a/examples/hook/hook.go b/examples/hook/hook.go
index 42e7a4c..cb5759a 100644
--- a/examples/hook/hook.go
+++ b/examples/hook/hook.go
@@ -3,21 +3,16 @@
 import (
 	"github.com/Sirupsen/logrus"
 	"github.com/Sirupsen/logrus/hooks/airbrake"
-	"github.com/tobi/airbrake-go"
 )
 
 var log = logrus.New()
 
 func init() {
 	log.Formatter = new(logrus.TextFormatter) // default
-	log.Hooks.Add(new(logrus_airbrake.AirbrakeHook))
+	log.Hooks.Add(airbrake.NewHook("https://example.com", "xyz", "development"))
 }
 
 func main() {
-	airbrake.Endpoint = "https://exceptions.whatever.com/notifier_api/v2/notices.xml"
-	airbrake.ApiKey = "whatever"
-	airbrake.Environment = "production"
-
 	log.WithFields(logrus.Fields{
 		"animal": "walrus",
 		"size":   10,
diff --git a/formatters/logstash/logstash.go b/formatters/logstash/logstash.go
new file mode 100644
index 0000000..34b1ccb
--- /dev/null
+++ b/formatters/logstash/logstash.go
@@ -0,0 +1,48 @@
+package logstash
+
+import (
+	"encoding/json"
+	"fmt"
+	"github.com/Sirupsen/logrus"
+	"time"
+)
+
+// Formatter generates json in logstash format.
+// Logstash site: http://logstash.net/
+type LogstashFormatter struct {
+	Type string // if not empty use for logstash type field.
+}
+
+func (f *LogstashFormatter) Format(entry *logrus.Entry) ([]byte, error) {
+	entry.Data["@version"] = 1
+	entry.Data["@timestamp"] = entry.Time.Format(time.RFC3339)
+
+	// set message field
+	v, ok := entry.Data["message"]
+	if ok {
+		entry.Data["fields.message"] = v
+	}
+	entry.Data["message"] = entry.Message
+
+	// set level field
+	v, ok = entry.Data["level"]
+	if ok {
+		entry.Data["fields.level"] = v
+	}
+	entry.Data["level"] = entry.Level.String()
+
+	// set type field
+	if f.Type != "" {
+		v, ok = entry.Data["type"]
+		if ok {
+			entry.Data["fields.type"] = v
+		}
+		entry.Data["type"] = f.Type
+	}
+
+	serialized, err := json.Marshal(entry.Data)
+	if err != nil {
+		return nil, fmt.Errorf("Failed to marshal fields to JSON, %v", err)
+	}
+	return append(serialized, '\n'), nil
+}
diff --git a/formatters/logstash/logstash_test.go b/formatters/logstash/logstash_test.go
new file mode 100644
index 0000000..d8814a0
--- /dev/null
+++ b/formatters/logstash/logstash_test.go
@@ -0,0 +1,52 @@
+package logstash
+
+import (
+	"bytes"
+	"encoding/json"
+	"github.com/Sirupsen/logrus"
+	"github.com/stretchr/testify/assert"
+	"testing"
+)
+
+func TestLogstashFormatter(t *testing.T) {
+	assert := assert.New(t)
+
+	lf := LogstashFormatter{Type: "abc"}
+
+	fields := logrus.Fields{
+		"message": "def",
+		"level":   "ijk",
+		"type":    "lmn",
+		"one":     1,
+		"pi":      3.14,
+		"bool":    true,
+	}
+
+	entry := logrus.WithFields(fields)
+	entry.Message = "msg"
+	entry.Level = logrus.InfoLevel
+
+	b, _ := lf.Format(entry)
+
+	var data map[string]interface{}
+	dec := json.NewDecoder(bytes.NewReader(b))
+	dec.UseNumber()
+	dec.Decode(&data)
+
+	// base fields
+	assert.Equal(json.Number("1"), data["@version"])
+	assert.NotEmpty(data["@timestamp"])
+	assert.Equal("abc", data["type"])
+	assert.Equal("msg", data["message"])
+	assert.Equal("info", data["level"])
+
+	// substituted fields
+	assert.Equal("def", data["fields.message"])
+	assert.Equal("ijk", data["fields.level"])
+	assert.Equal("lmn", data["fields.type"])
+
+	// formats
+	assert.Equal(json.Number("1"), data["one"])
+	assert.Equal(json.Number("3.14"), data["pi"])
+	assert.Equal(true, data["bool"])
+}
diff --git a/hooks/airbrake/airbrake.go b/hooks/airbrake/airbrake.go
index 75f4db1..b0502c3 100644
--- a/hooks/airbrake/airbrake.go
+++ b/hooks/airbrake/airbrake.go
@@ -1,51 +1,51 @@
-package logrus_airbrake
+package airbrake
 
 import (
+	"errors"
+	"fmt"
+
 	"github.com/Sirupsen/logrus"
 	"github.com/tobi/airbrake-go"
 )
 
 // AirbrakeHook to send exceptions to an exception-tracking service compatible
-// with the Airbrake API. You must set:
-// * airbrake.Endpoint
-// * airbrake.ApiKey
-// * airbrake.Environment
-//
-// Before using this hook, to send an error. Entries that trigger an Error,
-// Fatal or Panic should now include an "error" field to send to Airbrake.
-type AirbrakeHook struct{}
+// with the Airbrake API.
+type airbrakeHook struct {
+	APIKey      string
+	Endpoint    string
+	Environment string
+}
 
-func (hook *AirbrakeHook) Fire(entry *logrus.Entry) error {
-	if entry.Data["error"] == nil {
-		entry.Logger.WithFields(logrus.Fields{
-			"source":   "airbrake",
-			"endpoint": airbrake.Endpoint,
-		}).Warn("Exceptions sent to Airbrake must have an 'error' key with the error")
-		return nil
+func NewHook(endpoint, apiKey, env string) *airbrakeHook {
+	return &airbrakeHook{
+		APIKey:      apiKey,
+		Endpoint:    endpoint,
+		Environment: env,
 	}
+}
 
+func (hook *airbrakeHook) Fire(entry *logrus.Entry) error {
+	airbrake.ApiKey = hook.APIKey
+	airbrake.Endpoint = hook.Endpoint
+	airbrake.Environment = hook.Environment
+
+	var notifyErr error
 	err, ok := entry.Data["error"].(error)
-	if !ok {
-		entry.Logger.WithFields(logrus.Fields{
-			"source":   "airbrake",
-			"endpoint": airbrake.Endpoint,
-		}).Warn("Exceptions sent to Airbrake must have an `error` key of type `error`")
-		return nil
+	if ok {
+		notifyErr = err
+	} else {
+		notifyErr = errors.New(entry.Message)
 	}
 
-	airErr := airbrake.Notify(err)
+	airErr := airbrake.Notify(notifyErr)
 	if airErr != nil {
-		entry.Logger.WithFields(logrus.Fields{
-			"source":   "airbrake",
-			"endpoint": airbrake.Endpoint,
-			"error":    airErr,
-		}).Warn("Failed to send error to Airbrake")
+		return fmt.Errorf("Failed to send error to Airbrake: %s", airErr)
 	}
 
 	return nil
 }
 
-func (hook *AirbrakeHook) Levels() []logrus.Level {
+func (hook *airbrakeHook) Levels() []logrus.Level {
 	return []logrus.Level{
 		logrus.ErrorLevel,
 		logrus.FatalLevel,
diff --git a/hooks/airbrake/airbrake_test.go b/hooks/airbrake/airbrake_test.go
index d2fd61d..058a91e 100644
--- a/hooks/airbrake/airbrake_test.go
+++ b/hooks/airbrake/airbrake_test.go
@@ -1,27 +1,124 @@
-package logrus_airbrake
+package airbrake
 
 import (
 	"encoding/xml"
-	"errors"
 	"net/http"
 	"net/http/httptest"
 	"testing"
 	"time"
 
 	"github.com/Sirupsen/logrus"
-	"github.com/tobi/airbrake-go"
 )
 
 type notice struct {
-	Error struct {
-		Message string `xml:"message"`
-	} `xml:"error"`
+	Error NoticeError `xml:"error"`
+}
+type NoticeError struct {
+	Class   string `xml:"class"`
+	Message string `xml:"message"`
 }
 
-func TestNoticeReceived(t *testing.T) {
-	msg := make(chan string, 1)
-	expectedMsg := "foo"
+type customErr struct {
+	msg string
+}
 
+func (e *customErr) Error() string {
+	return e.msg
+}
+
+const (
+	testAPIKey    = "abcxyz"
+	testEnv       = "development"
+	expectedClass = "*airbrake.customErr"
+	expectedMsg   = "foo"
+	unintendedMsg = "Airbrake will not see this string"
+)
+
+var (
+	noticeError = make(chan NoticeError, 1)
+)
+
+// TestLogEntryMessageReceived checks if invoking Logrus' log.Error
+// method causes an XML payload containing the log entry message is received
+// by a HTTP server emulating an Airbrake-compatible endpoint.
+func TestLogEntryMessageReceived(t *testing.T) {
+	log := logrus.New()
+	ts := startAirbrakeServer(t)
+	defer ts.Close()
+
+	hook := NewHook(ts.URL, testAPIKey, "production")
+	log.Hooks.Add(hook)
+
+	log.Error(expectedMsg)
+
+	select {
+	case received := <-noticeError:
+		if received.Message != expectedMsg {
+			t.Errorf("Unexpected message received: %s", received.Message)
+		}
+	case <-time.After(time.Second):
+		t.Error("Timed out; no notice received by Airbrake API")
+	}
+}
+
+// TestLogEntryMessageReceived confirms that, when passing an error type using
+// logrus.Fields, a HTTP server emulating an Airbrake endpoint receives the
+// error message returned by the Error() method on the error interface
+// rather than the logrus.Entry.Message string.
+func TestLogEntryWithErrorReceived(t *testing.T) {
+	log := logrus.New()
+	ts := startAirbrakeServer(t)
+	defer ts.Close()
+
+	hook := NewHook(ts.URL, testAPIKey, "production")
+	log.Hooks.Add(hook)
+
+	log.WithFields(logrus.Fields{
+		"error": &customErr{expectedMsg},
+	}).Error(unintendedMsg)
+
+	select {
+	case received := <-noticeError:
+		if received.Message != expectedMsg {
+			t.Errorf("Unexpected message received: %s", received.Message)
+		}
+		if received.Class != expectedClass {
+			t.Errorf("Unexpected error class: %s", received.Class)
+		}
+	case <-time.After(time.Second):
+		t.Error("Timed out; no notice received by Airbrake API")
+	}
+}
+
+// TestLogEntryWithNonErrorTypeNotReceived confirms that, when passing a
+// non-error type using logrus.Fields, a HTTP server emulating an Airbrake
+// endpoint receives the logrus.Entry.Message string.
+//
+// Only error types are supported when setting the 'error' field using
+// logrus.WithFields().
+func TestLogEntryWithNonErrorTypeNotReceived(t *testing.T) {
+	log := logrus.New()
+	ts := startAirbrakeServer(t)
+	defer ts.Close()
+
+	hook := NewHook(ts.URL, testAPIKey, "production")
+	log.Hooks.Add(hook)
+
+	log.WithFields(logrus.Fields{
+		"error": expectedMsg,
+	}).Error(unintendedMsg)
+
+	select {
+	case received := <-noticeError:
+		if received.Message != unintendedMsg {
+			t.Errorf("Unexpected message received: %s", received.Message)
+		}
+	case <-time.After(time.Second):
+		t.Error("Timed out; no notice received by Airbrake API")
+	}
+}
+
+func startAirbrakeServer(t *testing.T) *httptest.Server {
 	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		var notice notice
 		if err := xml.NewDecoder(r.Body).Decode(&notice); err != nil {
@@ -29,29 +126,8 @@
 		}
 		r.Body.Close()
 
-		msg <- notice.Error.Message
+		noticeError <- notice.Error
 	}))
-	defer ts.Close()
 
-	hook := &AirbrakeHook{}
-
-	airbrake.Environment = "production"
-	airbrake.Endpoint = ts.URL
-	airbrake.ApiKey = "foo"
-
-	log := logrus.New()
-	log.Hooks.Add(hook)
-
-	log.WithFields(logrus.Fields{
-		"error": errors.New(expectedMsg),
-	}).Error("Airbrake will not see this string")
-
-	select {
-	case received := <-msg:
-		if received != expectedMsg {
-			t.Errorf("Unexpected message received: %s", received)
-		}
-	case <-time.After(time.Second):
-		t.Error("Timed out; no notice received by Airbrake API")
-	}
+	return ts
 }
diff --git a/json_formatter.go b/json_formatter.go
index 0e38a61..5c4c44b 100644
--- a/json_formatter.go
+++ b/json_formatter.go
@@ -11,11 +11,12 @@
 func (f *JSONFormatter) Format(entry *Entry) ([]byte, error) {
 	data := make(Fields, len(entry.Data)+3)
 	for k, v := range entry.Data {
-		// Otherwise errors are ignored by `encoding/json`
-		// https://github.com/Sirupsen/logrus/issues/137
-		if err, ok := v.(error); ok {
-			data[k] = err.Error()
-		} else {
+		switch v := v.(type) {
+		case error:
+			// Otherwise errors are ignored by `encoding/json`
+			// https://github.com/Sirupsen/logrus/issues/137
+			data[k] = v.Error()
+		default:
 			data[k] = v
 		}
 	}
diff --git a/terminal_openbsd.go b/terminal_openbsd.go
index d238bfa..af609a5 100644
--- a/terminal_openbsd.go
+++ b/terminal_openbsd.go
@@ -1,4 +1,3 @@
-
 package logrus
 
 import "syscall"
diff --git a/text_formatter.go b/text_formatter.go
index 71dcb66..0a06a11 100644
--- a/text_formatter.go
+++ b/text_formatter.go
@@ -3,7 +3,6 @@
 import (
 	"bytes"
 	"fmt"
-	"regexp"
 	"sort"
 	"strings"
 	"time"
@@ -21,7 +20,6 @@
 var (
 	baseTimestamp time.Time
 	isTerminal    bool
-	noQuoteNeeded *regexp.Regexp
 )
 
 func init() {
diff --git a/writer.go b/writer.go
index 90d3e01..1e30b1c 100644
--- a/writer.go
+++ b/writer.go
@@ -6,7 +6,7 @@
 	"runtime"
 )
 
-func (logger *Logger) Writer() (*io.PipeWriter) {
+func (logger *Logger) Writer() *io.PipeWriter {
 	reader, writer := io.Pipe()
 
 	go logger.writerScanner(reader)