From 7fd88a876d3a9bd793602f4a935ecc4e9c400c1b Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Mon, 27 Jan 2025 16:11:13 -0600 Subject: [PATCH] Redesigned the way WithXXX() works The new design passes an atomic pointer to the named logger's base handler, and a slice of transformer functions which can rebuild the final handler by replaying a sequence of WithGroup and WithAttrs calls. Each flume.handler now stores a copy of the base delegate's pointer, and a copy of the final delegate, run through the transformers. Each time it logs a record, it checks whether the base pointer has changed, and if so, re-builds and caches the final delegate. This design has many advantages, and one cost. The cost is that it does two atomic.Pointer.Load() calls per log, but in bench testing that is only adding about 1ns overhead (more than 10x faster than a mutex). The advantages are: - no references from parents to children, meaning no need to implement weakrefs - don't need to track groups and attrs the child handlers - fewer locks - changing a setting in the Controller is a bit faster...the new sink is propagated lazily to child handlers --- v2/TODO.md | 5 +- v2/conf.go | 62 ++++--------------- v2/config.go | 6 +- v2/controller.go | 3 +- v2/handler.go | 112 +++++++++++++++------------------ v2/handler_test.go | 143 +++++++++++++++---------------------------- v2/v1_compat_test.go | 2 +- 7 files changed, 123 insertions(+), 210 deletions(-) diff --git a/v2/TODO.md b/v2/TODO.md index e668c29..344f942 100644 --- a/v2/TODO.md +++ b/v2/TODO.md @@ -41,5 +41,6 @@ - [ ] Should there be a bridge from v1 to v2? Add a way to direct all v1 calls to v2 calls? - Working on a slog handler -> zap core bridge, and a zap core -> slog handler bridge - [ ] A gofix style tool to migrate a codebase from v1 to v2, and migrating from Log() to LogCtx() calls? -- [ ] I think the states need to be re-organized back into a parent-child graph, and sinks need to trickle down that tree. Creating all the handlers and states in conf isn't working the way it was intended. Rebuilding the leaf handlers is grouping the cached attrs wrong (need tests to verify this), and is also inefficient, since it creates inefficient calls to the sink's WithAttrs() -- [ ] Add a middleware which supports ReplaceAttr. Could be used to add ReplaceAttr support to Handlers which don't natively support it \ No newline at end of file +- [x] I think the states need to be re-organized back into a parent-child graph, and sinks need to trickle down that tree. Creating all the handlers and states in conf isn't working the way it was intended. Rebuilding the leaf handlers is grouping the cached attrs wrong (need tests to verify this), and is also inefficient, since it creates inefficient calls to the sink's WithAttrs() +- [ ] Add a middleware which supports ReplaceAttr. Could be used to add ReplaceAttr support to Handlers which don't natively support it + - [ ] We could then promote ReplaceAttr support to the root of Config. If the selected handler natively supports ReplaceAttr, great, otherwise we can add the middleware. To support this, change the way handlers are registered with Config, so that each registration provides a factory method for building the handler, which can take the Config object, and adapt it to the native options that handler supports. \ No newline at end of file diff --git a/v2/conf.go b/v2/conf.go index 17251f2..38f6f64 100644 --- a/v2/conf.go +++ b/v2/conf.go @@ -2,28 +2,22 @@ package flume import ( "log/slog" - "runtime" - "sync" + "sync/atomic" ) type conf struct { - name string - lvl *slog.LevelVar - customLvl bool + name string + lvl *slog.LevelVar + customLvl, customSink bool // sink is the ultimate, final handler // delegate is the sink wrapped with middleware - sink, delegate slog.Handler - customSink bool - sync.Mutex - states map[*state]struct{} + sink slog.Handler + delegatePtr atomic.Pointer[slog.Handler] middleware []Middleware globalMiddleware []Middleware } func (c *conf) setSink(sink slog.Handler, isDefault bool) { - c.Lock() - defer c.Unlock() - if c.customSink && isDefault { return } @@ -51,11 +45,9 @@ func (c *conf) rebuildDelegate() { h = c.globalMiddleware[i].Apply(h) } - c.delegate = h + h = h.WithAttrs([]slog.Attr{slog.String(LoggerKey, c.name)}) - for s := range c.states { - s.setDelegate(c.delegate) - } + c.delegatePtr.Store(&h) } func (c *conf) setLevel(l slog.Level, isDefault bool) { @@ -72,50 +64,20 @@ func (c *conf) setLevel(l slog.Level, isDefault bool) { } func (c *conf) use(middleware ...Middleware) { - c.Lock() - defer c.Unlock() - c.middleware = append(c.middleware, middleware...) c.rebuildDelegate() } func (c *conf) setGlobalMiddleware(middleware []Middleware) { - c.Lock() - defer c.Unlock() - c.globalMiddleware = middleware c.rebuildDelegate() } -func (c *conf) newHandler(attrs []slog.Attr, groups []string) *handler { - c.Lock() - defer c.Unlock() - - s := &state{ - attrs: attrs, - groups: groups, - level: c.lvl, - conf: c, +func (c *conf) handler() slog.Handler { + return &handler{ + basePtr: &c.delegatePtr, + level: c.lvl, } - s.setDelegate(c.delegate) - - c.states[s] = struct{}{} - - h := &handler{ - state: s, - } - - // when the handler goes out of scope, run a finalizer which - // removes the state reference from its parent state, allowing - // it to be gc'd - runtime.SetFinalizer(h, func(_ *handler) { - c.Lock() - defer c.Unlock() - - delete(c.states, s) - }) - - return h } diff --git a/v2/config.go b/v2/config.go index 23cdd7a..185e5d3 100644 --- a/v2/config.go +++ b/v2/config.go @@ -87,6 +87,7 @@ type Config struct { const ( EncodingJSON = "json" EncodingText = "text" + EncodingTerm = "term" EncodingTermColor = "term-color" ) @@ -260,8 +261,9 @@ func (c Config) Handler() slog.Handler { switch c.Encoding { case "text", "console": handler = slog.NewTextHandler(out, &opts) - case "term": + case EncodingTerm: handler = console.NewHandler(out, &console.HandlerOptions{ + NoColor: true, AddSource: c.AddSource, Theme: console.NewDefaultTheme(), ReplaceAttr: ChainReplaceAttrs(c.ReplaceAttrs...), @@ -269,7 +271,7 @@ func (c Config) Handler() slog.Handler { Headers: []string{LoggerKey}, HeaderWidth: 13, }) - case "term-color": + case EncodingTermColor: handler = console.NewHandler(out, &console.HandlerOptions{ AddSource: c.AddSource, Theme: console.NewDefaultTheme(), diff --git a/v2/controller.go b/v2/controller.go index 0a1b49f..eb96f2d 100644 --- a/v2/controller.go +++ b/v2/controller.go @@ -59,7 +59,7 @@ func (c *Controller) Handler(name string) slog.Handler { c.mutex.Lock() defer c.mutex.Unlock() - return c.conf(name).newHandler([]slog.Attr{slog.String(LoggerKey, name)}, nil) + return c.conf(name).handler() } // conf locates or creates a new conf for the given name. The Controller @@ -75,7 +75,6 @@ func (c *Controller) conf(name string) *conf { cfg = &conf{ name: name, lvl: levelVar, - states: map[*state]struct{}{}, globalMiddleware: c.defaultMiddleware, } cfg.setSink(c.defaultSink, true) diff --git a/v2/handler.go b/v2/handler.go index c370c07..03f74ec 100644 --- a/v2/handler.go +++ b/v2/handler.go @@ -8,86 +8,76 @@ import ( ) type handler struct { - *state -} + // atomic pointer to the base delegate + basePtr *atomic.Pointer[slog.Handler] -type state struct { - // attrs associated with this handler. immutable - attrs []slog.Attr - // group associated with this handler. immutable - groups []string + // atomic pointer to a memoized copy of the base + // delegate, plus any transformations (i.e. WithGroup or WithAttrs calls) + memoPrt atomic.Pointer[[2]*slog.Handler] - // atomic pointer to handler delegate - delegatePtr atomic.Pointer[slog.Handler] + // list of WithGroup/WithAttrs transformations. Can be re-applied + // to the base delegate any time it changes + transformers []func(slog.Handler) slog.Handler // should be reference to the levelVar in the parent conf level *slog.LevelVar - - conf *conf } -func (s *state) setDelegate(delegate slog.Handler) { - // re-apply groups and attrs settings - // don't need to check if s.attrs is empty: it will never be empty because - // all flume handlers have at least a "logger_name" attribute - // TODO: I think this logic is wrong. this assumes - // that all these attrs are either nested in *no* groups, or - // nested in *all* the groups. Really, each attr will only - // be nested in whatever set of groups was active when that - // attr was first added. - // TODO: Need to go back to each state holding pointers to - // children, and trickling delegate or conf changes down - // to to children. And children need to point to parents... - // need to ensure that *only leaf states* are eligible for - // garbage collection, and not states which still have - // referenced children. - // Also, I think each state only needs to hold the set of - // attrs which were used to create that state using WithAttrs. - // It doesn't need the list of all attrs cached by parent - // states. So long as those parents already embedded those attrs - // in their respective delegate handlers, and those delegate - // handlers have already been trickled down to this state, - // this state doesn't care about those parent attrs. The state - // only needs to hold on to its own attrs in case a new delegate - // trickles down, and the state needs to rebuild its own delegate - delegate = delegate.WithAttrs(slices.Clone(s.attrs)) - for _, g := range s.groups { - delegate = delegate.WithGroup(g) +func (s *handler) WithAttrs(attrs []slog.Attr) slog.Handler { + transformer := func(h slog.Handler) slog.Handler { + return h.WithAttrs(attrs) + } + return &handler{ + basePtr: s.basePtr, + level: s.level, + transformers: slices.Clip(append(s.transformers, transformer)), } - - s.delegatePtr.Store(&delegate) -} - -func (s *state) delegate() slog.Handler { - handlerRef := s.delegatePtr.Load() - return *handlerRef } -func (s *state) WithAttrs(attrs []slog.Attr) slog.Handler { - // TODO: I think I need to clone or clip this slice - // TODO: this is actually pretty inefficient. Each time this is - // called, we end up re-calling WithAttrs and WithGroup on the delegate - // several times. - // TODO: consider adding native support for ReplaceAttr, and calling it - // here...that way I can implement ReplaceAttrs in flume, and it - // doesn't matter if the sinks implement it. I'd need to add calls - // to it in Handle() as well. - return s.conf.newHandler(append(s.attrs, attrs...), s.groups) -} +func (s *handler) WithGroup(name string) slog.Handler { + transformer := func(h slog.Handler) slog.Handler { + return h.WithGroup(name) + } -func (s *state) WithGroup(name string) slog.Handler { - // TODO: I think I need to clone or clip this slice - return s.conf.newHandler(s.attrs, append(s.groups, name)) + return &handler{ + basePtr: s.basePtr, + level: s.level, + transformers: slices.Clip(append(s.transformers, transformer)), + } } -func (s *state) Enabled(_ context.Context, level slog.Level) bool { +func (s *handler) Enabled(_ context.Context, level slog.Level) bool { return level >= s.level.Level() } -func (s *state) Handle(ctx context.Context, record slog.Record) error { +func (s *handler) Handle(ctx context.Context, record slog.Record) error { return s.delegate().Handle(ctx, record) } +func (s *handler) delegate() slog.Handler { + base := s.basePtr.Load() + if base == nil { + return noop + } + memo := s.memoPrt.Load() + if memo != nil && memo[0] == base { + hPtr := memo[1] + if hPtr != nil { + return *hPtr + } + } + // build and memoize + delegate := *base + for _, transformer := range s.transformers { + delegate = transformer(delegate) + } + if delegate == nil { + delegate = noop + } + s.memoPrt.Store(&[2]*slog.Handler{base, &delegate}) + return delegate +} + var noop = noopHandler{} type noopHandler struct{} diff --git a/v2/handler_test.go b/v2/handler_test.go index df00be5..c50a908 100644 --- a/v2/handler_test.go +++ b/v2/handler_test.go @@ -4,81 +4,12 @@ import ( "bytes" "context" "log/slog" - "os" "runtime" - "sync/atomic" "testing" "github.com/stretchr/testify/assert" ) -func TestStateWeakRef(t *testing.T) { - // This ensures that child handlers are garbage collected. slog methods like WithGroup() create - // copies of their handlers, and flume's handlers keep references to those child handlers, so that - // changes from the Factory can propagate down through all the handler clones. - // - // But when the child loggers are garbage collected, the handlers inside them should be collected - // to. The reference from the parent handler to the child handler shouldn't prevent the child handler - // from being collected, so it needs to be something like a weakref in java. Golang doesn't have weakref - // yet, but we use some fancy finalizers to mimic them. - // - // Test: create a logger/handler. Pass it to another function, which creates a child logger/handler, uses - // it, then discards it. - // - // After this function returns, the child logger should be garbage collected, and the parent *handler* should - // no longer be holding a ref to the child handler. - - ctl := NewController(slog.NewTextHandler(os.Stdout, nil)) - h := ctl.Handler("") - - conf := ctl.conf("") - lenStates := func() int { - // need to lock before checking size of children or race detector complains - conf.Lock() - defer conf.Unlock() - - return len(conf.states) - } - - logger := slog.New(h) - - logger.Info("Hi") - - // useChildLogger creates a child logger, uses it, then throws it away - useChildLogger := func(t *testing.T, logger *slog.Logger) *slog.Logger { - child := logger.WithGroup("colors").With("blue", true) - child.Info("There", "flavor", "vanilla") - - assert.Equal(t, 3, lenStates()) - - grandchild := child.With("size", "big") - - assert.Equal(t, 4, lenStates()) - - return grandchild - } - - grandchild := useChildLogger(t, logger) - - // Need to run 2 gc cycles. The first one should collect the handler and run the finalizer, then the second - // should collect the state orphaned by the finalizer. - runtime.GC() - runtime.GC() - - // after gc, there should be two states left, the original referenced by `h`, and the grandchild. - assert.Equal(t, 2, lenStates()) - - // to make this test reliable, we need to ensure that the compiler doesn't allow h to be gc'd before we've - // asserted the length of states. - runtime.KeepAlive(h) - - // make sure changes to the root ancestor still cascade down to all ancestors, - // even if links in the tree have already been released. - assert.IsType(t, &slog.TextHandler{}, grandchild.Handler().(*handler).delegate()) - ctl.SetDefaultSink(slog.NewJSONHandler(os.Stdout, nil)) - assert.IsType(t, &slog.JSONHandler{}, grandchild.Handler().(*handler).delegate()) -} - // removeKeys returns a function suitable for HandlerOptions.ReplaceAttr // that removes all Attrs with the given keys. func removeKeys(keys ...string) func([]string, slog.Attr) slog.Attr { @@ -109,14 +40,14 @@ func TestHandlers(t *testing.T) { }, { name: "factory constructor", - wantJSON: `{"level": "INFO", LoggerKey: "h1", "msg":"hi"}`, + wantJSON: `{"level": "INFO", "logger": "h1", "msg":"hi"}`, handlerFn: func(_ *testing.T, buf *bytes.Buffer, opts *slog.HandlerOptions) slog.Handler { return NewController(slog.NewJSONHandler(buf, opts)).Handler("h1") }, }, { - name: "change default before construction", - wantJSON: `{"level": "INFO", LoggerKey: "h1", "msg":"hi"}`, + name: "change default sink before handler", + wantJSON: `{"level": "INFO", "logger": "h1", "msg":"hi"}`, handlerFn: func(_ *testing.T, buf *bytes.Buffer, opts *slog.HandlerOptions) slog.Handler { f := NewController(nil) f.SetDefaultSink(slog.NewJSONHandler(buf, opts)) @@ -125,7 +56,7 @@ func TestHandlers(t *testing.T) { }, }, { - name: "change default after construction", + name: "change default sink after handler", wantText: "level=INFO msg=hi logger=h1\n", handlerFn: func(_ *testing.T, buf *bytes.Buffer, opts *slog.HandlerOptions) slog.Handler { f := NewController(slog.NewJSONHandler(buf, opts)) @@ -136,8 +67,8 @@ func TestHandlers(t *testing.T) { }, }, { - name: "change other handler before construction", - wantJSON: `{"level": "INFO", LoggerKey: "h1", "msg":"hi"}`, + name: "change other sink before handler", + wantJSON: `{"level": "INFO", "logger": "h1", "msg":"hi"}`, handlerFn: func(_ *testing.T, buf *bytes.Buffer, opts *slog.HandlerOptions) slog.Handler { f := NewController(slog.NewJSONHandler(buf, opts)) f.SetSink("h2", slog.NewTextHandler(buf, opts)) @@ -146,7 +77,7 @@ func TestHandlers(t *testing.T) { }, }, { - name: "change specific before construction", + name: "change sink before handler", wantText: "level=INFO msg=hi logger=h1\n", handlerFn: func(_ *testing.T, buf *bytes.Buffer, opts *slog.HandlerOptions) slog.Handler { f := NewController(slog.NewJSONHandler(buf, opts)) @@ -156,7 +87,7 @@ func TestHandlers(t *testing.T) { }, }, { - name: "change specific after construction", + name: "change sink after handler", wantText: "level=INFO msg=hi logger=h1\n", handlerFn: func(_ *testing.T, buf *bytes.Buffer, opts *slog.HandlerOptions) slog.Handler { f := NewController(slog.NewJSONHandler(buf, opts)) @@ -167,27 +98,40 @@ func TestHandlers(t *testing.T) { }, }, { - name: "cascade to children after construction", - wantText: "level=INFO msg=hi logger=h1 color=red\n", + name: "WithXXX", + wantText: "level=INFO msg=hi logger=h1 props.color=red\n", + handlerFn: func(_ *testing.T, buf *bytes.Buffer, opts *slog.HandlerOptions) slog.Handler { + f := NewController(slog.NewTextHandler(buf, opts)) + h := f.Handler("h1") + h = h.WithGroup("props") + h = h.WithAttrs([]slog.Attr{slog.String("color", "red")}) + return h + }, + }, + { + name: "change sink after WithXXX", + wantText: "level=INFO msg=hi logger=h1 size=big props.color=red props.address.street=mockingbird\n", handlerFn: func(_ *testing.T, buf *bytes.Buffer, opts *slog.HandlerOptions) slog.Handler { f := NewController(slog.NewJSONHandler(buf, opts)) h := f.Handler("h1") - c := h.WithAttrs([]slog.Attr{slog.String("color", "red")}) + h = h.WithAttrs([]slog.Attr{slog.String("size", "big")}) + h = h.WithGroup("props").WithAttrs([]slog.Attr{slog.String("color", "red")}).WithGroup("address").WithAttrs([]slog.Attr{slog.String("street", "mockingbird")}) f.SetSink("h1", slog.NewTextHandler(buf, opts)) - return c + return h }, }, { - name: "cascade to children before construction", - wantText: "level=INFO msg=hi logger=h1 color=red\n", + name: "change sink before WithXXX", + wantText: "level=INFO msg=hi logger=h1 size=big props.color=red props.address.street=mockingbird\n", handlerFn: func(_ *testing.T, buf *bytes.Buffer, opts *slog.HandlerOptions) slog.Handler { f := NewController(slog.NewJSONHandler(buf, opts)) f.SetSink("h1", slog.NewTextHandler(buf, opts)) h := f.Handler("h1") - c := h.WithAttrs([]slog.Attr{slog.String("color", "red")}) + h = h.WithAttrs([]slog.Attr{slog.String("size", "big")}) + h = h.WithGroup("props").WithAttrs([]slog.Attr{slog.String("color", "red")}).WithGroup("address").WithAttrs([]slog.Attr{slog.String("street", "mockingbird")}) - return c + return h }, }, { @@ -212,7 +156,7 @@ func TestHandlers(t *testing.T) { }, { name: "set other logger to nil", - wantJSON: `{"level": "INFO", LoggerKey: "h1", "msg":"hi"}`, + wantJSON: `{"level": "INFO", "logger": "h1", "msg":"hi"}`, handlerFn: func(_ *testing.T, buf *bytes.Buffer, opts *slog.HandlerOptions) slog.Handler { f := NewController(slog.NewJSONHandler(buf, opts)) h := f.Handler("h1") @@ -223,7 +167,7 @@ func TestHandlers(t *testing.T) { }, { name: "default", - wantJSON: `{"level": "INFO", LoggerKey: "def1", "msg":"hi"}`, + wantJSON: `{"level": "INFO", "logger": "def1", "msg":"hi"}`, handlerFn: func(_ *testing.T, buf *bytes.Buffer, opts *slog.HandlerOptions) slog.Handler { Default().SetDefaultSink(slog.NewJSONHandler(buf, opts)) return Handler("def1") @@ -279,6 +223,21 @@ func TestHandlers(t *testing.T) { return Handler(t.Name()) }, }, + { + name: "ensure cloned slices", + wantText: "level=INFO msg=hi logger=h1 props.flavor=lemon props.color=red\n", + handlerFn: func(_ *testing.T, buf *bytes.Buffer, opts *slog.HandlerOptions) slog.Handler { + ctl := NewController(slog.NewTextHandler(buf, opts)) + h1 := ctl.Handler("h1").WithGroup("props").WithAttrs([]slog.Attr{slog.String("flavor", "lemon")}) + h2 := h1.WithAttrs([]slog.Attr{slog.String("color", "red")}) // this appended a group to an internal slice + h3 := h1.WithAttrs([]slog.Attr{slog.String("size", "big")}) // so did this + runtime.KeepAlive(h3) + // need to make sure that h2 and h3 have completely independent states, and one group didn't over the other's group + // to test this, I need to install a ReplaceAttr function, since that's all the group slice is + // used for + return h2 + }, + }, } for _, test := range tests { @@ -308,7 +267,7 @@ func TestLevels(t *testing.T) { }{ { name: "default info", - wantJSON: `{"level": "INFO", LoggerKey: "h1", "msg":"hi"}`, + wantJSON: `{"level": "INFO", "logger": "h1", "msg":"hi"}`, handlerFunc: func(_ *testing.T, f *Controller) slog.Handler { return f.Handler("h1") }, @@ -339,7 +298,7 @@ func TestLevels(t *testing.T) { { name: "set handler specific after construction", level: slog.LevelDebug, - wantJSON: `{"level": "DEBUG", LoggerKey: "h1", "msg":"hi"}`, + wantJSON: `{"level": "DEBUG", "logger": "h1", "msg":"hi"}`, handlerFunc: func(_ *testing.T, f *Controller) slog.Handler { h := f.Handler("h1") f.SetLevel("h1", slog.LevelDebug) @@ -350,7 +309,7 @@ func TestLevels(t *testing.T) { { name: "set handler specific before construction", level: slog.LevelDebug, - wantJSON: `{"level": "DEBUG", LoggerKey: "h1", "msg":"hi"}`, + wantJSON: `{"level": "DEBUG", "logger": "h1", "msg":"hi"}`, handlerFunc: func(_ *testing.T, f *Controller) slog.Handler { f.SetLevel("h1", slog.LevelDebug) return f.Handler("h1") @@ -377,7 +336,7 @@ func TestLevels(t *testing.T) { { name: "cascade to children", level: slog.LevelDebug, - wantJSON: `{"level": "DEBUG", LoggerKey: "h1", "msg":"hi", "color":"red"}`, + wantJSON: `{"level": "DEBUG", "logger": "h1", "msg":"hi", "color":"red"}`, handlerFunc: func(_ *testing.T, f *Controller) slog.Handler { f.SetLevel("h1", slog.LevelDebug) h := f.Handler("h1") @@ -389,7 +348,7 @@ func TestLevels(t *testing.T) { { name: "update after creating child", level: slog.LevelDebug, - wantJSON: `{"level": "DEBUG", LoggerKey: "h1", "msg":"hi", "color":"red"}`, + wantJSON: `{"level": "DEBUG", "logger": "h1", "msg":"hi", "color":"red"}`, handlerFunc: func(_ *testing.T, f *Controller) slog.Handler { h := f.Handler("h1") c := h.WithAttrs([]slog.Attr{slog.String("color", "red")}) diff --git a/v2/v1_compat_test.go b/v2/v1_compat_test.go index 5f7cb68..77f69e8 100644 --- a/v2/v1_compat_test.go +++ b/v2/v1_compat_test.go @@ -130,7 +130,7 @@ func TestDetailedErrors(t *testing.T) { marshaledErr, merr := json.Marshal(fmt.Sprintf("%+v", err)) require.NoError(t, merr) - assert.JSONEq(t, fmt.Sprintf(`{"level":"INFO",LoggerKey:"main","msg":"an error","error":%v}`, string(marshaledErr)), buf.String()) + assert.JSONEq(t, fmt.Sprintf(`{"level":"INFO","logger":"main","msg":"an error","error":%v}`, string(marshaledErr)), buf.String()) // mapstest.AssertEquivalent(t, map[string]any{"level": "INFO", LoggerKey: "main", "msg": "an error", "error": merry.Details(err)}, json.RawMessage(buf.String())) }