Repository: hashicorp/go-hclog
Pull request #36 (2019-02-27 11:13:13)

Add a LevelWriter interface to use as leveled writer
The `LevelWriter` interface extends the default `io.Writer` Write function with a `level` parameter:

```go
LevelWrite(level Level, p []byte) error
```

This allows the writer to write log messages to different targets based on the given log level.

svanharmelen: @jefferai and @evanphx I'm curious what you think of this approach? I tried a few other things, but got stuck with being able to dynamically pass the log level. So this seemed like a reasonable solution to me.

Patch:
diff --git a/intlogger.go b/intlogger.go
index 0d9a26e..d32630c 100644
--- a/intlogger.go
+++ b/intlogger.go
@@ -1,14 +1,12 @@
 package hclog
 
 import (
-	"bufio"
 	"bytes"
 	"encoding"
 	"encoding/json"
 	"fmt"
 	"io"
 	"log"
-	"os"
 	"reflect"
 	"runtime"
 	"sort"
@@ -47,7 +45,7 @@ type intLogger struct {
 	// This is a pointer so that it's shared by any derived loggers, since
 	// those derived loggers share the bufio.Writer as well.
 	mutex  *sync.Mutex
-	writer *bufio.Writer
+	writer *writer
 	level  *int32
 
 	implied []interface{}
@@ -61,7 +59,7 @@ func New(opts *LoggerOptions) Logger {
 
 	output := opts.Output
 	if output == nil {
-		output = os.Stderr
+		output = DefaultOutput
 	}
 
 	level := opts.Level
@@ -80,7 +78,7 @@ func New(opts *LoggerOptions) Logger {
 		name:       opts.Name,
 		timeFormat: TimeFormat,
 		mutex:      mutex,
-		writer:     bufio.NewWriter(output),
+		writer:     newWriter(output),
 		level:      new(int32),
 	}
 
@@ -111,7 +109,7 @@ func (l *intLogger) Log(level Level, msg string, args ...interface{}) {
 		l.log(t, level, msg, args...)
 	}
 
-	l.writer.Flush()
+	l.writer.Flush(level)
 }
 
 // Cleanup a path by returning the last 2 segments of the path only.
diff --git a/logger.go b/logger.go
index e823948..0f1f826 100644
--- a/logger.go
+++ b/logger.go
@@ -10,7 +10,7 @@ import (
 
 var (
 	//DefaultOutput is used as the default log output.
-	DefaultOutput = os.Stderr
+	DefaultOutput io.Writer = os.Stderr
 
 	// DefaultLevel is used as the default log level.
 	DefaultLevel = Info
diff --git a/writer.go b/writer.go
new file mode 100644
index 0000000..3cb30a7
--- /dev/null
+++ b/writer.go
@@ -0,0 +1,74 @@
+package hclog
+
+import (
+	"bytes"
+	"io"
+)
+
+type writer struct {
+	b bytes.Buffer
+	w io.Writer
+}
+
+func newWriter(w io.Writer) *writer {
+	return &writer{w: w}
+}
+
+func (w *writer) Flush(level Level) (err error) {
+	if lw, ok := w.w.(LevelWriter); ok {
+		_, err = lw.LevelWrite(level, w.b.Bytes())
+	} else {
+		_, err = w.w.Write(w.b.Bytes())
+	}
+	w.b.Reset()
+	return err
+}
+
+func (w *writer) Write(p []byte) (int, error) {
+	return w.b.Write(p)
+}
+
+func (w *writer) WriteByte(c byte) error {
+	return w.b.WriteByte(c)
+}
+
+func (w *writer) WriteString(s string) (int, error) {
+	return w.b.WriteString(s)
+}
+
+// LevelWriter is the interface that wraps the LevelWrite method.
+type LevelWriter interface {
+	LevelWrite(level Level, p []byte) (n int, err error)
+}
+
+// LeveledWriter writes all log messages to the standard writer,
+// except for log levels that are defined in the overrides map.
+type LeveledWriter struct {
+	standard  io.Writer
+	overrides map[Level]io.Writer
+}
+
+// NewLevelWriter returns an initialized LeveledWriter.
+//
+// standard will be used as the default writer for all log levels,
+// except for log levels that are defined in the overrides map.
+func NewLevelWriter(standard io.Writer, overrides map[Level]io.Writer) *LeveledWriter {
+	return &LeveledWriter{
+		standard:  standard,
+		overrides: overrides,
+	}
+}
+
+// Write implements io.Writer.
+func (lw *LeveledWriter) Write(p []byte) (int, error) {
+	return lw.standard.Write(p)
+}
+
+// LevelWrite implements LevelWriter.
+func (lw *LeveledWriter) LevelWrite(level Level, p []byte) (int, error) {
+	w, ok := lw.overrides[level]
+	if !ok {
+		w = lw.standard
+	}
+	return w.Write(p)
+}


Test patch:
diff --git a/logger_test.go b/logger_test.go
index bf7b08d..e8038c4 100644
--- a/logger_test.go
+++ b/logger_test.go
@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"io"
 	"strconv"
 	"strings"
 	"testing"
@@ -15,6 +16,23 @@ import (
 )
 
 func TestLogger(t *testing.T) {
+	t.Run("uses default output if none is given", func(t *testing.T) {
+		var buf bytes.Buffer
+		DefaultOutput = &buf
+
+		logger := New(&LoggerOptions{
+			Name: "test",
+		})
+
+		logger.Info("this is test", "who", "programmer", "why", "testing")
+
+		str := buf.String()
+		dataIdx := strings.IndexByte(str, ' ')
+		rest := str[dataIdx+1:]
+
+		assert.Equal(t, "[INFO]  test: this is test: who=programmer why=testing\n", rest)
+	})
+
 	t.Run("formats log entries", func(t *testing.T) {
 		var buf bytes.Buffer
 
@@ -26,10 +44,7 @@ func TestLogger(t *testing.T) {
 		logger.Info("this is test", "who", "programmer", "why", "testing")
 
 		str := buf.String()
-
 		dataIdx := strings.IndexByte(str, ' ')
-
-		// ts := str[:dataIdx]
 		rest := str[dataIdx+1:]
 
 		assert.Equal(t, "[INFO]  test: this is test: who=programmer why=testing\n", rest)
@@ -46,10 +61,7 @@ func TestLogger(t *testing.T) {
 		logger.Info("this is test", "who", "programmer", "why", "testing is fun")
 
 		str := buf.String()
-
 		dataIdx := strings.IndexByte(str, ' ')
-
-		// ts := str[:dataIdx]
 		rest := str[dataIdx+1:]
 
 		assert.Equal(t, "[INFO]  test: this is test: who=programmer why=\"testing is fun\"\n", rest)
@@ -66,10 +78,7 @@ func TestLogger(t *testing.T) {
 		logger.Info("this is test", "who", "programmer", "why", []interface{}{"testing", "dev", 1, uint64(5), []int{3, 4}})
 
 		str := buf.String()
-
 		dataIdx := strings.IndexByte(str, ' ')
-
-		// ts := str[:dataIdx]
 		rest := str[dataIdx+1:]
 
 		assert.Equal(t, "[INFO]  test: this is test: who=programmer why=[testing, dev, 1, 5, \"[3 4]\"]\n", rest)
@@ -86,10 +95,7 @@ func TestLogger(t *testing.T) {
 		logger.Info("this is test", "who", "programmer", "why", []string{"testing & qa", "dev"})
 
 		str := buf.String()
-
 		dataIdx := strings.IndexByte(str, ' ')
-
-		// ts := str[:dataIdx]
 		rest := str[dataIdx+1:]
 
 		assert.Equal(t, "[INFO]  test: this is test: who=programmer why=[\"testing & qa\", dev]\n", rest)
@@ -106,7 +112,6 @@ func TestLogger(t *testing.T) {
 		logger.Info("who", "programmer", "why", "testing", Stacktrace())
 
 		lines := strings.Split(buf.String(), "\n")
-
 		require.True(t, len(lines) > 1)
 
 		assert.Equal(t, "github.com/hashicorp/go-hclog.Stacktrace", lines[1])
@@ -123,7 +128,6 @@ func TestLogger(t *testing.T) {
 		logger.Info("who", "programmer", "why", "testing", "foo", Stacktrace())
 
 		lines := strings.Split(buf.String(), "\n")
-
 		require.True(t, len(lines) > 1)
 
 		assert.Equal(t, "github.com/hashicorp/go-hclog.Stacktrace", lines[1])
@@ -141,14 +145,11 @@ func TestLogger(t *testing.T) {
 		logger.Info("this is test", "who", "programmer", "why", "testing is fun")
 
 		str := buf.String()
-
 		dataIdx := strings.IndexByte(str, ' ')
-
-		// ts := str[:dataIdx]
 		rest := str[dataIdx+1:]
 
 		// This test will break if you move this around, it's line dependent, just fyi
-		assert.Equal(t, "[INFO]  go-hclog/logger_test.go:141: test: this is test: who=programmer why=\"testing is fun\"\n", rest)
+		assert.Equal(t, "[INFO]  go-hclog/logger_test.go:145: test: this is test: who=programmer why=\"testing is fun\"\n", rest)
 	})
 
 	t.Run("prefixes the name", func(t *testing.T) {
@@ -187,7 +188,6 @@ func TestLogger(t *testing.T) {
 		logger.Info("this is test", "who", "programmer", "why", "testing is fun")
 
 		str := buf.String()
-
 		dataIdx := strings.IndexByte(str, ' ')
 
 		assert.Equal(t, str[:dataIdx], time.Now().Format(time.Kitchen))
@@ -329,16 +329,75 @@ func TestLogger(t *testing.T) {
 		logger.Info("this is test", "production", Fmt("%d beans/day", 12))
 
 		str := buf.String()
-
 		dataIdx := strings.IndexByte(str, ' ')
-
-		// ts := str[:dataIdx]
 		rest := str[dataIdx+1:]
 
 		assert.Equal(t, "[INFO]  test: this is test: production=\"12 beans/day\"\n", rest)
 	})
 }
 
+func TestLogger_leveledWriter(t *testing.T) {
+	t.Run("writes errors to stderr", func(t *testing.T) {
+		var stderr bytes.Buffer
+		var stdout bytes.Buffer
+
+		logger := New(&LoggerOptions{
+			Name:   "test",
+			Output: NewLevelWriter(&stdout, map[Level]io.Writer{Error: &stderr}),
+		})
+
+		logger.Error("this is an error", "who", "programmer", "why", "testing")
+
+		errStr := stderr.String()
+		errDataIdx := strings.IndexByte(errStr, ' ')
+		errRest := errStr[errDataIdx+1:]
+
+		assert.Equal(t, "[ERROR] test: this is an error: who=programmer why=testing\n", errRest)
+	})
+
+	t.Run("writes non-errors to stdout", func(t *testing.T) {
+		var stderr bytes.Buffer
+		var stdout bytes.Buffer
+
+		logger := New(&LoggerOptions{
+			Name:   "test",
+			Output: NewLevelWriter(&stdout, map[Level]io.Writer{Error: &stderr}),
+		})
+
+		logger.Info("this is test", "who", "programmer", "why", "testing")
+
+		outStr := stdout.String()
+		outDataIdx := strings.IndexByte(outStr, ' ')
+		outRest := outStr[outDataIdx+1:]
+
+		assert.Equal(t, "[INFO]  test: this is test: who=programmer why=testing\n", outRest)
+	})
+
+	t.Run("writes errors and non-errors correctly", func(t *testing.T) {
+		var stderr bytes.Buffer
+		var stdout bytes.Buffer
+
+		logger := New(&LoggerOptions{
+			Name:   "test",
+			Output: NewLevelWriter(&stdout, map[Level]io.Writer{Error: &stderr}),
+		})
+
+		logger.Info("this is test", "who", "programmer", "why", "testing")
+		logger.Error("this is an error", "who", "programmer", "why", "testing")
+
+		errStr := stderr.String()
+		errDataIdx := strings.IndexByte(errStr, ' ')
+		errRest := errStr[errDataIdx+1:]
+
+		outStr := stdout.String()
+		outDataIdx := strings.IndexByte(outStr, ' ')
+		outRest := outStr[outDataIdx+1:]
+
+		assert.Equal(t, "[ERROR] test: this is an error: who=programmer why=testing\n", errRest)
+		assert.Equal(t, "[INFO]  test: this is test: who=programmer why=testing\n", outRest)
+	})
+}
+
 func TestLogger_JSON(t *testing.T) {
 	t.Run("json formatting", func(t *testing.T) {
 		var buf bytes.Buffer