Repository: hashicorp/go-hclog
Pull request #18 (2018-04-02 16:58:47)

Add hclog.Fmt to make it easier when doing string formatting
A common complaint about hclog is that it doesn't do Sprintf logging at all, which makes it a bit more combersome that other loggers. This change bridges that gap by allowing code like:

```
logger.Info("detected new production information", "production", hclog.Fmt("%d beans/day", beansPerDay))
```




Patch:
diff --git a/README.md b/README.md
index 614342b..1153e28 100644
--- a/README.md
+++ b/README.md
@@ -10,8 +10,7 @@ interface for use in development and production environments.
 It provides logging levels that provide decreased output based upon the
 desired amount of output, unlike the standard library `log` package.
 
-It does not provide `Printf` style logging, only key/value logging that is
-exposed as arguments to the logging functions for simplicity.
+It provides `Printf` style logging of values via `hclog.Fmt()`.
 
 It provides a human readable output mode for use in development as well as
 JSON output mode for production.
@@ -100,6 +99,17 @@ requestLogger.Info("we are transporting a request")
 This allows sub Loggers to be context specific without having to thread that
 into all the callers.
 
+### Using `hclog.Fmt()`
+
+```go
+var int totalBandwidth = 200
+appLogger.Info("total bandwidth exceeded", "bandwidth", hclog.Fmt("%d GB/s", totalBandwidth))
+```
+
+```text
+... [INFO ] my-app: total bandwidth exceeded: bandwidth="200 GB/s"
+```
+
 ### Use this with code that uses the standard library logger
 
 If you want to use the standard library's `log.Logger` interface you can wrap
diff --git a/int.go b/int.go
index 340cfdf..0166c3d 100644
--- a/int.go
+++ b/int.go
@@ -214,6 +214,8 @@ func (z *intLogger) log(t time.Time, level Level, msg string, args ...interface{
 			case CapturedStacktrace:
 				stacktrace = st
 				continue FOR
+			case Format:
+				val = fmt.Sprintf(st[0].(string), st[1:]...)
 			default:
 				val = fmt.Sprintf("%v", st)
 			}
@@ -294,16 +296,20 @@ func (z *intLogger) logJson(t time.Time, level Level, msg string, args ...interf
 				continue
 			}
 			val := args[i+1]
-			// Check if val is of type error. If error type doesn't
-			// implement json.Marshaler or encoding.TextMarshaler
-			// then set val to err.Error() so that it gets marshaled
-			if err, ok := val.(error); ok {
-				switch err.(type) {
+			switch sv := val.(type) {
+			case error:
+				// Check if val is of type error. If error type doesn't
+				// implement json.Marshaler or encoding.TextMarshaler
+				// then set val to err.Error() so that it gets marshaled
+				switch sv.(type) {
 				case json.Marshaler, encoding.TextMarshaler:
 				default:
-					val = err.Error()
+					val = sv.Error()
 				}
+			case Format:
+				val = fmt.Sprintf(sv[0].(string), sv[1:]...)
 			}
+
 			vals[args[i].(string)] = val
 		}
 	}
diff --git a/log.go b/log.go
index 6be9839..3629248 100644
--- a/log.go
+++ b/log.go
@@ -37,6 +37,18 @@ const (
 	Error Level = 5
 )
 
+// When processing a value of this type, the logger automatically treats the first
+// argument as a Printf formatting string and passes the rest as the values to be
+// formatted. For example: L.Info(Fmt{"%d beans/day", beans}). This is a simple
+// convience type for when formatting is required.
+type Format []interface{}
+
+// Fmt returns a Format type. This is a convience function for creating a Format
+// type.
+func Fmt(str string, args ...interface{}) Format {
+	return append(Format{str}, args...)
+}
+
 // LevelFromString returns a Level type for the named log level, or "NoLevel" if
 // the level string is invalid. This facilitates setting the log level via
 // config or environment variable by name in a predictable way.


Test patch:
diff --git a/logger_test.go b/logger_test.go
index bb233e9..d8c143f 100644
--- a/logger_test.go
+++ b/logger_test.go
@@ -211,6 +211,26 @@ func TestLogger(t *testing.T) {
 		dataIdx = strings.IndexByte(output, ' ')
 		assert.Equal(t, "[INFO ] with_test: derived_test: a=1 b=2 c=3 cat=30\n", output[dataIdx+1:])
 	})
+
+	t.Run("supports Printf style expansions when requested", func(t *testing.T) {
+		var buf bytes.Buffer
+
+		logger := New(&LoggerOptions{
+			Name:   "test",
+			Output: &buf,
+		})
+
+		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_JSON(t *testing.T) {
@@ -284,6 +304,7 @@ func TestLogger_JSON(t *testing.T) {
 		assert.Equal(t, "programmer", raw["who"])
 		assert.Equal(t, errMsg.Error(), raw["err"])
 	})
+
 	t.Run("json formatting custom error type json marshaler", func(t *testing.T) {
 		var buf bytes.Buffer
 
@@ -316,6 +337,7 @@ func TestLogger_JSON(t *testing.T) {
 		assert.Equal(t, "programmer", raw["who"])
 		assert.Equal(t, expectedMsg, raw["err"])
 	})
+
 	t.Run("json formatting custom error type text marshaler", func(t *testing.T) {
 		var buf bytes.Buffer
 
@@ -345,6 +367,28 @@ func TestLogger_JSON(t *testing.T) {
 		assert.Equal(t, "programmer", raw["who"])
 		assert.Equal(t, expectedMsg, raw["err"])
 	})
+
+	t.Run("supports Printf style expansions when requested", func(t *testing.T) {
+		var buf bytes.Buffer
+
+		logger := New(&LoggerOptions{
+			Name:       "test",
+			Output:     &buf,
+			JSONFormat: true,
+		})
+
+		logger.Info("this is test", "production", Fmt("%d beans/day", 12))
+
+		b := buf.Bytes()
+
+		var raw map[string]interface{}
+		if err := json.Unmarshal(b, &raw); err != nil {
+			t.Fatal(err)
+		}
+
+		assert.Equal(t, "this is test", raw["@message"])
+		assert.Equal(t, "12 beans/day", raw["production"])
+	})
 }
 
 type customErrJSON struct {