Skip to content

Commit eebd559

Browse files
committed
Handle fields that don't support marshal to json
1 parent b3f0009 commit eebd559

2 files changed

Lines changed: 139 additions & 1 deletion

File tree

slog_handler.go

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package ghostferry
22

33
import (
44
"context"
5+
"fmt"
56
"log/slog"
7+
"reflect"
68
)
79

810
// loggerSlogHandler implements slog.Handler on top of a ghostferry Logger.
@@ -99,7 +101,98 @@ func applySlogAttrToLogger(l Logger, a slog.Attr, prefix string) Logger {
99101
if a.Key == "" {
100102
return l
101103
}
102-
return l.WithField(joinSlogPrefix(prefix, a.Key), a.Value.Any())
104+
return l.WithField(joinSlogPrefix(prefix, a.Key), safeFieldValue(a.Value.Any()))
105+
}
106+
107+
// safeFieldValue returns a value safe to hand to a structured-logging backend.
108+
//
109+
// Some third-party libraries log values that cannot be JSON-encoded. For
110+
// example, go-mysql's BinlogSyncer logs its entire config via
111+
// slog.Any("config", cfg), and that config contains func fields
112+
// (Option func(*client.Conn) error, Dialer). logrus's JSON formatter calls
113+
// json.Marshal on each field and fails on such values, printing
114+
// "Failed to obtain reader, failed to marshal fields to JSON, json:
115+
// unsupported type: func(*client.Conn) error" to stderr for every binlog
116+
// syncer created — which floods the test logs.
117+
//
118+
// To stay backend-agnostic, any value that contains an unmarshalable kind
119+
// (func, chan, or unsafe.Pointer) is rendered to a string via fmt instead of
120+
// being passed through as a live Go value. Ordinary values are returned
121+
// unchanged so normal structured fields are unaffected.
122+
func safeFieldValue(v any) any {
123+
if v == nil {
124+
return nil
125+
}
126+
if containsUnmarshalableKind(reflect.ValueOf(v), 0) {
127+
return fmt.Sprintf("%+v", v)
128+
}
129+
return v
130+
}
131+
132+
// containsUnmarshalableKind reports whether v contains a func, chan, or
133+
// unsafe.Pointer anywhere in its (possibly nested) structure. It guards
134+
// against unbounded recursion with a depth limit and treats anything beyond it
135+
// as unmarshalable so the value is stringified rather than risk a marshal
136+
// failure.
137+
func containsUnmarshalableKind(v reflect.Value, depth int) bool {
138+
if !v.IsValid() {
139+
return false
140+
}
141+
if depth > 8 {
142+
return true
143+
}
144+
145+
switch v.Kind() {
146+
case reflect.Func, reflect.Chan, reflect.UnsafePointer:
147+
return true
148+
case reflect.Ptr, reflect.Interface:
149+
if v.IsNil() {
150+
return false
151+
}
152+
return containsUnmarshalableKind(v.Elem(), depth+1)
153+
case reflect.Struct:
154+
for i := 0; i < v.NumField(); i++ {
155+
if containsUnmarshalableKind(v.Field(i), depth+1) {
156+
return true
157+
}
158+
}
159+
return false
160+
case reflect.Slice, reflect.Array:
161+
elem := v.Type().Elem()
162+
if isUnmarshalableType(elem) {
163+
return true
164+
}
165+
for i := 0; i < v.Len(); i++ {
166+
if containsUnmarshalableKind(v.Index(i), depth+1) {
167+
return true
168+
}
169+
}
170+
return false
171+
case reflect.Map:
172+
if isUnmarshalableType(v.Type().Elem()) || isUnmarshalableType(v.Type().Key()) {
173+
return true
174+
}
175+
for _, k := range v.MapKeys() {
176+
if containsUnmarshalableKind(v.MapIndex(k), depth+1) {
177+
return true
178+
}
179+
}
180+
return false
181+
default:
182+
return false
183+
}
184+
}
185+
186+
// isUnmarshalableType reports whether a type is (or trivially contains) a kind
187+
// that cannot be JSON-encoded. Used to short-circuit empty containers whose
188+
// element type alone makes them unsafe.
189+
func isUnmarshalableType(t reflect.Type) bool {
190+
switch t.Kind() {
191+
case reflect.Func, reflect.Chan, reflect.UnsafePointer:
192+
return true
193+
default:
194+
return false
195+
}
103196
}
104197

105198
// joinSlogPrefix concatenates prefix and key with a dot, eliding the dot when

test/go/logger_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,51 @@ func (s *LoggerTestSuite) TestNewSlogLoggerOutputIsValidJSON() {
614614
}
615615
}
616616

617+
// configWithFunc mimics the shape of go-mysql's BinlogSyncerConfig, which is
618+
// logged via slog.Any("config", cfg) and contains func fields that cannot be
619+
// JSON-encoded. See safeFieldValue in slog_handler.go.
620+
type configWithFunc struct {
621+
ServerID uint32
622+
Host string
623+
Option func(conn *struct{}) error
624+
}
625+
626+
// TestNewSlogLoggerHandlesUnmarshalableFields is a regression test for the
627+
// "Failed to obtain reader, failed to marshal fields to JSON, json:
628+
// unsupported type: func(...)" noise that flooded the logs whenever go-mysql's
629+
// BinlogSyncer logged its config (which contains func fields) through our slog
630+
// bridge with the logrus JSON formatter.
631+
func (s *LoggerTestSuite) TestNewSlogLoggerHandlesUnmarshalableFields() {
632+
for _, b := range backends {
633+
s.Run(string(b), func() {
634+
var buf bytes.Buffer
635+
useBackend(b, &buf)
636+
ghostferry.SetLogJSONFormatter()
637+
638+
cfg := configWithFunc{
639+
ServerID: 42,
640+
Host: "localhost",
641+
Option: func(_ *struct{}) error { return nil },
642+
}
643+
644+
sl := ghostferry.NewSlogLogger(ghostferry.LogWithField("tag", "slog_unmarshalable"))
645+
sl.Info("create BinlogSyncer", slog.Any("config", cfg))
646+
647+
out := buf.String()
648+
s.Require().NotContains(out, "failed to marshal fields to JSON", "log line: %s", out)
649+
s.Require().NotContains(out, "unsupported type", "log line: %s", out)
650+
651+
var parsed map[string]any
652+
err := json.Unmarshal(buf.Bytes(), &parsed)
653+
s.Require().NoError(err, "output should be valid JSON: %s", out)
654+
s.Require().Equal("create BinlogSyncer", parsed["msg"])
655+
// The config is stringified, so it should be present as a string
656+
// field and include the non-func data.
657+
s.Require().Contains(fmt.Sprintf("%v", parsed["config"]), "localhost")
658+
})
659+
}
660+
}
661+
617662
func TestLoggerTestSuite(t *testing.T) {
618663
suite.Run(t, new(LoggerTestSuite))
619664
}

0 commit comments

Comments
 (0)