Skip to content

Commit

Permalink
Recursive json serialization for structured attributes (log.Any) (#50)
Browse files Browse the repository at this point in the history
Recursive json serialization for structured attributes (log.Any) and also:
- Handles both errors with exported struct fields (as sub objects) and the ones who do not (calling .Error() to get the JSON string)
- Correctly handles nil as JSON null

Of note: performance is somewhat worse
  • Loading branch information
ldemailly authored Nov 20, 2023
1 parent ea76ac3 commit 71b9af3
Show file tree
Hide file tree
Showing 3 changed files with 64 additions and 40 deletions.
4 changes: 2 additions & 2 deletions http_logging_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ func TestLogRequest(t *testing.T) {
w.Flush()
actual := b.String()
//nolint: lll
expected := `{"level":"info","msg":"test1","method":"","url":"<nil>","proto":"","remote_addr":"","host":"foo-host:123","header.x-forwarded-proto":"","header.x-forwarded-for":"","user-agent":"","tls":true,"tls.peer_cn":"x\nyz","header.foo":"bar1,bar2"}
{"level":"info","msg":"test2","method":"","url":"<nil>","proto":"","remote_addr":"","host":"foo-host:123","header.x-forwarded-proto":"","header.x-forwarded-for":"","user-agent":"","extra1":"v1","extra2":"v2"}
expected := `{"level":"info","msg":"test1","method":"","url":null,"proto":"","remote_addr":"","host":"foo-host:123","header.x-forwarded-proto":"","header.x-forwarded-for":"","user-agent":"","tls":true,"tls.peer_cn":"x\nyz","header.foo":"bar1,bar2"}
{"level":"info","msg":"test2","method":"","url":null,"proto":"","remote_addr":"","host":"foo-host:123","header.x-forwarded-proto":"","header.x-forwarded-for":"","user-agent":"","extra1":"v1","extra2":"v2"}
`
if actual != expected {
t.Errorf("unexpected:\n%s\nvs:\n%s\n", actual, expected)
Expand Down
54 changes: 16 additions & 38 deletions logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ package log // import "fortio.org/log"

import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"math"
"os"
"runtime"
"sort"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -589,53 +589,31 @@ type ValueType[T ValueTypes] struct {
Val T
}

func arrayToString(s []interface{}) string {
var buf strings.Builder
buf.WriteString("[")
for i, e := range s {
if i != 0 {
buf.WriteString(",")
}
vv := ValueType[interface{}]{Val: e}
buf.WriteString(vv.String())
}
buf.WriteString("]")
return buf.String()
}

func mapToString(s map[string]interface{}) string {
var buf strings.Builder
buf.WriteString("{")
keys := make([]string, 0, len(s))
for k := range s {
keys = append(keys, k)
func toJSON(v any) string {
bytes, err := json.Marshal(v)
if err != nil {
return strconv.Quote(fmt.Sprintf("ERR marshaling %v: %v", v, err))
}
sort.Strings(keys)
for i, k := range keys {
if i != 0 {
buf.WriteString(",")
}
buf.WriteString(fmt.Sprintf("%q", k))
buf.WriteString(":")
vv := ValueType[interface{}]{Val: s[k]}
buf.WriteString(vv.String())
str := string(bytes)
// This is kinda hacky way to handle both structured and custom serialization errors, and
// struct with no public fields for which we need to call Error() to get a useful string.
if e, isError := v.(error); isError && str == "{}" {
return fmt.Sprintf("%q", e.Error())
}
buf.WriteString("}")
return buf.String()
return str
}

func (v ValueType[T]) String() string {
// if the type is numeric, use Sprint(v.val) otherwise use Sprintf("%q", v.Val) to quote it.
switch s := any(v.Val).(type) {
case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64,
float32, float64:
return fmt.Sprint(v.Val)
case []interface{}:
return arrayToString(s)
case map[string]interface{}:
return mapToString(s)
return fmt.Sprint(s)
case string:
return fmt.Sprintf("%q", s)
/* It's all handled by json fallback now even though slightly more expensive at runtime, it's a lot simpler */
default:
return fmt.Sprintf("%q", fmt.Sprint(v.Val))
return toJSON(v.Val) // was fmt.Sprintf("%q", fmt.Sprint(v.Val))
}
}

Expand Down
46 changes: 46 additions & 0 deletions logger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,52 @@ func TestNoLevel(t *testing.T) {
}
}

type customError struct {
Msg string
Code int
}

func (e customError) Error() string {
return fmt.Sprintf("custom error %s (code %d)", e.Msg, e.Code)
}

func TestSerializationOfError(t *testing.T) {
var err error
kv := Any("err", err)
kvStr := kv.StringValue()
expected := `null`
if kvStr != expected {
t.Errorf("unexpected:\n%s\nvs:\n%s\n", kvStr, expected)
}
err = fmt.Errorf("test error")
Errf("Error on purpose: %v", err)
S(Error, "Error on purpose", Any("err", err))
kv = Any("err", err)
kvStr = kv.StringValue()
expected = `"test error"`
if kvStr != expected {
t.Errorf("unexpected:\n%s\nvs:\n%s\n", kvStr, expected)
}
err = customError{Msg: "custom error", Code: 42}
kv = Any("err", err)
kvStr = kv.StringValue()
expected = `{"Msg":"custom error","Code":42}`
if kvStr != expected {
t.Errorf("unexpected:\n%s\nvs:\n%s\n", kvStr, expected)
}
}

func TestToJSON_MarshalError(t *testing.T) {
badValue := make(chan int)

expected := fmt.Sprintf("\"ERR marshaling %v: %v\"", badValue, "json: unsupported type: chan int")
actual := toJSON(badValue)

if actual != expected {
t.Errorf("Expected %q, got %q", expected, actual)
}
}

// io.Discard but specially known to by logger optimizations for instance.
type discard struct{}

Expand Down

0 comments on commit 71b9af3

Please sign in to comment.