From 1a66028eeb0ec64f030975261528c7da7d6da447 Mon Sep 17 00:00:00 2001 From: Nicholas Wiersma Date: Wed, 8 Apr 2026 20:05:43 +0200 Subject: [PATCH] feat: support groups --- ctx/ctx.go | 15 ++++ event.go | 39 +++++++---- format.go | 67 ++++++++++++++++-- format_test.go | 95 +++++++++++++++++++++++++- internal/bytes/buffer.go | 8 +++ internal/bytes/buffer_internal_test.go | 20 ++++++ logger_test.go | 61 +++++++++++++++++ 7 files changed, 284 insertions(+), 21 deletions(-) diff --git a/ctx/ctx.go b/ctx/ctx.go index 7b00d53..fc2c555 100644 --- a/ctx/ctx.go +++ b/ctx/ctx.go @@ -188,6 +188,21 @@ func Interface(k string, v any) logger.Field { } } +// Group returns a field that writes all the given fields inside a named group. +func Group(name string, fields ...logger.Field) logger.Field { + return func(e *logger.Event) { + if len(fields) == 0 { + return + } + + e.OpenGroup(name) + for _, f := range fields { + f(e) + } + e.CloseGroup() + } +} + // Span represents an open telemetry span. type Span interface { IsRecording() bool diff --git a/event.go b/event.go index 20307b8..bb48f5f 100644 --- a/event.go +++ b/event.go @@ -17,14 +17,16 @@ var eventPool = &sync.Pool{ // Event is a log event. type Event struct { - fmtr Formatter - buf *bytes.Buffer + fmtr Formatter + buf *bytes.Buffer + prefix []byte } func newEvent(fmtr Formatter) *Event { e := eventPool.Get().(*Event) e.fmtr = fmtr e.buf.Reset() + e.prefix = e.prefix[:0] return e } @@ -32,15 +34,26 @@ func putEvent(e *Event) { eventPool.Put(e) } +// OpenGroup opens a named group in the event output. Every OpenGroup call +// must be paired with a matching CloseGroup call. +func (e *Event) OpenGroup(name string) { + e.prefix = e.fmtr.AppendGroupStart(e.buf, e.prefix, name) +} + +// CloseGroup closes the most recently opened group. +func (e *Event) CloseGroup() { + e.prefix = e.fmtr.AppendGroupEnd(e.buf, e.prefix) +} + // AppendString appends a string to the event. func (e *Event) AppendString(k, s string) { - e.fmtr.AppendKey(e.buf, k) + e.fmtr.AppendKey(e.buf, e.prefix, k) e.fmtr.AppendString(e.buf, s) } // AppendStrings appends strings to the event. func (e *Event) AppendStrings(k string, s []string) { - e.fmtr.AppendKey(e.buf, k) + e.fmtr.AppendKey(e.buf, e.prefix, k) e.fmtr.AppendArrayStart(e.buf) for i, ss := range s { if i > 0 { @@ -53,7 +66,7 @@ func (e *Event) AppendStrings(k string, s []string) { // AppendBytes appends bytes to the event. func (e *Event) AppendBytes(k string, p []byte) { - e.fmtr.AppendKey(e.buf, k) + e.fmtr.AppendKey(e.buf, e.prefix, k) e.fmtr.AppendArrayStart(e.buf) for i, b := range p { if i > 0 { @@ -66,19 +79,19 @@ func (e *Event) AppendBytes(k string, p []byte) { // AppendBool appends a bool to the event. func (e *Event) AppendBool(k string, b bool) { - e.fmtr.AppendKey(e.buf, k) + e.fmtr.AppendKey(e.buf, e.prefix, k) e.fmtr.AppendBool(e.buf, b) } // AppendInt appends an int to the event. func (e *Event) AppendInt(k string, i int64) { - e.fmtr.AppendKey(e.buf, k) + e.fmtr.AppendKey(e.buf, e.prefix, k) e.fmtr.AppendInt(e.buf, i) } // AppendInts appends ints to the event. func (e *Event) AppendInts(k string, a []int) { - e.fmtr.AppendKey(e.buf, k) + e.fmtr.AppendKey(e.buf, e.prefix, k) e.fmtr.AppendArrayStart(e.buf) for i, ii := range a { if i > 0 { @@ -91,30 +104,30 @@ func (e *Event) AppendInts(k string, a []int) { // AppendUint appends a uint to the event. func (e *Event) AppendUint(k string, i uint64) { - e.fmtr.AppendKey(e.buf, k) + e.fmtr.AppendKey(e.buf, e.prefix, k) e.fmtr.AppendUint(e.buf, i) } // AppendFloat appends a float to the event. func (e *Event) AppendFloat(k string, f float64) { - e.fmtr.AppendKey(e.buf, k) + e.fmtr.AppendKey(e.buf, e.prefix, k) e.fmtr.AppendFloat(e.buf, f) } // AppendTime appends a time to the event. func (e *Event) AppendTime(k string, d time.Time) { - e.fmtr.AppendKey(e.buf, k) + e.fmtr.AppendKey(e.buf, e.prefix, k) e.fmtr.AppendTime(e.buf, d) } // AppendDuration appends a duration to the event. func (e *Event) AppendDuration(k string, d time.Duration) { - e.fmtr.AppendKey(e.buf, k) + e.fmtr.AppendKey(e.buf, e.prefix, k) e.fmtr.AppendDuration(e.buf, d) } // AppendInterface appends a interface to the event. func (e *Event) AppendInterface(k string, v any) { - e.fmtr.AppendKey(e.buf, k) + e.fmtr.AppendKey(e.buf, e.prefix, k) e.fmtr.AppendInterface(e.buf, v) } diff --git a/format.go b/format.go index 94d597b..48c99aa 100644 --- a/format.go +++ b/format.go @@ -27,7 +27,9 @@ type Formatter interface { AppendArrayStart(buf *bytes.Buffer) AppendArraySep(buf *bytes.Buffer) AppendArrayEnd(buf *bytes.Buffer) - AppendKey(buf *bytes.Buffer, key string) + AppendKey(buf *bytes.Buffer, prefix []byte, key string) + AppendGroupStart(buf *bytes.Buffer, prefix []byte, name string) []byte + AppendGroupEnd(buf *bytes.Buffer, prefix []byte) []byte AppendString(buf *bytes.Buffer, s string) AppendBool(buf *bytes.Buffer, b bool) AppendInt(buf *bytes.Buffer, i int64) @@ -86,12 +88,32 @@ func (j *json) AppendArrayEnd(buf *bytes.Buffer) { buf.WriteByte(']') } -func (j *json) AppendKey(buf *bytes.Buffer, key string) { - buf.WriteString(`,"`) +func (j *json) AppendKey(buf *bytes.Buffer, _ []byte, key string) { + if buf.Peek() != '{' { + buf.WriteString(`,"`) + } else { + buf.WriteByte('"') + } buf.WriteString(key) buf.WriteString(`":`) } +func (j *json) AppendGroupStart(buf *bytes.Buffer, prefix []byte, name string) []byte { + if buf.Peek() != '{' { + buf.WriteString(`,"`) + } else { + buf.WriteByte('"') + } + buf.WriteString(name) + buf.WriteString(`":{`) + return prefix +} + +func (j *json) AppendGroupEnd(buf *bytes.Buffer, prefix []byte) []byte { + buf.WriteByte('}') + return prefix +} + func (j *json) AppendString(buf *bytes.Buffer, s string) { appendString(buf, s, true) } @@ -184,12 +206,24 @@ func (l *logfmt) AppendArraySep(buf *bytes.Buffer) { func (l *logfmt) AppendArrayEnd(_ *bytes.Buffer) {} -func (l *logfmt) AppendKey(buf *bytes.Buffer, key string) { +func (l *logfmt) AppendKey(buf *bytes.Buffer, prefix []byte, key string) { buf.WriteByte(' ') + if len(prefix) > 0 { + buf.Write(prefix) + } buf.WriteString(key) buf.WriteByte('=') } +func (l *logfmt) AppendGroupStart(_ *bytes.Buffer, prefix []byte, name string) []byte { + prefix = append(prefix, name...) + return append(prefix, '.') +} + +func (l *logfmt) AppendGroupEnd(_ *bytes.Buffer, prefix []byte) []byte { + return trimLastGroup(prefix) +} + func (l *logfmt) AppendString(buf *bytes.Buffer, s string) { appendString(buf, s, l.needsQuote(s)) } @@ -327,7 +361,7 @@ func (c *console) AppendArraySep(buf *bytes.Buffer) { func (c *console) AppendArrayEnd(_ *bytes.Buffer) {} -func (c *console) AppendKey(buf *bytes.Buffer, key string) { +func (c *console) AppendKey(buf *bytes.Buffer, prefix []byte, key string) { buf.WriteByte(' ') col := newColor(colorCyan) @@ -336,11 +370,23 @@ func (c *console) AppendKey(buf *bytes.Buffer, key string) { } withColor(col, buf, func() { + if len(prefix) > 0 { + buf.Write(prefix) + } buf.WriteString(key) buf.WriteByte('=') }) } +func (c *console) AppendGroupStart(_ *bytes.Buffer, prefix []byte, name string) []byte { + prefix = append(prefix, name...) + return append(prefix, '.') +} + +func (c *console) AppendGroupEnd(_ *bytes.Buffer, prefix []byte) []byte { + return trimLastGroup(prefix) +} + func (c *console) AppendString(buf *bytes.Buffer, s string) { appendString(buf, s, false) } @@ -456,3 +502,14 @@ func tryAddASCII(buf *bytes.Buffer, b byte) bool { } return true } + +// trimLastGroup removes the last "name." segment from prefix by scanning +// backwards for the dot preceding the final segment. +func trimLastGroup(prefix []byte) []byte { + for i := len(prefix) - 2; i >= 0; i-- { + if prefix[i] == '.' { + return prefix[:i+1] + } + } + return prefix[:0] +} diff --git a/format_test.go b/format_test.go index 32a5e94..18e36de 100644 --- a/format_test.go +++ b/format_test.go @@ -15,7 +15,7 @@ func TestJsonFormat(t *testing.T) { buf := bytes.NewBuffer(512) fmtr.AppendBeginMarker(buf) fmtr.WriteMessage(buf, time.Unix(123, 0).UTC(), logger.Error, "some message") - fmtr.AppendKey(buf, "error") + fmtr.AppendKey(buf, nil, "error") fmtr.AppendString(buf, "some error") fmtr.AppendEndMarker(buf) fmtr.AppendLineBreak(buf) @@ -171,13 +171,51 @@ func TestJsonFormat_Interface(t *testing.T) { assert.Equal(t, `"{Name:test}"null`, string(buf.Bytes())) } +func TestJsonFormat_Group(t *testing.T) { + fmtr := logger.JSONFormat() + + buf := bytes.NewBuffer(512) + fmtr.AppendBeginMarker(buf) + fmtr.WriteMessage(buf, time.Time{}, logger.Info, "msg") + prefix := fmtr.AppendGroupStart(buf, nil, "db") + fmtr.AppendKey(buf, prefix, "host") + fmtr.AppendString(buf, "localhost") + fmtr.AppendKey(buf, prefix, "port") + fmtr.AppendInt(buf, 5432) + fmtr.AppendGroupEnd(buf, prefix) + fmtr.AppendKey(buf, nil, "other") + fmtr.AppendString(buf, "val") + fmtr.AppendEndMarker(buf) + + want := `{"lvl":"info","msg":"msg","db":{"host":"localhost","port":5432},"other":"val"}` + assert.Equal(t, want, string(buf.Bytes())) +} + +func TestJsonFormat_NestedGroup(t *testing.T) { + fmtr := logger.JSONFormat() + + buf := bytes.NewBuffer(512) + fmtr.AppendBeginMarker(buf) + fmtr.WriteMessage(buf, time.Time{}, logger.Info, "msg") + outer := fmtr.AppendGroupStart(buf, nil, "outer") + inner := fmtr.AppendGroupStart(buf, outer, "inner") + fmtr.AppendKey(buf, inner, "k") + fmtr.AppendString(buf, "v") + fmtr.AppendGroupEnd(buf, inner) + fmtr.AppendGroupEnd(buf, outer) + fmtr.AppendEndMarker(buf) + + want := `{"lvl":"info","msg":"msg","outer":{"inner":{"k":"v"}}}` + assert.Equal(t, want, string(buf.Bytes())) +} + func TestLogfmtFormat(t *testing.T) { fmtr := logger.LogfmtFormat() buf := bytes.NewBuffer(512) fmtr.AppendBeginMarker(buf) fmtr.WriteMessage(buf, time.Unix(123, 0).UTC(), logger.Error, "some message") - fmtr.AppendKey(buf, "error") + fmtr.AppendKey(buf, nil, "error") fmtr.AppendString(buf, "some error") fmtr.AppendEndMarker(buf) fmtr.AppendLineBreak(buf) @@ -333,13 +371,48 @@ func TestLogfmtFormat_Interface(t *testing.T) { assert.Equal(t, `{Name:test}`, string(buf.Bytes())) } +func TestLogfmtFormat_Group(t *testing.T) { + fmtr := logger.LogfmtFormat() + + buf := bytes.NewBuffer(512) + fmtr.WriteMessage(buf, time.Time{}, logger.Info, "msg") + prefix := fmtr.AppendGroupStart(buf, nil, "db") + fmtr.AppendKey(buf, prefix, "host") + fmtr.AppendString(buf, "localhost") + fmtr.AppendKey(buf, prefix, "port") + fmtr.AppendInt(buf, 5432) + prefix = fmtr.AppendGroupEnd(buf, prefix) + fmtr.AppendKey(buf, prefix, "other") + fmtr.AppendString(buf, "val") + + want := `lvl=info msg=msg db.host=localhost db.port=5432 other=val` + assert.Equal(t, want, string(buf.Bytes())) +} + +func TestLogfmtFormat_NestedGroup(t *testing.T) { + fmtr := logger.LogfmtFormat() + + buf := bytes.NewBuffer(512) + fmtr.WriteMessage(buf, time.Time{}, logger.Info, "msg") + outer := fmtr.AppendGroupStart(buf, nil, "a") + inner := fmtr.AppendGroupStart(buf, outer, "b") + fmtr.AppendKey(buf, inner, "k") + fmtr.AppendString(buf, "v") + outer = fmtr.AppendGroupEnd(buf, inner) + fmtr.AppendKey(buf, outer, "x") + fmtr.AppendString(buf, "y") + + want := `lvl=info msg=msg a.b.k=v a.x=y` + assert.Equal(t, want, string(buf.Bytes())) +} + func TestConsoleFormat(t *testing.T) { fmtr := logger.ConsoleFormat() buf := bytes.NewBuffer(512) fmtr.AppendBeginMarker(buf) fmtr.WriteMessage(buf, time.Unix(123, 0).UTC(), logger.Error, "some message") - fmtr.AppendKey(buf, "error") + fmtr.AppendKey(buf, nil, "error") fmtr.AppendString(buf, "some error") fmtr.AppendEndMarker(buf) fmtr.AppendLineBreak(buf) @@ -482,3 +555,19 @@ func TestConsoleFormat_Interface(t *testing.T) { assert.Equal(t, `{Name:test}`, string(buf.Bytes())) } + +func TestConsoleFormat_Group(t *testing.T) { + fmtr := logger.ConsoleFormat() + + buf := bytes.NewBuffer(512) + fmtr.WriteMessage(buf, time.Time{}, logger.Info, "msg") + prefix := fmtr.AppendGroupStart(buf, nil, "db") + fmtr.AppendKey(buf, prefix, "host") + fmtr.AppendString(buf, "localhost") + prefix = fmtr.AppendGroupEnd(buf, prefix) + fmtr.AppendKey(buf, prefix, "other") + fmtr.AppendString(buf, "val") + + want := "\x1b[32mINFO\x1b[0m msg \x1b[36mdb.host=\x1b[0mlocalhost \x1b[36mother=\x1b[0mval" + assert.Equal(t, want, string(buf.Bytes())) +} diff --git a/internal/bytes/buffer.go b/internal/bytes/buffer.go index d2a1297..a8f9607 100644 --- a/internal/bytes/buffer.go +++ b/internal/bytes/buffer.go @@ -68,6 +68,14 @@ func (b *Buffer) Len() int { return len(b.b) } +// Peek returns the last byte written to the Buffer, or 0 if the buffer is empty. +func (b *Buffer) Peek() byte { + if len(b.b) == 0 { + return 0 + } + return b.b[len(b.b)-1] +} + // Reset resets the underlying byte slice. Subsequent writes re-use the slice's // backing array. func (b *Buffer) Reset() { diff --git a/internal/bytes/buffer_internal_test.go b/internal/bytes/buffer_internal_test.go index 9076fe6..5b1ac07 100644 --- a/internal/bytes/buffer_internal_test.go +++ b/internal/bytes/buffer_internal_test.go @@ -61,6 +61,26 @@ func TestBuffer(t *testing.T) { }, } + t.Run("Peek", func(t *testing.T) { + buf.Reset() + + got := buf.Peek() + + assert.Equal(t, byte(0), got) + + buf.WriteByte('x') + + got = buf.Peek() + + assert.Equal(t, byte('x'), got) + + buf.WriteString("ab") + + got = buf.Peek() + + assert.Equal(t, byte('b'), got) + }) + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { buf.Reset() diff --git a/logger_test.go b/logger_test.go index 8726e6b..427095a 100644 --- a/logger_test.go +++ b/logger_test.go @@ -291,6 +291,67 @@ func TestLogger_Writer(t *testing.T) { assert.Equal(t, want, buf.String()) } +func TestLogger_Group_Logfmt(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + log := logger.New(&buf, logger.LogfmtFormat(), logger.Info) + + log.Info("msg", ctx.Group("db", ctx.Str("host", "localhost"), ctx.Int("port", 5432))) + + want := `lvl=info msg=msg db.host=localhost db.port=5432` + "\n" + assert.Equal(t, want, buf.String()) +} + +func TestLogger_Group_JSON(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + log := logger.New(&buf, logger.JSONFormat(), logger.Info) + + log.Info("msg", ctx.Group("db", ctx.Str("host", "localhost"), ctx.Int("port", 5432))) + + want := `{"lvl":"info","msg":"msg","db":{"host":"localhost","port":5432}}` + "\n" + assert.Equal(t, want, buf.String()) +} + +func TestLogger_NestedGroup_Logfmt(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + log := logger.New(&buf, logger.LogfmtFormat(), logger.Info) + + log.Info("msg", ctx.Group("a", ctx.Group("b", ctx.Str("k", "v")), ctx.Str("x", "y"))) + + want := `lvl=info msg=msg a.b.k=v a.x=y` + "\n" + assert.Equal(t, want, buf.String()) +} + +func TestLogger_NestedGroup_JSON(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + log := logger.New(&buf, logger.JSONFormat(), logger.Info) + + log.Info("msg", ctx.Group("outer", ctx.Group("inner", ctx.Str("k", "v")), ctx.Str("x", "y"))) + + want := `{"lvl":"info","msg":"msg","outer":{"inner":{"k":"v"},"x":"y"}}` + "\n" + assert.Equal(t, want, buf.String()) +} + +func TestLogger_GroupWith_JSON(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + log := logger.New(&buf, logger.JSONFormat(), logger.Info). + With(ctx.Group("db", ctx.Str("host", "localhost"))) + + log.Info("msg", ctx.Str("driver", "pgx")) + + want := `{"lvl":"info","msg":"msg","db":{"host":"localhost"},"driver":"pgx"}` + "\n" + assert.Equal(t, want, buf.String()) +} + type fakeSpan struct { Recording bool ID byte