From 6dc6e18d6a1d19aeebc64184decd2c74e034819d Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Tue, 7 Jan 2025 20:30:17 -0600 Subject: [PATCH 01/44] Adds a new "Dim" theme Makes attribute values and keys a little darker to make the messages pop more. Addresses issue #9. --- handler_test.go | 16 +++++++++++++--- theme.go | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/handler_test.go b/handler_test.go index eb09629..f5f6d95 100644 --- a/handler_test.go +++ b/handler_test.go @@ -282,6 +282,7 @@ func TestThemes(t *testing.T) { for _, theme := range []Theme{ NewDefaultTheme(), NewBrightTheme(), + NewDimTheme(), } { t.Run(theme.Name(), func(t *testing.T) { level := slog.LevelInfo @@ -292,6 +293,7 @@ func TestThemes(t *testing.T) { timeFormat := time.Kitchen index := -1 toIndex := -1 + var lastField []byte h := NewHandler(&buf, &HandlerOptions{ AddSource: true, TimeFormat: timeFormat, @@ -309,6 +311,7 @@ func TestThemes(t *testing.T) { bufBytes = bufBytes[toIndex:] index = bytes.IndexByte(bufBytes, '\x1b') AssertNotEqual(t, -1, index) + lastField = bufBytes[:index] toIndex = index + len(ResetMod) AssertEqual(t, ResetMod, ANSIMod(bufBytes[index:toIndex])) bufBytes = bufBytes[toIndex:] @@ -352,9 +355,16 @@ func TestThemes(t *testing.T) { checkANSIMod(t, "AttrKey", theme.AttrKey()) } - // AttrValue - if theme.AttrValue() != "" { - checkANSIMod(t, "AttrValue", theme.AttrValue()) + if string(lastField) == "error=" { + // AttrValueError + if theme.AttrValueError() != "" { + checkANSIMod(t, "AttrValueError", theme.AttrValueError()) + } + } else { + // AttrValue + if theme.AttrValue() != "" { + checkANSIMod(t, "AttrValue", theme.AttrValue()) + } } } }) diff --git a/theme.go b/theme.go index 8d1290f..377c7e0 100644 --- a/theme.go +++ b/theme.go @@ -149,3 +149,20 @@ func NewBrightTheme() Theme { levelDebug: ToANSICode(), } } + +func NewDimTheme() Theme { + return ThemeDef{ + name: "Dim", + timestamp: ToANSICode(BrightBlack), + source: ToANSICode(Bold, BrightBlack), + message: ToANSICode(Bold), + messageDebug: ToANSICode(), + attrKey: ToANSICode(Blue), + attrValue: ToANSICode(Gray), + attrValueError: ToANSICode(Bold, Red), + levelError: ToANSICode(Red), + levelWarn: ToANSICode(Yellow), + levelInfo: ToANSICode(Green), + levelDebug: ToANSICode(), + } +} From 3a2a41687bec822dd140e61fd0ab7be13045ee72 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Tue, 7 Jan 2025 15:24:01 -0600 Subject: [PATCH 02/44] Use fmt for errors, like slog.TextHandler Uses fmt.Fprintf with "%+v" to print errors. Useful with some error packages which render additional information like stacktraces. Addresses #7 --- buffer.go | 5 +++++ encoding.go | 4 +++- handler_test.go | 15 ++++++++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/buffer.go b/buffer.go index ddff9c3..f05182a 100644 --- a/buffer.go +++ b/buffer.go @@ -45,6 +45,11 @@ func (b *buffer) WriteTo(dst io.Writer) (int64, error) { return int64(n), nil } +func (b *buffer) Write(bt []byte) (int, error) { + *b = append(*b, bt...) + return len(bt), nil +} + func (b *buffer) Reset() { *b = (*b)[:0] } diff --git a/encoding.go b/encoding.go index 6088e65..6214a69 100644 --- a/encoding.go +++ b/encoding.go @@ -144,7 +144,9 @@ func (e encoder) writeValue(buf *buffer, value slog.Value) { case slog.KindAny: switch v := value.Any().(type) { case error: - e.writeColoredString(buf, v.Error(), e.opts.Theme.AttrValueError()) + e.withColor(buf, e.opts.Theme.AttrValueError(), func() { + fmt.Fprintf(buf, "%+v", v) + }) return case fmt.Stringer: e.writeColoredString(buf, v.String(), attrValue) diff --git a/handler_test.go b/handler_test.go index eb09629..a60cb42 100644 --- a/handler_test.go +++ b/handler_test.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "io" "log/slog" "os" "path/filepath" @@ -72,6 +73,17 @@ func (v *theValuer) LogValue() slog.Value { return slog.StringValue(fmt.Sprintf("The word is '%s'", v.word)) } +type formatterError struct { + error +} + +func (e *formatterError) Format(f fmt.State, verb rune) { + if verb == 'v' && f.Flag('+') { + io.WriteString(f, "formatted ") + } + io.WriteString(f, e.Error()) +} + func TestHandler_Attr(t *testing.T) { buf := bytes.Buffer{} h := NewHandler(&buf, &HandlerOptions{NoColor: true}) @@ -87,6 +99,7 @@ func TestHandler_Attr(t *testing.T) { slog.Duration("dur", time.Second), slog.Group("group", slog.String("foo", "bar"), slog.Group("subgroup", slog.String("foo", "bar"))), slog.Any("err", errors.New("the error")), + slog.Any("formattedError", &formatterError{errors.New("the error")}), slog.Any("stringer", theStringer{}), slog.Any("nostringer", noStringer{Foo: "bar"}), // Resolve LogValuer items in addition to Stringer items. @@ -102,7 +115,7 @@ func TestHandler_Attr(t *testing.T) { ) AssertNoError(t, h.Handle(context.Background(), rec)) - expected := fmt.Sprintf("%s INF foobar bool=true int=-12 uint=12 float=3.14 foo=bar time=%s dur=1s group.foo=bar group.subgroup.foo=bar err=the error stringer=stringer nostringer={bar} valuer=The word is 'distant'\n", now.Format(time.DateTime), now.Format(time.DateTime)) + expected := fmt.Sprintf("%s INF foobar bool=true int=-12 uint=12 float=3.14 foo=bar time=%s dur=1s group.foo=bar group.subgroup.foo=bar err=the error formattedError=formatted the error stringer=stringer nostringer={bar} valuer=The word is 'distant'\n", now.Format(time.DateTime), now.Format(time.DateTime)) AssertEqual(t, expected, buf.String()) } From fdbbfe5031eb982a0be584d92e513faeafee32a1 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Tue, 7 Jan 2025 15:15:58 -0600 Subject: [PATCH 03/44] Multiline support Sort multiline values to the end, and print the key on a separate line --- bench_test.go | 1 + buffer.go | 4 +++ buffer_test.go | 9 +++++++ encoding.go | 1 + handler.go | 47 ++++++++++++++++++++++++++++++---- handler_test.go | 67 +++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 124 insertions(+), 5 deletions(-) diff --git a/bench_test.go b/bench_test.go index 88736ac..83479b8 100644 --- a/bench_test.go +++ b/bench_test.go @@ -36,6 +36,7 @@ var attrs = []slog.Attr{ slog.Any("err", errors.New("yo")), slog.Group("empty"), slog.Group("group", slog.String("bar", "baz")), + slog.String("multi", "foo\nbar"), } var attrsAny = func() (a []any) { diff --git a/buffer.go b/buffer.go index ddff9c3..ec86921 100644 --- a/buffer.go +++ b/buffer.go @@ -30,6 +30,10 @@ func (b *buffer) Cap() int { } func (b *buffer) WriteTo(dst io.Writer) (int64, error) { + if b == nil { + // for convenience, if receiver is nil, treat it like an empty buffer + return 0, nil + } l := len(*b) if l == 0 { return 0, nil diff --git a/buffer_test.go b/buffer_test.go index 5a4cdde..98b9ba6 100644 --- a/buffer_test.go +++ b/buffer_test.go @@ -46,6 +46,15 @@ func TestBuffer_WriteTo(t *testing.T) { AssertNoError(t, err) AssertEqual(t, "foobar", dest.String()) AssertZero(t, b.Len()) + + t.Run("nilbuffer", func(t *testing.T) { + // if receiver is nil, do nothing + dest.Reset() + c, err := (*buffer)(nil).WriteTo(&dest) + AssertNoError(t, err) + AssertZero(t, c) + AssertZero(t, dest.Len()) + }) } func TestBuffer_Clone(t *testing.T) { diff --git a/encoding.go b/encoding.go index 6088e65..0361dbc 100644 --- a/encoding.go +++ b/encoding.go @@ -114,6 +114,7 @@ func (e encoder) writeAttr(buf *buffer, a slog.Attr, group string) { } return } + buf.AppendByte(' ') e.withColor(buf, e.opts.Theme.AttrKey(), func() { if group != "" { diff --git a/handler.go b/handler.go index 82ce3ea..0cad596 100644 --- a/handler.go +++ b/handler.go @@ -1,6 +1,7 @@ package console import ( + "bytes" "context" "io" "log/slog" @@ -14,6 +15,17 @@ var bufferPool = &sync.Pool{ New: func() any { return new(buffer) }, } +func getBuf() *buffer { + return bufferPool.Get().(*buffer) +} + +func releaseBuf(buf *buffer) { + if buf != nil { + buf.Reset() + bufferPool.Put(buf) + } +} + var cwd, _ = os.Getwd() // HandlerOptions are options for a ConsoleHandler. @@ -82,7 +94,8 @@ func (h *Handler) Enabled(_ context.Context, l slog.Level) bool { // Handle implements slog.Handler. func (h *Handler) Handle(_ context.Context, rec slog.Record) error { - buf := bufferPool.Get().(*buffer) + buf := getBuf() + var multiLineBuf *buffer h.enc.writeTimestamp(buf, rec.Time) h.enc.writeLevel(buf, rec.Level) @@ -92,16 +105,40 @@ func (h *Handler) Handle(_ context.Context, rec slog.Record) error { h.enc.writeMessage(buf, rec.Level, rec.Message) buf.copy(&h.context) rec.Attrs(func(a slog.Attr) bool { + idx := buf.Len() h.enc.writeAttr(buf, a, h.group) + lastAttr := (*buf)[idx:] + if bytes.IndexByte(lastAttr, '\n') >= 0 { + if multiLineBuf == nil { + multiLineBuf = getBuf() + multiLineBuf.AppendByte(' ') + } + if k, v, ok := bytes.Cut(lastAttr, []byte("=")); ok { + multiLineBuf.Append(k[1:]) + multiLineBuf.AppendByte('=') + multiLineBuf.AppendByte('\n') + multiLineBuf.Append(v) + multiLineBuf.AppendByte('\n') + } else { + multiLineBuf.Append(lastAttr[1:]) + multiLineBuf.AppendByte('\n') + } + + *buf = (*buf)[:idx] + } return true }) - h.enc.NewLine(buf) + if multiLineBuf == nil { + h.enc.NewLine(buf) + } if _, err := buf.WriteTo(h.out); err != nil { - buf.Reset() - bufferPool.Put(buf) return err } - bufferPool.Put(buf) + if _, err := multiLineBuf.WriteTo(h.out); err != nil { + return err + } + releaseBuf(buf) + releaseBuf(multiLineBuf) return nil } diff --git a/handler_test.go b/handler_test.go index eb09629..a8e287d 100644 --- a/handler_test.go +++ b/handler_test.go @@ -106,6 +106,73 @@ func TestHandler_Attr(t *testing.T) { AssertEqual(t, expected, buf.String()) } +func TestHandler_AttrsWithNewlines(t *testing.T) { + tests := []struct { + name string + msg string + escapeNewlines bool + attrs []slog.Attr + want string + }{ + { + name: "single attr", + attrs: []slog.Attr{ + slog.String("foo", "line one\nline two"), + }, + want: "INF multiline attrs foo=\nline one\nline two\n", + }, + { + name: "multiple attrs", + attrs: []slog.Attr{ + slog.String("foo", "line one\nline two"), + slog.String("bar", "line three\nline four"), + }, + want: "INF multiline attrs foo=\nline one\nline two\nbar=\nline three\nline four\n", + }, + { + name: "sort multiline attrs to end", + attrs: []slog.Attr{ + slog.String("size", "big"), + slog.String("foo", "line one\nline two"), + slog.String("weight", "heavy"), + slog.String("bar", "line three\nline four"), + slog.String("color", "red"), + }, + want: "INF multiline attrs size=big weight=heavy color=red foo=\nline one\nline two\nbar=\nline three\nline four\n", + }, + { + name: "multiline message", + msg: "multiline\nmessage", + want: "INF multiline\nmessage\n", + }, + { + name: "preserve leading and trailing newlines", + attrs: []slog.Attr{ + slog.String("foo", "\nline one\nline two\n"), + }, + want: "INF multiline attrs foo=\n\nline one\nline two\n\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + buf := bytes.Buffer{} + h := NewHandler(&buf, &HandlerOptions{NoColor: true}) + + msg := test.msg + if msg == "" { + msg = "multiline attrs" + } + rec := slog.NewRecord(time.Time{}, slog.LevelInfo, msg, 0) + rec.AddAttrs(test.attrs...) + AssertNoError(t, h.Handle(context.Background(), rec)) + + AssertEqual(t, test.want, buf.String()) + }) + + } +} + // Handlers should not log groups (or subgroups) without fields. // '- If a group has no Attrs (even if it has a non-empty key), ignore it.' // https://pkg.go.dev/log/slog@master#Handler From 5aa80716b5f37d2549168e434703ce7a0517a6bd Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Thu, 9 Jan 2025 21:54:34 -0600 Subject: [PATCH 04/44] ReplaceAttr support HandlerOptions.ReplaceAttr support. When ReplaceAttr is set, there will be more allocations. There is one allocation happening even when ReplaceAttr is nil, around passing the slice of groups to ReplaceAttr. Still needs to be optimized away. --- encoding.go | 189 +++++++++++++++++++++++++++++++------ handler.go | 7 +- handler_test.go | 242 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 410 insertions(+), 28 deletions(-) diff --git a/encoding.go b/encoding.go index 6088e65..ea4d1a3 100644 --- a/encoding.go +++ b/encoding.go @@ -5,6 +5,7 @@ import ( "log/slog" "path/filepath" "runtime" + "strings" "time" ) @@ -69,33 +70,124 @@ func (e encoder) writeColoredDuration(w *buffer, d time.Duration, c ANSIMod) { } func (e encoder) writeTimestamp(buf *buffer, tt time.Time) { - if !tt.IsZero() { - e.writeColoredTime(buf, tt, e.opts.TimeFormat, e.opts.Theme.Timestamp()) - buf.AppendByte(' ') + if tt.IsZero() { + // elide, and skip ReplaceAttr + return + } + + if e.opts.ReplaceAttr != nil { + attr := e.opts.ReplaceAttr(nil, slog.Time(slog.TimeKey, tt)) + val := attr.Value.Resolve() + + switch val.Kind() { + case slog.KindTime: + // most common case + tt = val.Time() + if tt.IsZero() { + // elide + return + } + // skip to normal timestamp formatting and printing + case slog.KindAny: + if val.Any() == nil { + // elide + return + } + fallthrough + default: + // handle all non-time values by printing them like + // an attr value + e.writeColoredValue(buf, val, e.opts.Theme.Timestamp()) + buf.AppendByte(' ') + return + } } + + e.writeColoredTime(buf, tt, e.opts.TimeFormat, e.opts.Theme.Timestamp()) + buf.AppendByte(' ') } func (e encoder) writeSource(buf *buffer, pc uintptr, cwd string) { - frame, _ := runtime.CallersFrames([]uintptr{pc}).Next() + src := slog.Source{} + + if pc > 0 { + frame, _ := runtime.CallersFrames([]uintptr{pc}).Next() + src.Function = frame.Function + src.File = frame.File + src.Line = frame.Line + } + + if e.opts.ReplaceAttr != nil { + attr := e.opts.ReplaceAttr(nil, slog.Any(slog.SourceKey, &src)) + val := attr.Value.Resolve() + + switch val.Kind() { + case slog.KindAny: + if val.Any() == nil { + // elide + return + } + if newsrc, ok := val.Any().(*slog.Source); ok { + if newsrc == nil { + // elide + return + } + + src.File = newsrc.File + src.Line = newsrc.Line + // replaced prior source fields, proceed with normal source processing + break + } + // source replaced with some other type of value, + // fallthrough to processing other value types + fallthrough + default: + // handle all non-time values by printing them like + // an attr value + e.writeColoredValue(buf, val, e.opts.Theme.Timestamp()) + e.writeColoredString(buf, " > ", e.opts.Theme.AttrKey()) + return + } + } + + if src.File == "" && src.Line == 0 { + // elide + return + } + if cwd != "" { - if ff, err := filepath.Rel(cwd, frame.File); err == nil { - frame.File = ff + if ff, err := filepath.Rel(cwd, src.File); err == nil { + src.File = ff } } e.withColor(buf, e.opts.Theme.Source(), func() { - buf.AppendString(frame.File) + buf.AppendString(src.File) buf.AppendByte(':') - buf.AppendInt(int64(frame.Line)) + buf.AppendInt(int64(src.Line)) }) e.writeColoredString(buf, " > ", e.opts.Theme.AttrKey()) } func (e encoder) writeMessage(buf *buffer, level slog.Level, msg string) { - if level >= slog.LevelInfo { - e.writeColoredString(buf, msg, e.opts.Theme.Message()) - } else { - e.writeColoredString(buf, msg, e.opts.Theme.MessageDebug()) + style := e.opts.Theme.Message() + if level < slog.LevelInfo { + style = e.opts.Theme.MessageDebug() + } + + if e.opts.ReplaceAttr != nil { + attr := e.opts.ReplaceAttr(nil, slog.String(slog.MessageKey, msg)) + val := attr.Value.Resolve() + + if val.Kind() == slog.KindAny && val.Any() == nil { + // elide + return + } + + e.writeColoredValue(buf, val, style) + return } + + e.writeColoredString(buf, msg, style) } func (e encoder) writeAttr(buf *buffer, a slog.Attr, group string) { @@ -103,7 +195,24 @@ func (e encoder) writeAttr(buf *buffer, a slog.Attr, group string) { if a.Equal(slog.Attr{}) { return } - value := a.Value.Resolve() + a.Value = a.Value.Resolve() + + if a.Value.Kind() != slog.KindGroup && e.opts.ReplaceAttr != nil { + // todo: probably inefficient to call Split here. Need to + // cache and maintain the group slice as slog.TextHandler does + // this is also causing an allocation (even when this branch + // of code is never executed) + a = e.opts.ReplaceAttr(strings.Split(group, "."), a) + + // Elide empty Attrs. + if a.Equal(slog.Attr{}) { + return + } + a.Value = a.Value.Resolve() + } + + value := a.Value + if value.Kind() == slog.KindGroup { subgroup := a.Key if group != "" { @@ -115,6 +224,7 @@ func (e encoder) writeAttr(buf *buffer, a slog.Attr, group string) { return } buf.AppendByte(' ') + e.withColor(buf, e.opts.Theme.AttrKey(), func() { if group != "" { buf.AppendString(group) @@ -123,42 +233,63 @@ func (e encoder) writeAttr(buf *buffer, a slog.Attr, group string) { buf.AppendString(a.Key) buf.AppendByte('=') }) - e.writeValue(buf, value) + e.writeColoredValue(buf, value, e.opts.Theme.AttrValue()) } -func (e encoder) writeValue(buf *buffer, value slog.Value) { - attrValue := e.opts.Theme.AttrValue() +func (e encoder) writeColoredValue(buf *buffer, value slog.Value, c ANSIMod) { switch value.Kind() { case slog.KindInt64: - e.writeColoredInt(buf, value.Int64(), attrValue) + e.writeColoredInt(buf, value.Int64(), c) case slog.KindBool: - e.writeColoredBool(buf, value.Bool(), attrValue) + e.writeColoredBool(buf, value.Bool(), c) case slog.KindFloat64: - e.writeColoredFloat(buf, value.Float64(), attrValue) + e.writeColoredFloat(buf, value.Float64(), c) case slog.KindTime: - e.writeColoredTime(buf, value.Time(), e.opts.TimeFormat, attrValue) + e.writeColoredTime(buf, value.Time(), e.opts.TimeFormat, c) case slog.KindUint64: - e.writeColoredUint(buf, value.Uint64(), attrValue) + e.writeColoredUint(buf, value.Uint64(), c) case slog.KindDuration: - e.writeColoredDuration(buf, value.Duration(), attrValue) + e.writeColoredDuration(buf, value.Duration(), c) case slog.KindAny: switch v := value.Any().(type) { case error: e.writeColoredString(buf, v.Error(), e.opts.Theme.AttrValueError()) return case fmt.Stringer: - e.writeColoredString(buf, v.String(), attrValue) + e.writeColoredString(buf, v.String(), c) return } fallthrough case slog.KindString: fallthrough default: - e.writeColoredString(buf, value.String(), attrValue) + e.writeColoredString(buf, value.String(), c) } } func (e encoder) writeLevel(buf *buffer, l slog.Level) { + var val slog.Value + var writeVal bool + + if e.opts.ReplaceAttr != nil { + attr := e.opts.ReplaceAttr(nil, slog.Any(slog.LevelKey, l)) + val = attr.Value.Resolve() + // generally, we'll write the returned value, except in one + // case: when the resolved value is itself a slog.Level + writeVal = true + + if val.Kind() == slog.KindAny { + v := val.Any() + if ll, ok := v.(slog.Level); ok { + l = ll + writeVal = false + } else if v == nil { + // elide + return + } + } + } + var style ANSIMod var str string var delta int @@ -184,9 +315,13 @@ func (e encoder) writeLevel(buf *buffer, l slog.Level) { str = "DBG" delta = int(l - slog.LevelDebug) } - if delta != 0 { - str = fmt.Sprintf("%s%+d", str, delta) + if writeVal { + e.writeColoredValue(buf, val, style) + } else { + if delta != 0 { + str = fmt.Sprintf("%s%+d", str, delta) + } + e.writeColoredString(buf, str, style) } - e.writeColoredString(buf, str, style) buf.AppendByte(' ') } diff --git a/handler.go b/handler.go index 82ce3ea..074ef16 100644 --- a/handler.go +++ b/handler.go @@ -18,6 +18,7 @@ var cwd, _ = os.Getwd() // HandlerOptions are options for a ConsoleHandler. // A zero HandlerOptions consists entirely of default values. +// ReplaceAttr works identically to [slog.HandlerOptions.ReplaceAttr] type HandlerOptions struct { // AddSource causes the handler to compute the source code position // of the log statement and add a SourceKey attribute to the output. @@ -38,6 +39,10 @@ type HandlerOptions struct { // Theme defines the colorized output using ANSI escape sequences Theme Theme + + // ReplaceAttr is called to rewrite each non-group attribute before it is logged. + // See [slog.HandlerOptions] + ReplaceAttr func(groups []string, a slog.Attr) slog.Attr } type Handler struct { @@ -86,7 +91,7 @@ func (h *Handler) Handle(_ context.Context, rec slog.Record) error { h.enc.writeTimestamp(buf, rec.Time) h.enc.writeLevel(buf, rec.Level) - if h.opts.AddSource && rec.PC > 0 { + if h.opts.AddSource { h.enc.writeSource(buf, rec.PC, cwd) } h.enc.writeMessage(buf, rec.Level, rec.Message) diff --git a/handler_test.go b/handler_test.go index eb09629..0127274 100644 --- a/handler_test.go +++ b/handler_test.go @@ -8,6 +8,7 @@ import ( "log/slog" "os" "path/filepath" + "reflect" "runtime" "testing" "time" @@ -271,6 +272,247 @@ func TestHandler_Source(t *testing.T) { AssertEqual(t, fmt.Sprintf("%s INF foobar\n", now.Format(time.DateTime)), buf.String()) } +type valuer struct { + v slog.Value +} + +func (v valuer) LogValue() slog.Value { + return v.v +} +func TestHandler_ReplaceAttr(t *testing.T) { + pc, file, line, _ := runtime.Caller(0) + cwd, _ := os.Getwd() + file, _ = filepath.Rel(cwd, file) + sourceField := fmt.Sprintf("%s:%d", file, line) + + replaceAttrWith := func(key string, out slog.Attr) func(*testing.T, []string, slog.Attr) slog.Attr { + return func(t *testing.T, s []string, a slog.Attr) slog.Attr { + if a.Key == key { + return out + } + return a + } + } + + awesomeVal := slog.Any("valuer", valuer{slog.StringValue("awesome")}) + + tests := []struct { + name string + replaceAttr func(*testing.T, []string, slog.Attr) slog.Attr + want string + modrec func(*slog.Record) + noSource bool + groups []string + }{ + { + name: "no replaceattrs", + want: "2010-05-06 07:08:09 INF " + sourceField + " > foobar size=12 color=red\n", + }, + { + name: "not called for empty timestamp and disabled source", + modrec: func(r *slog.Record) { + r.Time = time.Time{} + }, + noSource: true, + want: "INF foobar size=12 color=red\n", + replaceAttr: func(t *testing.T, s []string, a slog.Attr) slog.Attr { + switch a.Key { + case slog.TimeKey, slog.SourceKey: + t.Errorf("replaceAttr should not have been called for %v", a) + } + return a + }, + }, + { + name: "not called for groups", + modrec: func(r *slog.Record) { r.Add(slog.Group("l1", slog.String("flavor", "vanilla"))) }, + replaceAttr: func(t *testing.T, s []string, a slog.Attr) slog.Attr { + if a.Key == "l1" { + t.Errorf("should not have been called on group attrs, was called on %v", a) + } + return a + }, + want: "2010-05-06 07:08:09 INF " + sourceField + " > foobar size=12 color=red l1.flavor=vanilla\n", + }, + { + name: "groups should be empty for builtins", + groups: []string{"l1", "l2"}, + replaceAttr: func(t *testing.T, s []string, a slog.Attr) slog.Attr { + switch a.Key { + case slog.TimeKey, slog.SourceKey, slog.MessageKey, slog.LevelKey: + if len(s) != 0 { + t.Errorf("for builtin attrs, expected no groups, got %v", s) + } + default: + wantGroups := []string{"l1", "l2"} + if !reflect.DeepEqual(wantGroups, s) { + t.Errorf("for other attrs, expected %v, got %v", wantGroups, s) + } + } + return a + }, + want: "2010-05-06 07:08:09 INF " + sourceField + " > foobar l1.l2.size=12 l1.l2.color=red\n", + }, + { + name: "clear timestamp", + replaceAttr: replaceAttrWith(slog.TimeKey, slog.Time(slog.TimeKey, time.Time{})), + want: "INF " + sourceField + " > foobar size=12 color=red\n", + }, + { + name: "replace timestamp", + replaceAttr: replaceAttrWith(slog.TimeKey, slog.Time(slog.TimeKey, time.Date(2000, 2, 3, 4, 5, 6, 0, time.UTC))), + want: "2000-02-03 04:05:06 INF " + sourceField + " > foobar size=12 color=red\n", + }, + { + name: "replace timestamp with different kind", + replaceAttr: replaceAttrWith(slog.TimeKey, slog.String("color", "red")), + want: "red INF " + sourceField + " > foobar size=12 color=red\n", + }, + { + name: "replace timestamp with valuer", + replaceAttr: replaceAttrWith(slog.TimeKey, awesomeVal), + want: "awesome INF " + sourceField + " > foobar size=12 color=red\n", + }, + { + name: "replace timestamp with time valuer", + replaceAttr: replaceAttrWith(slog.TimeKey, slog.Any("valuer", valuer{slog.TimeValue(time.Date(2000, 2, 3, 4, 5, 6, 0, time.UTC))})), + want: "2000-02-03 04:05:06 INF " + sourceField + " > foobar size=12 color=red\n", + }, + { + name: "replace level", + replaceAttr: replaceAttrWith(slog.LevelKey, slog.Any(slog.LevelKey, slog.LevelWarn)), + want: "2010-05-06 07:08:09 WRN " + sourceField + " > foobar size=12 color=red\n", + }, + { + name: "clear level", + replaceAttr: replaceAttrWith(slog.LevelKey, slog.Any(slog.LevelKey, nil)), + want: "2010-05-06 07:08:09 " + sourceField + " > foobar size=12 color=red\n", + }, + { + name: "replace level with different kind", + replaceAttr: replaceAttrWith(slog.LevelKey, slog.String("color", "red")), + want: "2010-05-06 07:08:09 red " + sourceField + " > foobar size=12 color=red\n", + }, + { + name: "replace level with valuer", + replaceAttr: replaceAttrWith(slog.LevelKey, awesomeVal), + want: "2010-05-06 07:08:09 awesome " + sourceField + " > foobar size=12 color=red\n", + }, + { + name: "replace level with level valuer", + replaceAttr: replaceAttrWith(slog.LevelKey, slog.Any("valuer", valuer{slog.AnyValue(slog.LevelWarn)})), + want: "2010-05-06 07:08:09 WRN " + sourceField + " > foobar size=12 color=red\n", + }, + { + name: "clear source", + replaceAttr: replaceAttrWith(slog.SourceKey, slog.Any(slog.SourceKey, nil)), + want: "2010-05-06 07:08:09 INF foobar size=12 color=red\n", + }, + { + name: "replace source", + replaceAttr: replaceAttrWith(slog.SourceKey, slog.Any(slog.SourceKey, &slog.Source{ + File: filepath.Join(cwd, "path", "to", "file.go"), + Line: 33, + })), + want: "2010-05-06 07:08:09 INF path/to/file.go:33 > foobar size=12 color=red\n", + }, + { + name: "replace source with different kind", + replaceAttr: replaceAttrWith(slog.SourceKey, slog.String("color", "red")), + want: "2010-05-06 07:08:09 INF red > foobar size=12 color=red\n", + }, + { + name: "replace source with valuer", + replaceAttr: replaceAttrWith(slog.SourceKey, awesomeVal), + want: "2010-05-06 07:08:09 INF awesome > foobar size=12 color=red\n", + }, + { + name: "replace source with source valuer", + replaceAttr: replaceAttrWith(slog.SourceKey, slog.Any("valuer", valuer{slog.AnyValue(&slog.Source{ + File: filepath.Join(cwd, "path", "to", "file.go"), + Line: 33, + })})), + want: "2010-05-06 07:08:09 INF path/to/file.go:33 > foobar size=12 color=red\n", + }, + { + name: "empty source", // should still be called + modrec: func(r *slog.Record) { r.PC = 0 }, + replaceAttr: replaceAttrWith(slog.SourceKey, slog.Any(slog.SourceKey, &slog.Source{ + File: filepath.Join(cwd, "path", "to", "file.go"), + Line: 33, + })), + want: "2010-05-06 07:08:09 INF path/to/file.go:33 > foobar size=12 color=red\n", + }, + { + name: "clear message", + replaceAttr: replaceAttrWith(slog.MessageKey, slog.Any(slog.MessageKey, nil)), + want: "2010-05-06 07:08:09 INF " + sourceField + " > size=12 color=red\n", + }, + { + name: "replace message", + replaceAttr: replaceAttrWith(slog.MessageKey, slog.String(slog.MessageKey, "barbaz")), + want: "2010-05-06 07:08:09 INF " + sourceField + " > barbaz size=12 color=red\n", + }, + { + name: "replace message with different kind", + replaceAttr: replaceAttrWith(slog.MessageKey, slog.Int(slog.MessageKey, 5)), + want: "2010-05-06 07:08:09 INF " + sourceField + " > 5 size=12 color=red\n", + }, + { + name: "replace message with valuer", + replaceAttr: replaceAttrWith(slog.MessageKey, awesomeVal), + want: "2010-05-06 07:08:09 INF " + sourceField + " > awesome size=12 color=red\n", + }, + { + name: "clear attr", + replaceAttr: replaceAttrWith("size", slog.Attr{}), + want: "2010-05-06 07:08:09 INF " + sourceField + " > foobar color=red\n", + }, + { + name: "replace attr", + replaceAttr: replaceAttrWith("size", slog.String("flavor", "vanilla")), + want: "2010-05-06 07:08:09 INF " + sourceField + " > foobar flavor=vanilla color=red\n", + }, + { + name: "group attrs", + replaceAttr: replaceAttrWith("size", slog.Group("l1", slog.String("flavor", "vanilla"))), + want: "2010-05-06 07:08:09 INF " + sourceField + " > foobar l1.flavor=vanilla color=red\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + buf := bytes.Buffer{} + + rec := slog.NewRecord(time.Date(2010, 5, 6, 7, 8, 9, 0, time.UTC), slog.LevelInfo, "foobar", pc) + rec.Add("size", 12, "color", "red") + + if test.modrec != nil { + test.modrec(&rec) + } + + var replaceAttr func([]string, slog.Attr) slog.Attr + if test.replaceAttr != nil { + replaceAttr = func(s []string, a slog.Attr) slog.Attr { + return test.replaceAttr(t, s, a) + } + } + + var h slog.Handler = NewHandler(&buf, &HandlerOptions{AddSource: !test.noSource, NoColor: true, ReplaceAttr: replaceAttr}) + + for _, group := range test.groups { + h = h.WithGroup(group) + } + + AssertNoError(t, h.Handle(context.Background(), rec)) + + AssertEqual(t, test.want, buf.String()) + + }) + } + +} + func TestHandler_Err(t *testing.T) { w := writerFunc(func(b []byte) (int, error) { return 0, errors.New("nope") }) h := NewHandler(w, &HandlerOptions{NoColor: true}) From 8a64069dfe3b24e252f1eea9ca001a6f3ecb4e99 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Fri, 10 Jan 2025 11:25:05 -0600 Subject: [PATCH 05/44] Optimized out the allocation Encoders are now the disposable state struct for each Handle call. A sync pool of encoders replaces the sync pool of buffers. This allows the encoder to store additional state per Handle() call, including a slice of currently open groups, which is needed to call ReplaceAttr. Benchmarking shows 0 allocations, regardless of whether ReplaceAttr is set or not. Setting a ReplaceAttr does add some overhead, similar to the slog.TextHandler. Benchmarking also shows a slight performance improvement over the original code (using the buf pool), when ReplaceAttrs is not set. --- bench_test.go | 2 + buffer.go | 6 +++ encoding.go | 126 +++++++++++++++++++++++++++++++----------------- handler.go | 72 +++++++++++++-------------- handler_test.go | 21 +++++--- 5 files changed, 137 insertions(+), 90 deletions(-) diff --git a/bench_test.go b/bench_test.go index 88736ac..c520644 100644 --- a/bench_test.go +++ b/bench_test.go @@ -22,7 +22,9 @@ var handlers = []struct { }{ {"dummy", &DummyHandler{}}, {"console", NewHandler(io.Discard, &HandlerOptions{Level: slog.LevelDebug, AddSource: false})}, + // {"console-replaceattr", NewHandler(io.Discard, &HandlerOptions{Level: slog.LevelDebug, AddSource: false, ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { return a }})}, {"std-text", slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: false})}, + // {"std-text-replaceattr", slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: false, ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { return a }})}, {"std-json", slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: false})}, } diff --git a/buffer.go b/buffer.go index ddff9c3..6f77dde 100644 --- a/buffer.go +++ b/buffer.go @@ -46,6 +46,12 @@ func (b *buffer) WriteTo(dst io.Writer) (int64, error) { } func (b *buffer) Reset() { + // To reduce peak allocation, return only smaller buffers to the pool. + const maxBufferSize = 16 << 10 + if cap(*b) > maxBufferSize { + *b = (*b)[:0:maxBufferSize] + return + } *b = (*b)[:0] } diff --git a/encoding.go b/encoding.go index ea4d1a3..abed144 100644 --- a/encoding.go +++ b/encoding.go @@ -5,20 +5,50 @@ import ( "log/slog" "path/filepath" "runtime" - "strings" + "sync" "time" ) +var encoderPool = &sync.Pool{ + New: func() any { + e := new(encoder) + e.groups = make([]string, 0, 10) + e.buf = make(buffer, 0, 1024) + return e + }, +} + type encoder struct { - opts HandlerOptions + h *Handler + buf buffer + groups []string +} + +func newEncoder(h *Handler) *encoder { + e := encoderPool.Get().(*encoder) + e.h = h + if h.opts.ReplaceAttr != nil { + e.groups = append(e.groups, h.groups...) + } + return e } -func (e encoder) NewLine(buf *buffer) { +func (e *encoder) free() { + if e == nil { + return + } + e.h = nil + e.buf.Reset() + e.groups = e.groups[:0] + encoderPool.Put(e) +} + +func (e *encoder) NewLine(buf *buffer) { buf.AppendByte('\n') } -func (e encoder) withColor(b *buffer, c ANSIMod, f func()) { - if c == "" || e.opts.NoColor { +func (e *encoder) withColor(b *buffer, c ANSIMod, f func()) { + if c == "" || e.h.opts.NoColor { f() return } @@ -27,56 +57,56 @@ func (e encoder) withColor(b *buffer, c ANSIMod, f func()) { b.AppendString(string(ResetMod)) } -func (e encoder) writeColoredTime(w *buffer, t time.Time, format string, c ANSIMod) { +func (e *encoder) writeColoredTime(w *buffer, t time.Time, format string, c ANSIMod) { e.withColor(w, c, func() { w.AppendTime(t, format) }) } -func (e encoder) writeColoredString(w *buffer, s string, c ANSIMod) { +func (e *encoder) writeColoredString(w *buffer, s string, c ANSIMod) { e.withColor(w, c, func() { w.AppendString(s) }) } -func (e encoder) writeColoredInt(w *buffer, i int64, c ANSIMod) { +func (e *encoder) writeColoredInt(w *buffer, i int64, c ANSIMod) { e.withColor(w, c, func() { w.AppendInt(i) }) } -func (e encoder) writeColoredUint(w *buffer, i uint64, c ANSIMod) { +func (e *encoder) writeColoredUint(w *buffer, i uint64, c ANSIMod) { e.withColor(w, c, func() { w.AppendUint(i) }) } -func (e encoder) writeColoredFloat(w *buffer, i float64, c ANSIMod) { +func (e *encoder) writeColoredFloat(w *buffer, i float64, c ANSIMod) { e.withColor(w, c, func() { w.AppendFloat(i) }) } -func (e encoder) writeColoredBool(w *buffer, b bool, c ANSIMod) { +func (e *encoder) writeColoredBool(w *buffer, b bool, c ANSIMod) { e.withColor(w, c, func() { w.AppendBool(b) }) } -func (e encoder) writeColoredDuration(w *buffer, d time.Duration, c ANSIMod) { +func (e *encoder) writeColoredDuration(w *buffer, d time.Duration, c ANSIMod) { e.withColor(w, c, func() { w.AppendDuration(d) }) } -func (e encoder) writeTimestamp(buf *buffer, tt time.Time) { +func (e *encoder) writeTimestamp(buf *buffer, tt time.Time) { if tt.IsZero() { // elide, and skip ReplaceAttr return } - if e.opts.ReplaceAttr != nil { - attr := e.opts.ReplaceAttr(nil, slog.Time(slog.TimeKey, tt)) + if e.h.opts.ReplaceAttr != nil { + attr := e.h.opts.ReplaceAttr(nil, slog.Time(slog.TimeKey, tt)) val := attr.Value.Resolve() switch val.Kind() { @@ -97,17 +127,17 @@ func (e encoder) writeTimestamp(buf *buffer, tt time.Time) { default: // handle all non-time values by printing them like // an attr value - e.writeColoredValue(buf, val, e.opts.Theme.Timestamp()) + e.writeColoredValue(buf, val, e.h.opts.Theme.Timestamp()) buf.AppendByte(' ') return } } - e.writeColoredTime(buf, tt, e.opts.TimeFormat, e.opts.Theme.Timestamp()) + e.writeColoredTime(buf, tt, e.h.opts.TimeFormat, e.h.opts.Theme.Timestamp()) buf.AppendByte(' ') } -func (e encoder) writeSource(buf *buffer, pc uintptr, cwd string) { +func (e *encoder) writeSource(buf *buffer, pc uintptr, cwd string) { src := slog.Source{} if pc > 0 { @@ -117,8 +147,8 @@ func (e encoder) writeSource(buf *buffer, pc uintptr, cwd string) { src.Line = frame.Line } - if e.opts.ReplaceAttr != nil { - attr := e.opts.ReplaceAttr(nil, slog.Any(slog.SourceKey, &src)) + if e.h.opts.ReplaceAttr != nil { + attr := e.h.opts.ReplaceAttr(nil, slog.Any(slog.SourceKey, &src)) val := attr.Value.Resolve() switch val.Kind() { @@ -144,8 +174,8 @@ func (e encoder) writeSource(buf *buffer, pc uintptr, cwd string) { default: // handle all non-time values by printing them like // an attr value - e.writeColoredValue(buf, val, e.opts.Theme.Timestamp()) - e.writeColoredString(buf, " > ", e.opts.Theme.AttrKey()) + e.writeColoredValue(buf, val, e.h.opts.Theme.Timestamp()) + e.writeColoredString(buf, " > ", e.h.opts.Theme.AttrKey()) return } } @@ -160,22 +190,22 @@ func (e encoder) writeSource(buf *buffer, pc uintptr, cwd string) { src.File = ff } } - e.withColor(buf, e.opts.Theme.Source(), func() { + e.withColor(buf, e.h.opts.Theme.Source(), func() { buf.AppendString(src.File) buf.AppendByte(':') buf.AppendInt(int64(src.Line)) }) - e.writeColoredString(buf, " > ", e.opts.Theme.AttrKey()) + e.writeColoredString(buf, " > ", e.h.opts.Theme.AttrKey()) } -func (e encoder) writeMessage(buf *buffer, level slog.Level, msg string) { - style := e.opts.Theme.Message() +func (e *encoder) writeMessage(buf *buffer, level slog.Level, msg string) { + style := e.h.opts.Theme.Message() if level < slog.LevelInfo { - style = e.opts.Theme.MessageDebug() + style = e.h.opts.Theme.MessageDebug() } - if e.opts.ReplaceAttr != nil { - attr := e.opts.ReplaceAttr(nil, slog.String(slog.MessageKey, msg)) + if e.h.opts.ReplaceAttr != nil { + attr := e.h.opts.ReplaceAttr(nil, slog.String(slog.MessageKey, msg)) val := attr.Value.Resolve() if val.Kind() == slog.KindAny && val.Any() == nil { @@ -190,19 +220,19 @@ func (e encoder) writeMessage(buf *buffer, level slog.Level, msg string) { e.writeColoredString(buf, msg, style) } -func (e encoder) writeAttr(buf *buffer, a slog.Attr, group string) { +func (e *encoder) writeAttr(buf *buffer, a slog.Attr, group string) { // Elide empty Attrs. if a.Equal(slog.Attr{}) { return } a.Value = a.Value.Resolve() - if a.Value.Kind() != slog.KindGroup && e.opts.ReplaceAttr != nil { + if a.Value.Kind() != slog.KindGroup && e.h.opts.ReplaceAttr != nil { // todo: probably inefficient to call Split here. Need to // cache and maintain the group slice as slog.TextHandler does // this is also causing an allocation (even when this branch // of code is never executed) - a = e.opts.ReplaceAttr(strings.Split(group, "."), a) + a = e.h.opts.ReplaceAttr(e.groups, a) // Elide empty Attrs. if a.Equal(slog.Attr{}) { @@ -218,14 +248,20 @@ func (e encoder) writeAttr(buf *buffer, a slog.Attr, group string) { if group != "" { subgroup = group + "." + a.Key } + if e.h.opts.ReplaceAttr != nil { + e.groups = append(e.groups, a.Key) + } for _, attr := range value.Group() { e.writeAttr(buf, attr, subgroup) } + if e.h.opts.ReplaceAttr != nil { + e.groups = e.groups[:len(e.groups)-1] + } return } buf.AppendByte(' ') - e.withColor(buf, e.opts.Theme.AttrKey(), func() { + e.withColor(buf, e.h.opts.Theme.AttrKey(), func() { if group != "" { buf.AppendString(group) buf.AppendByte('.') @@ -233,10 +269,10 @@ func (e encoder) writeAttr(buf *buffer, a slog.Attr, group string) { buf.AppendString(a.Key) buf.AppendByte('=') }) - e.writeColoredValue(buf, value, e.opts.Theme.AttrValue()) + e.writeColoredValue(buf, value, e.h.opts.Theme.AttrValue()) } -func (e encoder) writeColoredValue(buf *buffer, value slog.Value, c ANSIMod) { +func (e *encoder) writeColoredValue(buf *buffer, value slog.Value, c ANSIMod) { switch value.Kind() { case slog.KindInt64: e.writeColoredInt(buf, value.Int64(), c) @@ -245,7 +281,7 @@ func (e encoder) writeColoredValue(buf *buffer, value slog.Value, c ANSIMod) { case slog.KindFloat64: e.writeColoredFloat(buf, value.Float64(), c) case slog.KindTime: - e.writeColoredTime(buf, value.Time(), e.opts.TimeFormat, c) + e.writeColoredTime(buf, value.Time(), e.h.opts.TimeFormat, c) case slog.KindUint64: e.writeColoredUint(buf, value.Uint64(), c) case slog.KindDuration: @@ -253,7 +289,7 @@ func (e encoder) writeColoredValue(buf *buffer, value slog.Value, c ANSIMod) { case slog.KindAny: switch v := value.Any().(type) { case error: - e.writeColoredString(buf, v.Error(), e.opts.Theme.AttrValueError()) + e.writeColoredString(buf, v.Error(), e.h.opts.Theme.AttrValueError()) return case fmt.Stringer: e.writeColoredString(buf, v.String(), c) @@ -267,12 +303,12 @@ func (e encoder) writeColoredValue(buf *buffer, value slog.Value, c ANSIMod) { } } -func (e encoder) writeLevel(buf *buffer, l slog.Level) { +func (e *encoder) writeLevel(buf *buffer, l slog.Level) { var val slog.Value var writeVal bool - if e.opts.ReplaceAttr != nil { - attr := e.opts.ReplaceAttr(nil, slog.Any(slog.LevelKey, l)) + if e.h.opts.ReplaceAttr != nil { + attr := e.h.opts.ReplaceAttr(nil, slog.Any(slog.LevelKey, l)) val = attr.Value.Resolve() // generally, we'll write the returned value, except in one // case: when the resolved value is itself a slog.Level @@ -295,23 +331,23 @@ func (e encoder) writeLevel(buf *buffer, l slog.Level) { var delta int switch { case l >= slog.LevelError: - style = e.opts.Theme.LevelError() + style = e.h.opts.Theme.LevelError() str = "ERR" delta = int(l - slog.LevelError) case l >= slog.LevelWarn: - style = e.opts.Theme.LevelWarn() + style = e.h.opts.Theme.LevelWarn() str = "WRN" delta = int(l - slog.LevelWarn) case l >= slog.LevelInfo: - style = e.opts.Theme.LevelInfo() + style = e.h.opts.Theme.LevelInfo() str = "INF" delta = int(l - slog.LevelInfo) case l >= slog.LevelDebug: - style = e.opts.Theme.LevelDebug() + style = e.h.opts.Theme.LevelDebug() str = "DBG" delta = int(l - slog.LevelDebug) default: - style = e.opts.Theme.LevelDebug() + style = e.h.opts.Theme.LevelDebug() str = "DBG" delta = int(l - slog.LevelDebug) } diff --git a/handler.go b/handler.go index 074ef16..8655ff1 100644 --- a/handler.go +++ b/handler.go @@ -6,14 +6,9 @@ import ( "log/slog" "os" "strings" - "sync" "time" ) -var bufferPool = &sync.Pool{ - New: func() any { return new(buffer) }, -} - var cwd, _ = os.Getwd() // HandlerOptions are options for a ConsoleHandler. @@ -46,11 +41,11 @@ type HandlerOptions struct { } type Handler struct { - opts HandlerOptions - out io.Writer - group string - context buffer - enc *encoder + opts HandlerOptions + out io.Writer + groupPrefix string + groups []string + context buffer } var _ slog.Handler = (*Handler)(nil) @@ -72,11 +67,10 @@ func NewHandler(out io.Writer, opts *HandlerOptions) *Handler { opts.Theme = NewDefaultTheme() } return &Handler{ - opts: *opts, // Copy struct - out: out, - group: "", - context: nil, - enc: &encoder{opts: *opts}, + opts: *opts, // Copy struct + out: out, + groupPrefix: "", + context: nil, } } @@ -87,56 +81,58 @@ func (h *Handler) Enabled(_ context.Context, l slog.Level) bool { // Handle implements slog.Handler. func (h *Handler) Handle(_ context.Context, rec slog.Record) error { - buf := bufferPool.Get().(*buffer) + enc := newEncoder(h) + buf := &enc.buf - h.enc.writeTimestamp(buf, rec.Time) - h.enc.writeLevel(buf, rec.Level) + enc.writeTimestamp(buf, rec.Time) + enc.writeLevel(buf, rec.Level) if h.opts.AddSource { - h.enc.writeSource(buf, rec.PC, cwd) + enc.writeSource(buf, rec.PC, cwd) } - h.enc.writeMessage(buf, rec.Level, rec.Message) + enc.writeMessage(buf, rec.Level, rec.Message) buf.copy(&h.context) rec.Attrs(func(a slog.Attr) bool { - h.enc.writeAttr(buf, a, h.group) + enc.writeAttr(buf, a, h.groupPrefix) return true }) - h.enc.NewLine(buf) + enc.NewLine(buf) if _, err := buf.WriteTo(h.out); err != nil { - buf.Reset() - bufferPool.Put(buf) return err } - bufferPool.Put(buf) + + enc.free() return nil } // WithAttrs implements slog.Handler. func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { newCtx := h.context + enc := newEncoder(h) for _, a := range attrs { - h.enc.writeAttr(&newCtx, a, h.group) + enc.writeAttr(&newCtx, a, h.groupPrefix) } newCtx.Clip() return &Handler{ - opts: h.opts, - out: h.out, - group: h.group, - context: newCtx, - enc: h.enc, + opts: h.opts, + out: h.out, + groupPrefix: h.groupPrefix, + context: newCtx, + groups: h.groups, } } // WithGroup implements slog.Handler. func (h *Handler) WithGroup(name string) slog.Handler { name = strings.TrimSpace(name) - if h.group != "" { - name = h.group + "." + name + groupPrefix := name + if h.groupPrefix != "" { + groupPrefix = h.groupPrefix + "." + name } return &Handler{ - opts: h.opts, - out: h.out, - group: name, - context: h.context, - enc: h.enc, + opts: h.opts, + out: h.out, + groupPrefix: groupPrefix, + context: h.context, + groups: append(h.groups, name), } } diff --git a/handler_test.go b/handler_test.go index 0127274..a11e518 100644 --- a/handler_test.go +++ b/handler_test.go @@ -335,23 +335,30 @@ func TestHandler_ReplaceAttr(t *testing.T) { want: "2010-05-06 07:08:09 INF " + sourceField + " > foobar size=12 color=red l1.flavor=vanilla\n", }, { - name: "groups should be empty for builtins", + name: "groups arg", groups: []string{"l1", "l2"}, + modrec: func(r *slog.Record) { + r.Add(slog.Group("l3", slog.String("flavor", "vanilla"))) + r.Add(slog.Int("weight", 23)) + }, replaceAttr: func(t *testing.T, s []string, a slog.Attr) slog.Attr { + wantGroups := []string{"l1", "l2"} switch a.Key { case slog.TimeKey, slog.SourceKey, slog.MessageKey, slog.LevelKey: if len(s) != 0 { - t.Errorf("for builtin attrs, expected no groups, got %v", s) + t.Errorf("for builtin attr %v, expected no groups, got %v", a.Key, s) } + case "flavor": + wantGroups = []string{"l1", "l2", "l3"} + fallthrough default: - wantGroups := []string{"l1", "l2"} if !reflect.DeepEqual(wantGroups, s) { - t.Errorf("for other attrs, expected %v, got %v", wantGroups, s) + t.Errorf("for %v attr, expected %v, got %v", a.Key, wantGroups, s) } } - return a + return slog.String(a.Key, a.Key) }, - want: "2010-05-06 07:08:09 INF " + sourceField + " > foobar l1.l2.size=12 l1.l2.color=red\n", + want: "time level source > msg l1.l2.size=size l1.l2.color=color l1.l2.l3.flavor=flavor l1.l2.weight=weight\n", }, { name: "clear timestamp", @@ -474,7 +481,7 @@ func TestHandler_ReplaceAttr(t *testing.T) { want: "2010-05-06 07:08:09 INF " + sourceField + " > foobar flavor=vanilla color=red\n", }, { - name: "group attrs", + name: "replace with group attrs", replaceAttr: replaceAttrWith("size", slog.Group("l1", slog.String("flavor", "vanilla"))), want: "2010-05-06 07:08:09 INF " + sourceField + " > foobar l1.flavor=vanilla color=red\n", }, From 7cda51fa594d114cc2eb8049e38802742b916070 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Fri, 10 Jan 2025 11:41:45 -0600 Subject: [PATCH 06/44] optimize for errors which don't implement fmt.Formatter If the error doesn't implement fmt.Formatter, as most won't, its faster to use err.Error() than always use fmt to print the error. --- encoding.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/encoding.go b/encoding.go index 6214a69..4e39eea 100644 --- a/encoding.go +++ b/encoding.go @@ -144,9 +144,13 @@ func (e encoder) writeValue(buf *buffer, value slog.Value) { case slog.KindAny: switch v := value.Any().(type) { case error: - e.withColor(buf, e.opts.Theme.AttrValueError(), func() { - fmt.Fprintf(buf, "%+v", v) - }) + if _, ok := v.(fmt.Formatter); ok { + e.withColor(buf, e.opts.Theme.AttrValueError(), func() { + fmt.Fprintf(buf, "%+v", v) + }) + } else { + e.writeColoredString(buf, v.Error(), e.opts.Theme.AttrValueError()) + } return case fmt.Stringer: e.writeColoredString(buf, v.String(), attrValue) From 6a1e91f02f80034896a5b022cd1f9dcc55cb0a42 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Fri, 10 Jan 2025 12:12:02 -0600 Subject: [PATCH 07/44] style should always be passed to writeColoredValue() Only the caller knows the appropriate style to use for the current context in the log line. --- encoding.go | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/encoding.go b/encoding.go index e1c822c..ec4ef21 100644 --- a/encoding.go +++ b/encoding.go @@ -269,43 +269,50 @@ func (e *encoder) writeAttr(buf *buffer, a slog.Attr, group string) { buf.AppendString(a.Key) buf.AppendByte('=') }) - e.writeColoredValue(buf, value, e.h.opts.Theme.AttrValue()) + + style := e.h.opts.Theme.AttrValue() + if value.Kind() == slog.KindAny { + if _, ok := value.Any().(error); ok { + style = e.h.opts.Theme.AttrValueError() + } + } + e.writeColoredValue(buf, value, style) } -func (e *encoder) writeColoredValue(buf *buffer, value slog.Value, c ANSIMod) { +func (e *encoder) writeColoredValue(buf *buffer, value slog.Value, style ANSIMod) { switch value.Kind() { case slog.KindInt64: - e.writeColoredInt(buf, value.Int64(), c) + e.writeColoredInt(buf, value.Int64(), style) case slog.KindBool: - e.writeColoredBool(buf, value.Bool(), c) + e.writeColoredBool(buf, value.Bool(), style) case slog.KindFloat64: - e.writeColoredFloat(buf, value.Float64(), c) + e.writeColoredFloat(buf, value.Float64(), style) case slog.KindTime: - e.writeColoredTime(buf, value.Time(), e.h.opts.TimeFormat, c) + e.writeColoredTime(buf, value.Time(), e.h.opts.TimeFormat, style) case slog.KindUint64: - e.writeColoredUint(buf, value.Uint64(), c) + e.writeColoredUint(buf, value.Uint64(), style) case slog.KindDuration: - e.writeColoredDuration(buf, value.Duration(), c) + e.writeColoredDuration(buf, value.Duration(), style) case slog.KindAny: switch v := value.Any().(type) { case error: if _, ok := v.(fmt.Formatter); ok { - e.withColor(buf, e.opts.Theme.AttrValueError(), func() { + e.withColor(buf, style, func() { fmt.Fprintf(buf, "%+v", v) }) } else { - e.writeColoredString(buf, v.Error(), e.h.opts.Theme.AttrValueError()) + e.writeColoredString(buf, v.Error(), style) } return case fmt.Stringer: - e.writeColoredString(buf, v.String(), c) + e.writeColoredString(buf, v.String(), style) return } fallthrough case slog.KindString: fallthrough default: - e.writeColoredString(buf, value.String(), c) + e.writeColoredString(buf, value.String(), style) } } From cd141b944aae16755fcabd8ac2bc087ffafbe317 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Fri, 10 Jan 2025 15:54:10 -0600 Subject: [PATCH 08/44] WIP Add support for adding headers Headers are optional attributes removed from the end of the line, and injected (values only) into the header of the line, after the level/source, and before the message. --- encoding.go | 142 ++++++++++++++++++++++++-------------------- handler.go | 88 ++++++++++++++++++++++++---- handler_test.go | 153 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 311 insertions(+), 72 deletions(-) diff --git a/encoding.go b/encoding.go index ec4ef21..2a06c0a 100644 --- a/encoding.go +++ b/encoding.go @@ -14,14 +14,15 @@ var encoderPool = &sync.Pool{ e := new(encoder) e.groups = make([]string, 0, 10) e.buf = make(buffer, 0, 1024) + e.trailerBuf = make(buffer, 0, 1024) return e }, } type encoder struct { - h *Handler - buf buffer - groups []string + h *Handler + buf, trailerBuf buffer + groups []string } func newEncoder(h *Handler) *encoder { @@ -39,6 +40,7 @@ func (e *encoder) free() { } e.h = nil e.buf.Reset() + e.trailerBuf.Reset() e.groups = e.groups[:0] encoderPool.Put(e) } @@ -107,37 +109,35 @@ func (e *encoder) writeTimestamp(buf *buffer, tt time.Time) { if e.h.opts.ReplaceAttr != nil { attr := e.h.opts.ReplaceAttr(nil, slog.Time(slog.TimeKey, tt)) - val := attr.Value.Resolve() - - switch val.Kind() { - case slog.KindTime: - // most common case - tt = val.Time() - if tt.IsZero() { - // elide - return - } - // skip to normal timestamp formatting and printing - case slog.KindAny: - if val.Any() == nil { - // elide - return - } - fallthrough - default: + attr.Value = attr.Value.Resolve() + + if attr.Value.Equal(slog.Value{}) { + // elide + return + } + + if attr.Value.Kind() != slog.KindTime { // handle all non-time values by printing them like // an attr value - e.writeColoredValue(buf, val, e.h.opts.Theme.Timestamp()) + e.writeColoredValue(buf, attr.Value, e.h.opts.Theme.Timestamp()) buf.AppendByte(' ') return } + + // most common case + tt = attr.Value.Time() + if tt.IsZero() { + // elide + return + } } e.writeColoredTime(buf, tt, e.h.opts.TimeFormat, e.h.opts.Theme.Timestamp()) buf.AppendByte(' ') } -func (e *encoder) writeSource(buf *buffer, pc uintptr, cwd string) { +// writeSource returns true if source was written, false if elided +func (e *encoder) writeSource(buf *buffer, pc uintptr, cwd string) bool { src := slog.Source{} if pc > 0 { @@ -149,18 +149,18 @@ func (e *encoder) writeSource(buf *buffer, pc uintptr, cwd string) { if e.h.opts.ReplaceAttr != nil { attr := e.h.opts.ReplaceAttr(nil, slog.Any(slog.SourceKey, &src)) - val := attr.Value.Resolve() + attr.Value = attr.Value.Resolve() + + if attr.Value.Equal(slog.Value{}) { + return false + } - switch val.Kind() { + switch attr.Value.Kind() { case slog.KindAny: - if val.Any() == nil { - // elide - return - } - if newsrc, ok := val.Any().(*slog.Source); ok { + if newsrc, ok := attr.Value.Any().(*slog.Source); ok { if newsrc == nil { // elide - return + return false } src.File = newsrc.File @@ -174,15 +174,15 @@ func (e *encoder) writeSource(buf *buffer, pc uintptr, cwd string) { default: // handle all non-time values by printing them like // an attr value - e.writeColoredValue(buf, val, e.h.opts.Theme.Timestamp()) - e.writeColoredString(buf, " > ", e.h.opts.Theme.AttrKey()) - return + e.writeColoredValue(buf, attr.Value, e.h.opts.Theme.Timestamp()) + buf.AppendByte(' ') + return true } } if src.File == "" && src.Line == 0 { // elide - return + return false } if cwd != "" { @@ -194,8 +194,10 @@ func (e *encoder) writeSource(buf *buffer, pc uintptr, cwd string) { buf.AppendString(src.File) buf.AppendByte(':') buf.AppendInt(int64(src.Line)) + buf.AppendByte(' ') }) - e.writeColoredString(buf, " > ", e.h.opts.Theme.AttrKey()) + + return true } func (e *encoder) writeMessage(buf *buffer, level slog.Level, msg string) { @@ -206,40 +208,53 @@ func (e *encoder) writeMessage(buf *buffer, level slog.Level, msg string) { if e.h.opts.ReplaceAttr != nil { attr := e.h.opts.ReplaceAttr(nil, slog.String(slog.MessageKey, msg)) - val := attr.Value.Resolve() - - if val.Kind() == slog.KindAny && val.Any() == nil { + attr.Value = attr.Value.Resolve() + if attr.Value.Equal(slog.Value{}) { // elide return } - e.writeColoredValue(buf, val, style) + e.writeColoredValue(buf, attr.Value, style) return } e.writeColoredString(buf, msg, style) } -func (e *encoder) writeAttr(buf *buffer, a slog.Attr, group string) { - // Elide empty Attrs. - if a.Equal(slog.Attr{}) { - return +func (e encoder) writeHeaders(buf *buffer, headers []slog.Attr) bool { + wrote := false + for _, a := range headers { + if a.Value.Kind() != slog.KindGroup && e.h.opts.ReplaceAttr != nil { + a = e.h.opts.ReplaceAttr(nil, a) + a.Value = a.Value.Resolve() + } + // todo: this skips empty values, omitting them entire from the header. + // alternately, I could print or something, so the number of + // headers in each log entry is always fixed... + if a.Value.Equal(slog.Value{}) { + continue + } + e.writeColoredValue(buf, a.Value, e.h.opts.Theme.Source()) + buf.AppendByte(' ') + wrote = true } - a.Value = a.Value.Resolve() + return wrote +} + +func (e encoder) writeHeaderSeparator(buf *buffer) { + e.writeColoredString(buf, "> ", e.h.opts.Theme.AttrKey()) +} +func (e *encoder) writeAttr(buf *buffer, a slog.Attr, group string) { + a.Value = a.Value.Resolve() if a.Value.Kind() != slog.KindGroup && e.h.opts.ReplaceAttr != nil { - // todo: probably inefficient to call Split here. Need to - // cache and maintain the group slice as slog.TextHandler does - // this is also causing an allocation (even when this branch - // of code is never executed) a = e.h.opts.ReplaceAttr(e.groups, a) - - // Elide empty Attrs. - if a.Equal(slog.Attr{}) { - return - } a.Value = a.Value.Resolve() } + // Elide empty Attrs. + if a.Equal(slog.Attr{}) { + return + } value := a.Value @@ -322,19 +337,22 @@ func (e *encoder) writeLevel(buf *buffer, l slog.Level) { if e.h.opts.ReplaceAttr != nil { attr := e.h.opts.ReplaceAttr(nil, slog.Any(slog.LevelKey, l)) - val = attr.Value.Resolve() - // generally, we'll write the returned value, except in one - // case: when the resolved value is itself a slog.Level + attr.Value = attr.Value.Resolve() + + if attr.Value.Equal(slog.Value{}) { + // elide + return + } + + val = attr.Value writeVal = true if val.Kind() == slog.KindAny { - v := val.Any() - if ll, ok := v.(slog.Level); ok { + if ll, ok := val.Any().(slog.Level); ok { + // generally, we'll write the returned value, except in one + // case: when the resolved value is itself a slog.Level l = ll writeVal = false - } else if v == nil { - // elide - return } } } diff --git a/handler.go b/handler.go index 8655ff1..8f63300 100644 --- a/handler.go +++ b/handler.go @@ -5,6 +5,7 @@ import ( "io" "log/slog" "os" + "slices" "strings" "time" ) @@ -35,6 +36,11 @@ type HandlerOptions struct { // Theme defines the colorized output using ANSI escape sequences Theme Theme + // Headers are a list of attribute keys. These attributes will be removed from + // the trailing attr list, and the values will be inserted between + // the level/source and the message, in the configured order. + Headers []string + // ReplaceAttr is called to rewrite each non-group attribute before it is logged. // See [slog.HandlerOptions] ReplaceAttr func(groups []string, a slog.Attr) slog.Attr @@ -46,6 +52,7 @@ type Handler struct { groupPrefix string groups []string context buffer + headers []slog.Attr } var _ slog.Handler = (*Handler)(nil) @@ -71,6 +78,7 @@ func NewHandler(out io.Writer, opts *HandlerOptions) *Handler { out: out, groupPrefix: "", context: nil, + headers: make([]slog.Attr, len(opts.Headers)), } } @@ -82,30 +90,66 @@ func (h *Handler) Enabled(_ context.Context, l slog.Level) bool { // Handle implements slog.Handler. func (h *Handler) Handle(_ context.Context, rec slog.Record) error { enc := newEncoder(h) - buf := &enc.buf + headerBuf := &enc.buf + trailerBuf := &enc.trailerBuf + + enc.writeTimestamp(headerBuf, rec.Time) + enc.writeLevel(headerBuf, rec.Level) - enc.writeTimestamp(buf, rec.Time) - enc.writeLevel(buf, rec.Level) + var writeHeaderSeparator bool if h.opts.AddSource { - enc.writeSource(buf, rec.PC, cwd) + writeHeaderSeparator = enc.writeSource(headerBuf, rec.PC, cwd) } - enc.writeMessage(buf, rec.Level, rec.Message) - buf.copy(&h.context) + + enc.writeMessage(trailerBuf, rec.Level, rec.Message) + + trailerBuf.copy(&h.context) + + headers := h.headers + headersChanged := false rec.Attrs(func(a slog.Attr) bool { - enc.writeAttr(buf, a, h.groupPrefix) + idx := slices.IndexFunc(h.opts.Headers, func(s string) bool { return s == a.Key }) + if idx >= 0 { + if !headersChanged { + headersChanged = true + // todo: I think should could be replace now by a preallocated slice in encoder, avoiding allocation + // todo: this makes one allocation, but only if the headers weren't already + // satisfied by prior WithAttrs(). Could use a pool of *[]slog.Value, but + // I'm not sure it's worth it. + headers = make([]slog.Attr, len(h.opts.Headers)) + copy(headers, h.headers) + } + headers[idx] = a + return true + } + enc.writeAttr(trailerBuf, a, h.groupPrefix) return true }) - enc.NewLine(buf) - if _, err := buf.WriteTo(h.out); err != nil { - return err + enc.NewLine(trailerBuf) + + if len(headers) > 0 { + if enc.writeHeaders(headerBuf, headers) { + writeHeaderSeparator = true + } + } + + if writeHeaderSeparator { + enc.writeHeaderSeparator(headerBuf) } + if _, err := headerBuf.WriteTo(h.out); err != nil { + return err + } + if _, err := trailerBuf.WriteTo(h.out); err != nil { + return err + } enc.free() return nil } // WithAttrs implements slog.Handler. func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { + headers := h.extractHeaders(attrs) newCtx := h.context enc := newEncoder(h) for _, a := range attrs { @@ -118,6 +162,7 @@ func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { groupPrefix: h.groupPrefix, context: newCtx, groups: h.groups, + headers: headers, } } @@ -134,5 +179,28 @@ func (h *Handler) WithGroup(name string) slog.Handler { groupPrefix: groupPrefix, context: h.context, groups: append(h.groups, name), + headers: h.headers, + } +} + +// extractHeaders scans the attributes for keys specified in Headers. +// If found, their values are saved in a new list. +// The original attribute list will be modified to remove the extracted attributes. +func (h *Handler) extractHeaders(attrs []slog.Attr) (headers []slog.Attr) { + changed := false + headers = h.headers + for i, attr := range attrs { + idx := slices.IndexFunc(h.opts.Headers, func(s string) bool { return s == attr.Key }) + if idx >= 0 { + if !changed { + // make a copy of prefixes: + headers = make([]slog.Attr, len(h.headers)) + copy(headers, h.headers) + } + headers[idx] = attr + attrs[i] = slog.Attr{} // remove the prefix attribute + changed = true + } } + return } diff --git a/handler_test.go b/handler_test.go index 7516b12..f5ac7b7 100644 --- a/handler_test.go +++ b/handler_test.go @@ -292,6 +292,7 @@ type valuer struct { func (v valuer) LogValue() slog.Value { return v.v } + func TestHandler_ReplaceAttr(t *testing.T) { pc, file, line, _ := runtime.Caller(0) cwd, _ := os.Getwd() @@ -498,6 +499,9 @@ func TestHandler_ReplaceAttr(t *testing.T) { replaceAttr: replaceAttrWith("size", slog.Group("l1", slog.String("flavor", "vanilla"))), want: "2010-05-06 07:08:09 INF " + sourceField + " > foobar l1.flavor=vanilla color=red\n", }, + // { + // name: "replace header", + // } } for _, test := range tests { @@ -533,6 +537,155 @@ func TestHandler_ReplaceAttr(t *testing.T) { } +func TestHandler_Headers(t *testing.T) { + pc, file, line, _ := runtime.Caller(0) + cwd, _ := os.Getwd() + file, _ = filepath.Rel(cwd, file) + sourceField := fmt.Sprintf("%s:%d", file, line) + + tests := []struct { + name string + opts HandlerOptions + attrs []slog.Attr + withAttrs []slog.Attr + withGroups []string + want string + }{ + { + name: "no headers", + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "INF with headers foo=bar\n", + }, + { + name: "one header", + opts: HandlerOptions{Headers: []string{"foo"}}, + attrs: []slog.Attr{ + slog.String("foo", "bar"), + slog.String("bar", "baz"), + }, + want: "INF bar > with headers bar=baz\n", + }, + { + name: "two headers", + opts: HandlerOptions{Headers: []string{"foo", "bar"}}, + attrs: []slog.Attr{ + slog.String("foo", "bar"), + slog.String("bar", "baz"), + }, + want: "INF bar baz > with headers\n", + }, + { + name: "missing headers", + opts: HandlerOptions{Headers: []string{"foo", "bar"}}, + attrs: []slog.Attr{slog.String("bar", "baz"), slog.String("baz", "foo")}, + want: "INF baz > with headers baz=foo\n", + }, + { + name: "missing all headers", + opts: HandlerOptions{Headers: []string{"foo", "bar"}}, + want: "INF with headers\n", + }, + { + name: "header and source", + opts: HandlerOptions{Headers: []string{"foo"}, AddSource: true}, + attrs: []slog.Attr{ + slog.String("foo", "bar"), + slog.String("bar", "baz"), + }, + want: "INF " + sourceField + " bar > with headers bar=baz\n", + }, + { + name: "withattrs", + opts: HandlerOptions{Headers: []string{"foo"}}, + attrs: []slog.Attr{ + + slog.String("bar", "baz"), + }, + withAttrs: []slog.Attr{ + slog.String("foo", "bar"), + }, + want: "INF bar > with headers bar=baz\n", + }, + { + name: "withgroup", + opts: HandlerOptions{Headers: []string{"foo", "bar"}}, + attrs: []slog.Attr{ + slog.String("bar", "baz"), + slog.String("baz", "foo"), + }, + withGroups: []string{"group"}, + withAttrs: []slog.Attr{ + slog.String("foo", "bar"), + }, + want: "INF bar baz > with headers group.baz=foo\n", + }, + // { + // name: "resolver header", + // } + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + buf := bytes.Buffer{} + + opts := &test.opts + opts.NoColor = true + var h slog.Handler = NewHandler(&buf, &test.opts) + if test.withAttrs != nil { + h = h.WithAttrs(test.withAttrs) + } + for _, g := range test.withGroups { + h = h.WithGroup(g) + } + + rec := slog.NewRecord(time.Time{}, slog.LevelInfo, "with headers", pc) + + rec.AddAttrs(test.attrs...) + + AssertNoError(t, h.Handle(context.Background(), rec)) + AssertEqual(t, test.want, buf.String()) + }) + } + + t.Run("withAttrs state keeping", func(t *testing.T) { + // test to make sure the way that WithAttrs() copies the cached headers doesn't leak + // headers back to the parent handler or to subsequent Handle() calls (i.e. ensure that + // the headers slice is copied at the right times). + + buf := bytes.Buffer{} + h := NewHandler(&buf, &HandlerOptions{ + Headers: []string{"foo", "bar"}, + TimeFormat: "0", + NoColor: true, + }) + + assertLog := func(t *testing.T, handler slog.Handler, want string, attrs ...slog.Attr) { + buf.Reset() + rec := slog.NewRecord(time.Time{}, slog.LevelInfo, "with headers", pc) + + rec.AddAttrs(attrs...) + + AssertNoError(t, handler.Handle(context.Background(), rec)) + AssertEqual(t, want, buf.String()) + } + + assertLog(t, h, "INF bar > with headers\n", slog.String("foo", "bar")) + + h2 := h.WithAttrs([]slog.Attr{slog.String("foo", "baz")}) + assertLog(t, h2, "INF baz > with headers\n") + + h3 := h2.WithAttrs([]slog.Attr{slog.String("foo", "buz")}) + assertLog(t, h3, "INF buz > with headers\n") + // creating h3 should not have affected h2 + assertLog(t, h2, "INF baz > with headers\n") + + // overriding attrs shouldn't affect the handler + assertLog(t, h2, "INF biz > with headers\n", slog.String("foo", "biz")) + assertLog(t, h2, "INF baz > with headers\n") + + }) +} + func TestHandler_Err(t *testing.T) { w := writerFunc(func(b []byte) (int, error) { return 0, errors.New("nope") }) h := NewHandler(w, &HandlerOptions{NoColor: true}) From 7b59dbe21aaaef6b862f9ef6cabd9ec1ca1dd3a8 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Fri, 10 Jan 2025 16:52:01 -0600 Subject: [PATCH 09/44] Avoid an allocation by adding a re-usable headers buffer to encoder --- bench_test.go | 2 ++ encoding.go | 5 ++--- handler.go | 13 ++++--------- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/bench_test.go b/bench_test.go index c520644..3423bde 100644 --- a/bench_test.go +++ b/bench_test.go @@ -22,7 +22,9 @@ var handlers = []struct { }{ {"dummy", &DummyHandler{}}, {"console", NewHandler(io.Discard, &HandlerOptions{Level: slog.LevelDebug, AddSource: false})}, + // {"console-headers", NewHandler(io.Discard, &HandlerOptions{Headers: []string{"foo"}, Level: slog.LevelDebug, AddSource: false})}, // {"console-replaceattr", NewHandler(io.Discard, &HandlerOptions{Level: slog.LevelDebug, AddSource: false, ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { return a }})}, + // {"console-headers-replaceattr", NewHandler(io.Discard, &HandlerOptions{Headers: []string{"foo"}, Level: slog.LevelDebug, AddSource: false, ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { return a }})}, {"std-text", slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: false})}, // {"std-text-replaceattr", slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: false, ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { return a }})}, {"std-json", slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: false})}, diff --git a/encoding.go b/encoding.go index 2a06c0a..36f666f 100644 --- a/encoding.go +++ b/encoding.go @@ -15,6 +15,7 @@ var encoderPool = &sync.Pool{ e.groups = make([]string, 0, 10) e.buf = make(buffer, 0, 1024) e.trailerBuf = make(buffer, 0, 1024) + e.headers = make([]slog.Attr, 0, 6) return e }, } @@ -23,6 +24,7 @@ type encoder struct { h *Handler buf, trailerBuf buffer groups []string + headers []slog.Attr } func newEncoder(h *Handler) *encoder { @@ -228,9 +230,6 @@ func (e encoder) writeHeaders(buf *buffer, headers []slog.Attr) bool { a = e.h.opts.ReplaceAttr(nil, a) a.Value = a.Value.Resolve() } - // todo: this skips empty values, omitting them entire from the header. - // alternately, I could print or something, so the number of - // headers in each log entry is always fixed... if a.Value.Equal(slog.Value{}) { continue } diff --git a/handler.go b/handler.go index 8f63300..1f18275 100644 --- a/handler.go +++ b/handler.go @@ -106,18 +106,13 @@ func (h *Handler) Handle(_ context.Context, rec slog.Record) error { trailerBuf.copy(&h.context) headers := h.headers - headersChanged := false + localHeaders := false rec.Attrs(func(a slog.Attr) bool { idx := slices.IndexFunc(h.opts.Headers, func(s string) bool { return s == a.Key }) if idx >= 0 { - if !headersChanged { - headersChanged = true - // todo: I think should could be replace now by a preallocated slice in encoder, avoiding allocation - // todo: this makes one allocation, but only if the headers weren't already - // satisfied by prior WithAttrs(). Could use a pool of *[]slog.Value, but - // I'm not sure it's worth it. - headers = make([]slog.Attr, len(h.opts.Headers)) - copy(headers, h.headers) + if !localHeaders { + localHeaders = true + headers = append(enc.headers, h.headers...) } headers[idx] = a return true From 9033a5e3483fcabce07a10db4516d3a364ddea4c Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Sat, 11 Jan 2025 14:36:12 -0600 Subject: [PATCH 10/44] Reverted attr key color in dim theme to cyan Blue looks good with some terminal themes, but not with the default macos theme. Cyan generally looks fine on more themes I think. --- theme.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/theme.go b/theme.go index 377c7e0..e6ff672 100644 --- a/theme.go +++ b/theme.go @@ -157,7 +157,7 @@ func NewDimTheme() Theme { source: ToANSICode(Bold, BrightBlack), message: ToANSICode(Bold), messageDebug: ToANSICode(), - attrKey: ToANSICode(Blue), + attrKey: ToANSICode(Cyan), attrValue: ToANSICode(Gray), attrValueError: ToANSICode(Bold, Red), levelError: ToANSICode(Red), From 31eb4aa4ec209f486751d96e6881302b0624bd2c Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Thu, 16 Jan 2025 21:02:57 -0600 Subject: [PATCH 11/44] changing module name --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index a29d282..5fcafd2 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module github.com/phsym/console-slog +module github.com/ansel1/console-slog go 1.21 From d97ead4a13da0406121df94651d352b5dad512e7 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Fri, 17 Jan 2025 14:46:57 -0600 Subject: [PATCH 12/44] Better trimming of the source file path Only make the source file path relative to the current working directly if the source file is a child path of the current working directory. Typically, the source file path will only be a child of the CWD when the code is being run from its own project folder. But if the executable moved somewhere else first, or packaged in a container, or compiled with the -trimpath option, then this relative path logic doesn't work. If the file path isn't related to the CWT, then trim to just one path element above the file name, which is typically the go package name, e.g. "io/reader.go" Addresses #16 --- encoding.go | 45 +++++++++++++++++++++++++++++++++++++++------ example/main.go | 2 +- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/encoding.go b/encoding.go index 488c564..0fed25d 100644 --- a/encoding.go +++ b/encoding.go @@ -5,6 +5,7 @@ import ( "log/slog" "path/filepath" "runtime" + "strings" "sync" "time" ) @@ -189,13 +190,8 @@ func (e *encoder) writeSource(buf *buffer, pc uintptr, cwd string) { return } - if cwd != "" { - if ff, err := filepath.Rel(cwd, src.File); err == nil { - src.File = ff - } - } e.withColor(buf, e.h.opts.Theme.Source(), func() { - buf.AppendString(src.File) + buf.AppendString(trimmedPath(src.File, cwd)) buf.AppendByte(':') buf.AppendInt(int64(src.Line)) buf.AppendByte(' ') @@ -389,3 +385,40 @@ func (e *encoder) writeLevel(buf *buffer, l slog.Level) { } buf.AppendByte(' ') } + +func trimmedPath(path string, cwd string) string { + // if the file path appears to be under the current + // working directory, then we're probably running + // in a dev environment, and we can show the + // path of the source file relative to the + // working directory + if cwd != "" && strings.HasPrefix(path, cwd) { + if ff, err := filepath.Rel(cwd, path); err == nil { + return ff + } + } + + // Otherwise, show the filename and one + // path above it, which is typically going to + // be the package name + // Note that the go compiler always uses forward + // slashes, even if the compiler was run on Windows. + // + // See https://github.com/golang/go/issues/3335 + // and https://github.com/golang/go/issues/18151 + + // This is equivalent to filepath.Base(path) + idx := strings.LastIndexByte(path, '/') + if idx == -1 { + return path + } + + // And this walks back one more separater, which is + // equivalent to filepath.Join(filepath.Base(filepath.Dir(path)), filepath.Base(path)) + idx = strings.LastIndexByte(path[:idx], '/') + if idx == -1 { + return path + } + + return path[idx+1:] +} diff --git a/example/main.go b/example/main.go index ca78958..9870cb0 100644 --- a/example/main.go +++ b/example/main.go @@ -5,7 +5,7 @@ import ( "log/slog" "os" - "github.com/phsym/console-slog" + "github.com/ansel1/console-slog" ) func main() { From d33ca0cc43e78203d476e6b16c301d80b1fc09b0 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Wed, 22 Jan 2025 11:14:33 -0600 Subject: [PATCH 13/44] WIP: fixed header section width Uses simple approach, allocating each header exactly opts.HeaderWidth (truncating or padding as needed). Also moves source out of the header section. --- buffer.go | 10 +++++++ encoding.go | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++ handler.go | 36 +++++++++++++++++++------ theme.go | 10 +++---- 4 files changed, 118 insertions(+), 13 deletions(-) diff --git a/buffer.go b/buffer.go index 655c5e6..57f9b0b 100644 --- a/buffer.go +++ b/buffer.go @@ -25,6 +25,16 @@ func (b *buffer) Len() int { return len(*b) } +func (b *buffer) Truncate(n int) { + *b = (*b)[:n] +} + +func (b *buffer) Pad(n int, c byte) { + for ; n > 0; n-- { + b.AppendByte(byte(c)) + } +} + func (b *buffer) Cap() int { return cap(*b) } diff --git a/encoding.go b/encoding.go index 0fed25d..b0f92ff 100644 --- a/encoding.go +++ b/encoding.go @@ -233,6 +233,32 @@ func (e encoder) writeHeaders(buf *buffer, headers []slog.Attr) { } } +func (e encoder) writeHeader(buf *buffer, a slog.Attr, width int) int { + if a.Value.Kind() != slog.KindGroup && e.h.opts.ReplaceAttr != nil { + a = e.h.opts.ReplaceAttr(nil, a) + a.Value = a.Value.Resolve() + } + if a.Value.Equal(slog.Value{}) { + return 0 + } + e.withColor(buf, e.h.opts.Theme.Source(), func() { + l := buf.Len() + e.writeValue(buf, a.Value) + // truncate or pad to required width + remainingWidth := l + width - buf.Len() + if remainingWidth < 0 { + // truncate + buf.Truncate(l + width) + } else if remainingWidth > 0 { + // pad + buf.Pad(remainingWidth, ' ') + } + }) + + buf.AppendByte(' ') + return buf.Len() +} + func (e encoder) writeHeaderSeparator(buf *buffer) { e.writeColoredString(buf, "> ", e.h.opts.Theme.AttrKey()) } @@ -287,6 +313,46 @@ func (e *encoder) writeAttr(buf *buffer, a slog.Attr, group string) { e.writeColoredValue(buf, value, style) } +func (e *encoder) writeValue(buf *buffer, value slog.Value) { + switch value.Kind() { + case slog.KindInt64: + buf.AppendInt(value.Int64()) + case slog.KindBool: + buf.AppendBool(value.Bool()) + case slog.KindFloat64: + buf.AppendFloat(value.Float64()) + case slog.KindTime: + buf.AppendTime(value.Time(), e.h.opts.TimeFormat) + case slog.KindUint64: + buf.AppendUint(value.Uint64()) + case slog.KindDuration: + buf.AppendDuration(value.Duration()) + case slog.KindAny: + switch v := value.Any().(type) { + case error: + if _, ok := v.(fmt.Formatter); ok { + fmt.Fprintf(buf, "%+v", v) + } else { + buf.AppendString(v.Error()) + } + return + case fmt.Stringer: + buf.AppendString(v.String()) + return + case *slog.Source: + buf.AppendString(trimmedPath(v.File, cwd)) + buf.AppendByte(':') + buf.AppendInt(int64(v.Line)) + return + } + fallthrough + case slog.KindString: + fallthrough + default: + buf.AppendString(value.String()) + } +} + func (e *encoder) writeColoredValue(buf *buffer, value slog.Value, style ANSIMod) { switch value.Kind() { case slog.KindInt64: @@ -315,6 +381,13 @@ func (e *encoder) writeColoredValue(buf *buffer, value slog.Value, style ANSIMod case fmt.Stringer: e.writeColoredString(buf, v.String(), style) return + case *slog.Source: + e.withColor(buf, style, func() { + buf.AppendString(trimmedPath(v.File, cwd)) + buf.AppendByte(':') + buf.AppendInt(int64(v.Line)) + }) + return } fallthrough case slog.KindString: @@ -398,6 +471,8 @@ func trimmedPath(path string, cwd string) string { } } + return path + // Otherwise, show the filename and one // path above it, which is typically going to // be the package name diff --git a/handler.go b/handler.go index ba84c6d..54d4b5b 100644 --- a/handler.go +++ b/handler.go @@ -6,6 +6,7 @@ import ( "io" "log/slog" "os" + "runtime" "slices" "strings" "time" @@ -45,6 +46,8 @@ type HandlerOptions struct { // ReplaceAttr is called to rewrite each non-group attribute before it is logged. // See [slog.HandlerOptions] ReplaceAttr func(groups []string, a slog.Attr) slog.Attr + + HeaderWidth int } type Handler struct { @@ -98,15 +101,19 @@ func (h *Handler) Handle(_ context.Context, rec slog.Record) error { enc.writeTimestamp(headerBuf, rec.Time) enc.writeLevel(headerBuf, rec.Level) - headerLen := headerBuf.Len() - if h.opts.AddSource { - enc.writeSource(headerBuf, rec.PC, cwd) - } - enc.writeMessage(middleBuf, rec.Level, rec.Message) middleBuf.copy(&h.context) + if h.opts.AddSource && rec.PC > 0 { + src := slog.Source{} + frame, _ := runtime.CallersFrames([]uintptr{rec.PC}).Next() + src.Function = frame.Function + src.File = frame.File + src.Line = frame.Line + rec.AddAttrs(slog.Any(slog.SourceKey, &src)) + } + headers := h.headers localHeaders := false rec.Attrs(func(a slog.Attr) bool { @@ -178,9 +185,22 @@ func (h *Handler) Handle(_ context.Context, rec slog.Record) error { return true }) - // add additional headers after the source - if len(headers) > 0 { - enc.writeHeaders(headerBuf, headers) + headerLen := headerBuf.Len() + + if h.opts.HeaderWidth > 0 { + for _, a := range headers { + enc.writeHeader(headerBuf, a, h.opts.HeaderWidth) + } + } else { + // not using a fixed width header. Just write the entire source + // and headers to the buf sequentially. + // if h.opts.AddSource { + // enc.writeSource(headerBuf, rec.PC, cwd) + // } + + if len(headers) > 0 { + enc.writeHeaders(headerBuf, headers) + } } // connect the sections diff --git a/theme.go b/theme.go index e6ff672..e11ee5e 100644 --- a/theme.go +++ b/theme.go @@ -124,7 +124,7 @@ func NewDefaultTheme() Theme { message: ToANSICode(Bold), messageDebug: ToANSICode(), attrKey: ToANSICode(Cyan), - attrValue: ToANSICode(), + attrValue: ToANSICode(Gray), attrValueError: ToANSICode(Bold, Red), levelError: ToANSICode(Red), levelWarn: ToANSICode(Yellow), @@ -158,11 +158,11 @@ func NewDimTheme() Theme { message: ToANSICode(Bold), messageDebug: ToANSICode(), attrKey: ToANSICode(Cyan), - attrValue: ToANSICode(Gray), + attrValue: ToANSICode(BrightBlack), attrValueError: ToANSICode(Bold, Red), - levelError: ToANSICode(Red), - levelWarn: ToANSICode(Yellow), - levelInfo: ToANSICode(Green), + levelError: ToANSICode(Bold, Red), + levelWarn: ToANSICode(Bold, Yellow), + levelInfo: ToANSICode(Bold, Green), levelDebug: ToANSICode(), } } From b9af03cc03dbff43e973679231e4a10b572435cc Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Wed, 5 Feb 2025 11:19:53 -0600 Subject: [PATCH 14/44] Add option for customizing how the source attribute is truncated Also always treat the source as just another attribute. It can be put in the headers with opts.Headers --- encoding.go | 36 +++++++++++++++++------------------- example/main.go | 14 +++++++++++--- handler.go | 41 ++++++++++++++++++++++++++++++++++++----- handler_test.go | 5 ++--- 4 files changed, 66 insertions(+), 30 deletions(-) diff --git a/encoding.go b/encoding.go index b0f92ff..e497ea2 100644 --- a/encoding.go +++ b/encoding.go @@ -340,7 +340,7 @@ func (e *encoder) writeValue(buf *buffer, value slog.Value) { buf.AppendString(v.String()) return case *slog.Source: - buf.AppendString(trimmedPath(v.File, cwd)) + buf.AppendString(trimmedPath(v.File, cwd, e.h.opts.TruncateSourcePath)) buf.AppendByte(':') buf.AppendInt(int64(v.Line)) return @@ -459,7 +459,7 @@ func (e *encoder) writeLevel(buf *buffer, l slog.Level) { buf.AppendByte(' ') } -func trimmedPath(path string, cwd string) string { +func trimmedPath(path string, cwd string, truncate int) string { // if the file path appears to be under the current // working directory, then we're probably running // in a dev environment, and we can show the @@ -467,33 +467,31 @@ func trimmedPath(path string, cwd string) string { // working directory if cwd != "" && strings.HasPrefix(path, cwd) { if ff, err := filepath.Rel(cwd, path); err == nil { - return ff + path = ff } } - return path - - // Otherwise, show the filename and one - // path above it, which is typically going to - // be the package name + // Otherwise, show the full file path. + // If truncate is > 0, then truncate to that last + // number of path segments. + // 1 = just the filename + // 2 = the filename and its parent dir + // 3 = the filename and its two parent dirs + // ...etc + // // Note that the go compiler always uses forward // slashes, even if the compiler was run on Windows. // // See https://github.com/golang/go/issues/3335 // and https://github.com/golang/go/issues/18151 - // This is equivalent to filepath.Base(path) - idx := strings.LastIndexByte(path, '/') - if idx == -1 { - return path - } - - // And this walks back one more separater, which is - // equivalent to filepath.Join(filepath.Base(filepath.Dir(path)), filepath.Base(path)) + var start int + for idx := len(path); truncate > 0; truncate-- { idx = strings.LastIndexByte(path[:idx], '/') if idx == -1 { - return path + break } - - return path[idx+1:] + start = idx + 1 + } + return path[start:] } diff --git a/example/main.go b/example/main.go index 9870cb0..dd23d9f 100644 --- a/example/main.go +++ b/example/main.go @@ -10,7 +10,15 @@ import ( func main() { logger := slog.New( - console.NewHandler(os.Stderr, &console.HandlerOptions{Level: slog.LevelDebug, AddSource: true}), + console.NewHandler(os.Stderr, &console.HandlerOptions{ + Level: slog.LevelDebug, + AddSource: true, + Headers: []string{"logger"}, + TruncateSourcePath: 2, + TimeFormat: "15:04:05.000", + HeaderWidth: 15, + Theme: console.NewDimTheme(), + }), ) slog.SetDefault(logger) slog.Info("Hello world!", "foo", "bar") @@ -20,7 +28,7 @@ func main() { logger = logger.With("foo", "bar"). WithGroup("the-group"). - With("bar", "baz") + With("bar", "baz", "logger", "main") - logger.Info("group info", "attr", "value") + logger.Info("group info", "multiline", "hello\nworld", "attr", "value") } diff --git a/handler.go b/handler.go index 54d4b5b..57635b5 100644 --- a/handler.go +++ b/handler.go @@ -12,7 +12,21 @@ import ( "time" ) -var cwd, _ = os.Getwd() +var cwd string + +func init() { + cwd, _ = os.Getwd() + // We compare cwd to the filepath in runtime.Frame.File + // It turns out, an old legacy behavior of go is that runtime.Frame.File + // will always contain file paths with forward slashes, even if compiled + // on Windows. + // See https://github.com/golang/go/issues/3335 + // and https://github.com/golang/go/issues/18151 + cwd = strings.ReplaceAll(cwd, "\\", "/") +} + +// %[time]t %[source]3-h %[logger]8-h %[lvl]3l | %[msg]m %a +// timef(), source(3, left), header(logger, 8, right) levelAbbr() string("|") msg() attrs() // HandlerOptions are options for a ConsoleHandler. // A zero HandlerOptions consists entirely of default values. @@ -38,16 +52,33 @@ type HandlerOptions struct { // Theme defines the colorized output using ANSI escape sequences Theme Theme + // ReplaceAttr is called to rewrite each non-group attribute before it is logged. + // See [slog.HandlerOptions] + ReplaceAttr func(groups []string, a slog.Attr) slog.Attr + // Headers are a list of attribute keys. These attributes will be removed from // the trailing attr list, and the values will be inserted between // the level/source and the message, in the configured order. Headers []string - // ReplaceAttr is called to rewrite each non-group attribute before it is logged. - // See [slog.HandlerOptions] - ReplaceAttr func(groups []string, a slog.Attr) slog.Attr - + // HeaderWidth controls whether the header fields take up a fixed width in the log line. + // If 0, the full value of all headers are printed, meaning this section of the log line + // will vary in length from one line to the next. + // If >0, headers will be truncated or padded as needed to fit in the specified width. This can + // make busy logs easier to scan, as it ensures that the timestamp, headers, level, and message + // fields are always aligned on the same column. + // The available width will be allocated equally to HeaderWidth int + + // TruncateSourcePath shortens the source file path, if AddSource=true. + // If 0, no truncation is done. + // If >0, the file path is truncated to that many trailing path segments. + // For example: + // + // users.go:34 // TruncateSourcePath = 1 + // models/users.go:34 // TruncateSourcePath = 2 + // ...etc + TruncateSourcePath int } type Handler struct { diff --git a/handler_test.go b/handler_test.go index 755237a..09063ec 100644 --- a/handler_test.go +++ b/handler_test.go @@ -686,9 +686,8 @@ func TestHandler_Headers(t *testing.T) { }, want: "INF bar baz > with headers group.baz=foo\n", }, - // { - // name: "resolver header", - // } + // todo: add a test for when the record doesn't include the header field, but fixed width headers are enabled + // the header should be padded with spaces to the right } for _, test := range tests { From f1d09cd6f5d2221ddb759f0b04661149208c38bb Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Wed, 5 Feb 2025 11:21:07 -0600 Subject: [PATCH 15/44] Always write out the header seperator, even when there aren't headers Also print the headers before the log level. Makes it easier to scan. --- handler.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/handler.go b/handler.go index 57635b5..dfb9340 100644 --- a/handler.go +++ b/handler.go @@ -130,8 +130,9 @@ func (h *Handler) Handle(_ context.Context, rec slog.Record) error { trailerBuf := &enc.trailerBuf enc.writeTimestamp(headerBuf, rec.Time) - enc.writeLevel(headerBuf, rec.Level) + enc.writeLevel(middleBuf, rec.Level) + enc.writeHeaderSeparator(middleBuf) enc.writeMessage(middleBuf, rec.Level, rec.Message) middleBuf.copy(&h.context) @@ -216,8 +217,6 @@ func (h *Handler) Handle(_ context.Context, rec slog.Record) error { return true }) - headerLen := headerBuf.Len() - if h.opts.HeaderWidth > 0 { for _, a := range headers { enc.writeHeader(headerBuf, a, h.opts.HeaderWidth) @@ -234,11 +233,6 @@ func (h *Handler) Handle(_ context.Context, rec slog.Record) error { } } - // connect the sections - if headerBuf.Len() > headerLen { - enc.writeHeaderSeparator(headerBuf) - } - if trailerBuf.Len() == 0 { // if there were no multiline attrs, terminate the line with a newline enc.NewLine(middleBuf) From 0be17f8127f419b03f66259755c9d8836f147970 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Wed, 5 Feb 2025 11:21:44 -0600 Subject: [PATCH 16/44] Write the entire log line out with a single Write() call Some io.Writers may be expecting each log to equate to a single call --- handler.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/handler.go b/handler.go index dfb9340..d6cdb97 100644 --- a/handler.go +++ b/handler.go @@ -241,15 +241,14 @@ func (h *Handler) Handle(_ context.Context, rec slog.Record) error { enc.NewLine(trailerBuf) } + // concatenate the buffers together before writing to out, so the entire + // log line is written in a single Write call + headerBuf.copy(middleBuf) + headerBuf.copy(trailerBuf) + if _, err := headerBuf.WriteTo(h.out); err != nil { return err } - if _, err := middleBuf.WriteTo(h.out); err != nil { - return err - } - if _, err := trailerBuf.WriteTo(h.out); err != nil { - return err - } enc.free() return nil From da4ed6ed7adfd98fa48762a83e790bd32831e316 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Tue, 11 Feb 2025 15:32:50 -0600 Subject: [PATCH 17/44] Introduced new formatting option Replacing Headers with a formatting syntax, which lets the user compose the header section of the log like however they like. --- encoding.go | 213 ++++++++------------- example/main.go | 2 +- handler.go | 499 ++++++++++++++++++++++++++++++++++-------------- handler_test.go | 329 +++++++++++++++++++++++++++---- theme.go | 10 +- utils_test.go | 2 +- 6 files changed, 729 insertions(+), 326 deletions(-) diff --git a/encoding.go b/encoding.go index e497ea2..d684276 100644 --- a/encoding.go +++ b/encoding.go @@ -4,7 +4,6 @@ import ( "fmt" "log/slog" "path/filepath" - "runtime" "strings" "sync" "time" @@ -14,19 +13,20 @@ var encoderPool = &sync.Pool{ New: func() any { e := new(encoder) e.groups = make([]string, 0, 10) - e.headerBuf = make(buffer, 0, 1024) - e.middleBuf = make(buffer, 0, 1024) - e.trailerBuf = make(buffer, 0, 1024) + e.buf = make(buffer, 0, 1024) + e.attrBuf = make(buffer, 0, 1024) + e.multilineAttrBuf = make(buffer, 0, 1024) e.headers = make([]slog.Attr, 0, 6) return e }, } type encoder struct { - h *Handler - headerBuf, middleBuf, trailerBuf buffer - groups []string - headers []slog.Attr + h *Handler + buf, attrBuf, multilineAttrBuf buffer + groups []string + headers []slog.Attr + headersCopied bool } func newEncoder(h *Handler) *encoder { @@ -43,9 +43,9 @@ func (e *encoder) free() { return } e.h = nil - e.headerBuf.Reset() - e.middleBuf.Reset() - e.trailerBuf.Reset() + e.buf.Reset() + e.attrBuf.Reset() + e.multilineAttrBuf.Reset() e.groups = e.groups[:0] encoderPool.Put(e) } @@ -125,7 +125,6 @@ func (e *encoder) writeTimestamp(buf *buffer, tt time.Time) { // handle all non-time values by printing them like // an attr value e.writeColoredValue(buf, attr.Value, e.h.opts.Theme.Timestamp()) - buf.AppendByte(' ') return } @@ -138,64 +137,6 @@ func (e *encoder) writeTimestamp(buf *buffer, tt time.Time) { } e.writeColoredTime(buf, tt, e.h.opts.TimeFormat, e.h.opts.Theme.Timestamp()) - buf.AppendByte(' ') -} - -// writeSource returns true if source was written, false if elided -func (e *encoder) writeSource(buf *buffer, pc uintptr, cwd string) { - src := slog.Source{} - - if pc > 0 { - frame, _ := runtime.CallersFrames([]uintptr{pc}).Next() - src.Function = frame.Function - src.File = frame.File - src.Line = frame.Line - } - - if e.h.opts.ReplaceAttr != nil { - attr := e.h.opts.ReplaceAttr(nil, slog.Any(slog.SourceKey, &src)) - attr.Value = attr.Value.Resolve() - - if attr.Value.Equal(slog.Value{}) { - return - } - - switch attr.Value.Kind() { - case slog.KindAny: - if newsrc, ok := attr.Value.Any().(*slog.Source); ok { - if newsrc == nil { - // elide - return - } - - src.File = newsrc.File - src.Line = newsrc.Line - // replaced prior source fields, proceed with normal source processing - break - } - // source replaced with some other type of value, - // fallthrough to processing other value types - fallthrough - default: - // handle all non-time values by printing them like - // an attr value - e.writeColoredValue(buf, attr.Value, e.h.opts.Theme.Timestamp()) - buf.AppendByte(' ') - return - } - } - - if src.File == "" && src.Line == 0 { - // elide - return - } - - e.withColor(buf, e.h.opts.Theme.Source(), func() { - buf.AppendString(trimmedPath(src.File, cwd)) - buf.AppendByte(':') - buf.AppendInt(int64(src.Line)) - buf.AppendByte(' ') - }) } func (e *encoder) writeMessage(buf *buffer, level slog.Level, msg string) { @@ -219,44 +160,65 @@ func (e *encoder) writeMessage(buf *buffer, level slog.Level, msg string) { e.writeColoredString(buf, msg, style) } -func (e encoder) writeHeaders(buf *buffer, headers []slog.Attr) { - for _, a := range headers { - if a.Value.Kind() != slog.KindGroup && e.h.opts.ReplaceAttr != nil { - a = e.h.opts.ReplaceAttr(nil, a) - a.Value = a.Value.Resolve() - } - if a.Value.Equal(slog.Value{}) { - continue - } - e.writeColoredValue(buf, a.Value, e.h.opts.Theme.Source()) - buf.AppendByte(' ') - } -} - -func (e encoder) writeHeader(buf *buffer, a slog.Attr, width int) int { +// func (e encoder) writeHeaders(buf *buffer, headers []slog.Attr) { +// for _, a := range headers { +// if a.Value.Kind() != slog.KindGroup && e.h.opts.ReplaceAttr != nil { +// a = e.h.opts.ReplaceAttr(nil, a) +// a.Value = a.Value.Resolve() +// } +// if a.Value.Equal(slog.Value{}) { +// continue +// } +// e.writeColoredValue(buf, a.Value, e.h.opts.Theme.Source()) +// buf.AppendByte(' ') +// } +// } + +func (e encoder) writeHeader(buf *buffer, a slog.Attr, width int, rightAlign bool) { if a.Value.Kind() != slog.KindGroup && e.h.opts.ReplaceAttr != nil { a = e.h.opts.ReplaceAttr(nil, a) a.Value = a.Value.Resolve() } if a.Value.Equal(slog.Value{}) { - return 0 + // just pad as needed + if width > 0 { + buf.Pad(width, ' ') + } + return } + e.withColor(buf, e.h.opts.Theme.Source(), func() { l := buf.Len() e.writeValue(buf, a.Value) + if width <= 0 { + return + } // truncate or pad to required width remainingWidth := l + width - buf.Len() if remainingWidth < 0 { // truncate buf.Truncate(l + width) } else if remainingWidth > 0 { - // pad - buf.Pad(remainingWidth, ' ') + if rightAlign { + // For right alignment, shift the text right in-place: + // 1. Get the text length + textLen := buf.Len() - l + // 2. Add padding to reach final width + buf.Pad(remainingWidth, ' ') + // 3. Move the text to the right by copying from end to start + for i := 0; i < textLen; i++ { + (*buf)[buf.Len()-1-i] = (*buf)[l+textLen-1-i] + } + // 4. Fill the left side with spaces + for i := 0; i < remainingWidth; i++ { + (*buf)[l+i] = ' ' + } + } else { + // Left align - just pad with spaces + buf.Pad(remainingWidth, ' ') + } } }) - - buf.AppendByte(' ') - return buf.Len() } func (e encoder) writeHeaderSeparator(buf *buffer) { @@ -294,7 +256,6 @@ func (e *encoder) writeAttr(buf *buffer, a slog.Attr, group string) { } buf.AppendByte(' ') - e.withColor(buf, e.h.opts.Theme.AttrKey(), func() { if group != "" { buf.AppendString(group) @@ -354,50 +315,12 @@ func (e *encoder) writeValue(buf *buffer, value slog.Value) { } func (e *encoder) writeColoredValue(buf *buffer, value slog.Value, style ANSIMod) { - switch value.Kind() { - case slog.KindInt64: - e.writeColoredInt(buf, value.Int64(), style) - case slog.KindBool: - e.writeColoredBool(buf, value.Bool(), style) - case slog.KindFloat64: - e.writeColoredFloat(buf, value.Float64(), style) - case slog.KindTime: - e.writeColoredTime(buf, value.Time(), e.h.opts.TimeFormat, style) - case slog.KindUint64: - e.writeColoredUint(buf, value.Uint64(), style) - case slog.KindDuration: - e.writeColoredDuration(buf, value.Duration(), style) - case slog.KindAny: - switch v := value.Any().(type) { - case error: - if _, ok := v.(fmt.Formatter); ok { - e.withColor(buf, style, func() { - fmt.Fprintf(buf, "%+v", v) - }) - } else { - e.writeColoredString(buf, v.Error(), style) - } - return - case fmt.Stringer: - e.writeColoredString(buf, v.String(), style) - return - case *slog.Source: - e.withColor(buf, style, func() { - buf.AppendString(trimmedPath(v.File, cwd)) - buf.AppendByte(':') - buf.AppendInt(int64(v.Line)) - }) - return - } - fallthrough - case slog.KindString: - fallthrough - default: - e.writeColoredString(buf, value.String(), style) - } + e.withColor(buf, style, func() { + e.writeValue(buf, value) + }) } -func (e *encoder) writeLevel(buf *buffer, l slog.Level) { +func (e *encoder) writeLevel(buf *buffer, l slog.Level, abbreviated bool) { var val slog.Value var writeVal bool @@ -430,22 +353,37 @@ func (e *encoder) writeLevel(buf *buffer, l slog.Level) { case l >= slog.LevelError: style = e.h.opts.Theme.LevelError() str = "ERR" + if !abbreviated { + str = "ERROR" + } delta = int(l - slog.LevelError) case l >= slog.LevelWarn: style = e.h.opts.Theme.LevelWarn() str = "WRN" + if !abbreviated { + str = "WARN" + } delta = int(l - slog.LevelWarn) case l >= slog.LevelInfo: style = e.h.opts.Theme.LevelInfo() str = "INF" + if !abbreviated { + str = "INFO" + } delta = int(l - slog.LevelInfo) case l >= slog.LevelDebug: style = e.h.opts.Theme.LevelDebug() str = "DBG" + if !abbreviated { + str = "DEBUG" + } delta = int(l - slog.LevelDebug) default: style = e.h.opts.Theme.LevelDebug() str = "DBG" + if !abbreviated { + str = "DEBUG" + } delta = int(l - slog.LevelDebug) } if writeVal { @@ -456,7 +394,6 @@ func (e *encoder) writeLevel(buf *buffer, l slog.Level) { } e.writeColoredString(buf, str, style) } - buf.AppendByte(' ') } func trimmedPath(path string, cwd string, truncate int) string { @@ -487,10 +424,10 @@ func trimmedPath(path string, cwd string, truncate int) string { var start int for idx := len(path); truncate > 0; truncate-- { - idx = strings.LastIndexByte(path[:idx], '/') - if idx == -1 { + idx = strings.LastIndexByte(path[:idx], '/') + if idx == -1 { break - } + } start = idx + 1 } return path[start:] diff --git a/example/main.go b/example/main.go index dd23d9f..3ca82c9 100644 --- a/example/main.go +++ b/example/main.go @@ -17,7 +17,7 @@ func main() { TruncateSourcePath: 2, TimeFormat: "15:04:05.000", HeaderWidth: 15, - Theme: console.NewDimTheme(), + Theme: console.NewDimTheme(), }), ) slog.SetDefault(logger) diff --git a/handler.go b/handler.go index d6cdb97..2a43f17 100644 --- a/handler.go +++ b/handler.go @@ -3,11 +3,11 @@ package console import ( "bytes" "context" + "fmt" "io" "log/slog" "os" "runtime" - "slices" "strings" "time" ) @@ -25,9 +25,6 @@ func init() { cwd = strings.ReplaceAll(cwd, "\\", "/") } -// %[time]t %[source]3-h %[logger]8-h %[lvl]3l | %[msg]m %a -// timef(), source(3, left), header(logger, 8, right) levelAbbr() string("|") msg() attrs() - // HandlerOptions are options for a ConsoleHandler. // A zero HandlerOptions consists entirely of default values. // ReplaceAttr works identically to [slog.HandlerOptions.ReplaceAttr] @@ -79,16 +76,33 @@ type HandlerOptions struct { // models/users.go:34 // TruncateSourcePath = 2 // ...etc TruncateSourcePath int + + HeaderFormat string } type Handler struct { - opts HandlerOptions - out io.Writer - groupPrefix string - groups []string - context buffer - headers []slog.Attr + opts HandlerOptions + out io.Writer + groupPrefix string + groups []string + context, multilineContext buffer + fields []any + numHeaders int +} + +type timestampField struct{} +type headerField struct { + key string + width int + rightAlign bool + capture bool + memo string +} +type levelField struct { + abbreviated bool + rightAlign bool } +type messageField struct{} var _ slog.Handler = (*Handler)(nil) @@ -108,12 +122,19 @@ func NewHandler(out io.Writer, opts *HandlerOptions) *Handler { if opts.Theme == nil { opts.Theme = NewDefaultTheme() } + if opts.HeaderFormat == "" { + opts.HeaderFormat = "%t %l %[source]h > %m" // default format + } + + fields, numHeaders := parseFormat(opts.HeaderFormat) + return &Handler{ opts: *opts, // Copy struct out: out, groupPrefix: "", context: nil, - headers: make([]slog.Attr, len(opts.Headers)), + fields: fields, + numHeaders: numHeaders, } } @@ -122,20 +143,66 @@ func (h *Handler) Enabled(_ context.Context, l slog.Level) bool { return l >= h.opts.Level.Level() } -// Handle implements slog.Handler. -func (h *Handler) Handle(_ context.Context, rec slog.Record) error { - enc := newEncoder(h) - headerBuf := &enc.headerBuf - middleBuf := &enc.middleBuf - trailerBuf := &enc.trailerBuf - - enc.writeTimestamp(headerBuf, rec.Time) +func (e *encoder) encodeAttr(groupPrefix string, a slog.Attr) { + offset := e.attrBuf.Len() + e.writeAttr(&e.attrBuf, a, groupPrefix) + + // check if the last attr written has newlines in it + // if so, move it to the trailerBuf + lastAttr := e.attrBuf[offset:] + if bytes.IndexByte(lastAttr, '\n') >= 0 { + // todo: consider splitting the key and the value + // components, so the `key=` can be printed on its + // own line, and the value will not share any of its + // lines with anything else. Like: + // + // INF msg key1=val1 + // key2= + // val2 line 1 + // val2 line 2 + // key3= + // val3 line 1 + // val3 line 2 + // + // and maybe consider printing the key for these values + // differently, like: + // + // === key2 === + // val2 line1 + // val2 line2 + // === key3 === + // val3 line 1 + // val3 line 2 + // + // Splitting the key and value doesn't work up here in + // Handle() though, because we don't know where the term + // control characters are. Would need to push this + // multiline handling deeper into encoder, or pass + // offsets back up from writeAttr() + // + // if k, v, ok := bytes.Cut(lastAttr, []byte("=")); ok { + // trailerBuf.AppendString("=== ") + // trailerBuf.Append(k[1:]) + // trailerBuf.AppendString(" ===\n") + // trailerBuf.AppendByte('=') + // trailerBuf.AppendByte('\n') + // trailerBuf.AppendString("---------------------\n") + // trailerBuf.Append(v) + // trailerBuf.AppendString("\n---------------------\n") + // trailerBuf.AppendByte('\n') + // } else { + // trailerBuf.Append(lastAttr[1:]) + // trailerBuf.AppendByte('\n') + // } + e.multilineAttrBuf.Append(lastAttr) - enc.writeLevel(middleBuf, rec.Level) - enc.writeHeaderSeparator(middleBuf) - enc.writeMessage(middleBuf, rec.Level, rec.Message) + // rewind the middle buffer + e.attrBuf = e.attrBuf[:offset] + } +} - middleBuf.copy(&h.context) +func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { + enc := newEncoder(h) if h.opts.AddSource && rec.PC > 0 { src := slog.Source{} @@ -146,107 +213,67 @@ func (h *Handler) Handle(_ context.Context, rec slog.Record) error { rec.AddAttrs(slog.Any(slog.SourceKey, &src)) } - headers := h.headers - localHeaders := false + // todo: make this part of the encoder struct + headers := make([]slog.Attr, h.numHeaders) + + enc.attrBuf.Append(h.context) + enc.multilineAttrBuf.Append(h.multilineContext) + rec.Attrs(func(a slog.Attr) bool { - idx := slices.IndexFunc(h.opts.Headers, func(s string) bool { return s == a.Key }) - if idx >= 0 { - if !localHeaders { - localHeaders = true - headers = append(enc.headers, h.headers...) + headerIdx := -1 + for _, f := range h.fields { + if f, ok := f.(headerField); ok { + headerIdx++ + if f.key == a.Key { + headers[headerIdx] = a + if f.capture { + return true + } + } } - headers[idx] = a - return true } - offset := middleBuf.Len() - enc.writeAttr(middleBuf, a, h.groupPrefix) - - // check if the last attr written has newlines in it - // if so, move it to the trailerBuf - lastAttr := (*middleBuf)[offset:] - if bytes.IndexByte(lastAttr, '\n') >= 0 { - // todo: consider splitting the key and the value - // components, so the `key=` can be printed on its - // own line, and the value will not share any of its - // lines with anything else. Like: - // - // INF msg key1=val1 - // key2= - // val2 line 1 - // val2 line 2 - // key3= - // val3 line 1 - // val3 line 2 - // - // and maybe consider printing the key for these values - // differently, like: - // - // === key2 === - // val2 line1 - // val2 line2 - // === key3 === - // val3 line 1 - // val3 line 2 - // - // Splitting the key and value doesn't work up here in - // Handle() though, because we don't know where the term - // control characters are. Would need to push this - // multiline handling deeper into encoder, or pass - // offsets back up from writeAttr() - // - // if k, v, ok := bytes.Cut(lastAttr, []byte("=")); ok { - // trailerBuf.AppendString("=== ") - // trailerBuf.Append(k[1:]) - // trailerBuf.AppendString(" ===\n") - // trailerBuf.AppendByte('=') - // trailerBuf.AppendByte('\n') - // trailerBuf.AppendString("---------------------\n") - // trailerBuf.Append(v) - // trailerBuf.AppendString("\n---------------------\n") - // trailerBuf.AppendByte('\n') - // } else { - // trailerBuf.Append(lastAttr[1:]) - // trailerBuf.AppendByte('\n') - // } - trailerBuf.Append(lastAttr) - - // rewind the middle buffer - *middleBuf = (*middleBuf)[:offset] - } + enc.encodeAttr(h.groupPrefix, a) return true }) - if h.opts.HeaderWidth > 0 { - for _, a := range headers { - enc.writeHeader(headerBuf, a, h.opts.HeaderWidth) - } - } else { - // not using a fixed width header. Just write the entire source - // and headers to the buf sequentially. - // if h.opts.AddSource { - // enc.writeSource(headerBuf, rec.PC, cwd) - // } - - if len(headers) > 0 { - enc.writeHeaders(headerBuf, headers) + var swallow bool + headerIdx := 0 + var l int + for _, f := range h.fields { + switch f := f.(type) { + case headerField: + if headers[headerIdx].Equal(slog.Attr{}) && f.memo != "" { + enc.buf.AppendString(f.memo) + } else { + enc.writeHeader(&enc.buf, headers[headerIdx], f.width, f.rightAlign) + } + headerIdx++ + case levelField: + enc.writeLevel(&enc.buf, rec.Level, f.abbreviated) + case messageField: + enc.writeMessage(&enc.buf, rec.Level, rec.Message) + case timestampField: + enc.writeTimestamp(&enc.buf, rec.Time) + case string: + if swallow { + f = strings.TrimPrefix(f, " ") + } + enc.buf.AppendString(f) + l = 0 // ensure the next field is not swallowed } - } - - if trailerBuf.Len() == 0 { - // if there were no multiline attrs, terminate the line with a newline - enc.NewLine(middleBuf) - } else { - // if there were multiline attrs, write middle <-> trailer separater - enc.NewLine(trailerBuf) + l2 := enc.buf.Len() + swallow = l2 == l + l = l2 } // concatenate the buffers together before writing to out, so the entire // log line is written in a single Write call - headerBuf.copy(middleBuf) - headerBuf.copy(trailerBuf) + enc.buf.copy(&enc.attrBuf) + enc.buf.copy(&enc.multilineAttrBuf) + enc.NewLine(&enc.buf) - if _, err := headerBuf.WriteTo(h.out); err != nil { + if _, err := enc.buf.WriteTo(h.out); err != nil { return err } @@ -256,20 +283,33 @@ func (h *Handler) Handle(_ context.Context, rec slog.Record) error { // WithAttrs implements slog.Handler. func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { - headers := h.extractHeaders(attrs) - newCtx := h.context + attrs, fields := h.memoizeHeaders(attrs) + enc := newEncoder(h) for _, a := range attrs { - enc.writeAttr(&newCtx, a, h.groupPrefix) + enc.encodeAttr(h.groupPrefix, a) } - newCtx.Clip() + + newCtx := h.context + newMultiCtx := h.multilineContext + if len(enc.attrBuf) > 0 { + newCtx = append(newCtx, enc.attrBuf...) + newCtx.Clip() + } + if len(enc.multilineAttrBuf) > 0 { + newMultiCtx = append(newMultiCtx, enc.multilineAttrBuf...) + newMultiCtx.Clip() + } + return &Handler{ - opts: h.opts, - out: h.out, - groupPrefix: h.groupPrefix, - context: newCtx, - groups: h.groups, - headers: headers, + opts: h.opts, + out: h.out, + groupPrefix: h.groupPrefix, + context: newCtx, + multilineContext: newMultiCtx, + groups: h.groups, + fields: fields, + numHeaders: h.numHeaders, } } @@ -286,28 +326,209 @@ func (h *Handler) WithGroup(name string) slog.Handler { groupPrefix: groupPrefix, context: h.context, groups: append(h.groups, name), - headers: h.headers, + numHeaders: h.numHeaders, + fields: h.fields, + } +} + +func (h *Handler) memoizeHeaders(attrs []slog.Attr) ([]slog.Attr, []any) { + enc := newEncoder(h) + defer enc.free() + buf := &enc.buf + newFields := make([]any, len(h.fields)) + copy(newFields, h.fields) + remainingAttrs := make([]slog.Attr, 0, len(attrs)) + + for _, attr := range attrs { + capture := false + for i, field := range h.fields { + if headerField, ok := field.(headerField); ok { + if headerField.key == attr.Key { + buf.Reset() + enc.writeHeader(buf, attr, headerField.width, headerField.rightAlign) + headerField.memo = buf.String() + newFields[i] = headerField + if headerField.capture { + capture = true + } + // don't break, in case there are multiple headers with the same key + } + } + } + if !capture { + remainingAttrs = append(remainingAttrs, attr) + } } + return remainingAttrs, newFields +} + +// ParseFormatResult contains the parsed fields and header count from a format string +type ParseFormatResult struct { + Fields []any + HeaderCount int } -// extractHeaders scans the attributes for keys specified in Headers. -// If found, their values are saved in a new list. -// The original attribute list will be modified to remove the extracted attributes. -func (h *Handler) extractHeaders(attrs []slog.Attr) (headers []slog.Attr) { - changed := false - headers = h.headers - for i, attr := range attrs { - idx := slices.IndexFunc(h.opts.Headers, func(s string) bool { return s == attr.Key }) - if idx >= 0 { - if !changed { - // make a copy of prefixes: - headers = make([]slog.Attr, len(h.headers)) - copy(headers, h.headers) +// Equal compares two ParseFormatResults for equality +func (p ParseFormatResult) Equal(other ParseFormatResult) bool { + if p.HeaderCount != other.HeaderCount { + return false + } + if len(p.Fields) != len(other.Fields) { + return false + } + for i := range p.Fields { + if fmt.Sprintf("%#v", p.Fields[i]) != fmt.Sprintf("%#v", other.Fields[i]) { + return false + } + } + return true +} + +// parseFormat parses a format string into a list of fields and the number of headerFields. +// Supported format verbs: +// %t - timestampField +// %h - headerField, requires [name] modifier, supports width, - and + modifiers +// %m - messageField +// %l - abbreviated levelField +// %L - non-abbreviated levelField, supports - modifier +// +// Modifiers: +// [name]: the key of the attribute to capture as a header, required +// width: int fixed width, optional +// -: for right alignment, optional +// +: for non-capturing header, optional +// +// Examples: +// +// "%t %l %m" // timestamp, level, message +// "%t [%l] %m" // timestamp, level in brackets, message +// "%t %l:%m" // timestamp, level:message +// "%t %l %[key]h %m" // timestamp, level, header with key "key", message +// "%t %l %[key1]h %[key2]h %m" // timestamp, level, header with key "key1", header with key "key2", message +// "%t %l %[key]10h %m" // timestamp, level, header with key "key" and width 10, message +// "%t %l %[key]-10h %m" // timestamp, level, right-aligned header with key "key" and width 10, message +// "%t %l %[key]10+h %m" // timestamp, level, captured header with key "key" and width 10, message +// "%t %l %[key]-10+h %m" // timestamp, level, right-aligned captured header with key "key" and width 10, message +// "%t %l %L %m" // timestamp, abbreviated level, non-abbreviated level, message +// "%t %l %L- %m" // timestamp, abbreviated level, right-aligned non-abbreviated level, message +// "%t %l %m string literal" // timestamp, level, message, and then " string literal" +// "prefix %t %l %m suffix" // "prefix ", timestamp, level, message, and then " suffix" +// "%% %t %l %m" // literal "%", timestamp, level, message +// +// Note that headers will "capture" their matching attribute by default, which means that attribute will not +// be included in the attributes section of the log line, and will not be matched by subsequent header fields. +// Use the non-capturing header modifier '+' to disable capturing. If a header is not capturing, the attribute +// will still be available for matching subsequent header fields, and will be included in the attributes section +// of the log line. +func parseFormat(format string) (fields []any, headerCount int) { + fields = make([]any, 0) + headerCount = 0 + + for i := 0; i < len(format); i++ { + if format[i] != '%' { + // Find the next % or end of string + start := i + for i < len(format) && format[i] != '%' { + i++ + } + fields = append(fields, format[start:i]) + i-- // compensate for loop increment + continue + } + + // Handle %% escape + if i+1 < len(format) && format[i+1] == '%' { + fields = append(fields, "%") + i++ + continue + } + + // Parse format verb and any modifiers + i++ + if i >= len(format) { + fields = append(fields, "%!(MISSING_VERB)") + break + } + + // Check for modifiers before verb + var field any + var width int + var rightAlign bool + var capture bool = true // default to capturing for headers + var key string + + // Look for [name] modifier + if format[i] == '[' { + // Find the next ] or end of string + end := i + 1 + for end < len(format) && format[end] != ']' && format[end] != ' ' { + end++ + } + if end >= len(format) || format[end] != ']' { + i = end - 1 // Position just before the next character to process + fields = append(fields, "%!(MISSING_CLOSING_BRACKET)") + continue } - headers[idx] = attr - attrs[i] = slog.Attr{} // remove the prefix attribute - changed = true + key = format[i+1 : end] + i = end + 1 } + + // Look for modifiers + for i < len(format) { + if format[i] == '-' { + rightAlign = true + i++ + } else if format[i] == '+' && key != "" { // '+' only valid for headers + capture = false + i++ + } else if format[i] >= '0' && format[i] <= '9' && key != "" { // width only valid for headers + width = 0 + for i < len(format) && format[i] >= '0' && format[i] <= '9' { + width = width*10 + int(format[i]-'0') + i++ + } + } else { + break + } + } + + if i >= len(format) { + fields = append(fields, "%!(MISSING_VERB)") + break + } + + // Parse the verb + switch format[i] { + case 't': + field = timestampField{} + case 'h': + if key == "" { + fields = append(fields, "%!h(MISSING_HEADER_NAME)") + continue + } + field = headerField{ + key: key, + width: width, + rightAlign: rightAlign, + capture: capture, + } + headerCount++ + case 'm': + field = messageField{} + case 'l': + field = levelField{abbreviated: true} + case 'L': + field = levelField{ + abbreviated: false, + rightAlign: rightAlign, + } + default: + fields = append(fields, fmt.Sprintf("%%!%c(INVALID_VERB)", format[i])) + continue + } + + fields = append(fields, field) } - return + + return fields, headerCount } diff --git a/handler_test.go b/handler_test.go index 09063ec..dd661fe 100644 --- a/handler_test.go +++ b/handler_test.go @@ -24,7 +24,7 @@ func TestHandler_TimeFormat(t *testing.T) { rec.AddAttrs(slog.Time("endtime", endTime)) AssertNoError(t, h.Handle(context.Background(), rec)) - expected := fmt.Sprintf("%s INF foobar endtime=%s\n", now.Format(time.RFC3339Nano), endTime.Format(time.RFC3339Nano)) + expected := fmt.Sprintf("%s INF > foobar endtime=%s\n", now.Format(time.RFC3339Nano), endTime.Format(time.RFC3339Nano)) AssertEqual(t, expected, buf.String()) } @@ -37,7 +37,7 @@ func TestHandler_TimeZero(t *testing.T) { rec := slog.NewRecord(time.Time{}, slog.LevelInfo, "foobar", 0) AssertNoError(t, h.Handle(context.Background(), rec)) - expected := fmt.Sprintf("INF foobar\n") + expected := fmt.Sprintf("INF > foobar\n") AssertEqual(t, expected, buf.String()) } @@ -48,7 +48,7 @@ func TestHandler_NoColor(t *testing.T) { rec := slog.NewRecord(now, slog.LevelInfo, "foobar", 0) AssertNoError(t, h.Handle(context.Background(), rec)) - expected := fmt.Sprintf("%s INF foobar\n", now.Format(time.DateTime)) + expected := fmt.Sprintf("%s INF > foobar\n", now.Format(time.DateTime)) AssertEqual(t, expected, buf.String()) } @@ -116,7 +116,7 @@ func TestHandler_Attr(t *testing.T) { ) AssertNoError(t, h.Handle(context.Background(), rec)) - expected := fmt.Sprintf("%s INF foobar bool=true int=-12 uint=12 float=3.14 foo=bar time=%s dur=1s group.foo=bar group.subgroup.foo=bar err=the error formattedError=formatted the error stringer=stringer nostringer={bar} valuer=The word is 'distant'\n", now.Format(time.DateTime), now.Format(time.DateTime)) + expected := fmt.Sprintf("%s INF > foobar bool=true int=-12 uint=12 float=3.14 foo=bar time=%s dur=1s group.foo=bar group.subgroup.foo=bar err=the error formattedError=formatted the error stringer=stringer nostringer={bar} valuer=The word is 'distant'\n", now.Format(time.DateTime), now.Format(time.DateTime)) AssertEqual(t, expected, buf.String()) } @@ -133,7 +133,7 @@ func TestHandler_AttrsWithNewlines(t *testing.T) { attrs: []slog.Attr{ slog.String("foo", "line one\nline two"), }, - want: "INF multiline attrs foo=line one\nline two\n", + want: "INF > multiline attrs foo=line one\nline two\n", }, { name: "multiple attrs", @@ -141,7 +141,7 @@ func TestHandler_AttrsWithNewlines(t *testing.T) { slog.String("foo", "line one\nline two"), slog.String("bar", "line three\nline four"), }, - want: "INF multiline attrs foo=line one\nline two bar=line three\nline four\n", + want: "INF > multiline attrs foo=line one\nline two bar=line three\nline four\n", }, { name: "sort multiline attrs to end", @@ -152,20 +152,21 @@ func TestHandler_AttrsWithNewlines(t *testing.T) { slog.String("bar", "line three\nline four"), slog.String("color", "red"), }, - want: "INF multiline attrs size=big weight=heavy color=red foo=line one\nline two bar=line three\nline four\n", + want: "INF > multiline attrs size=big weight=heavy color=red foo=line one\nline two bar=line three\nline four\n", }, { name: "multiline message", msg: "multiline\nmessage", - want: "INF multiline\nmessage\n", + want: "INF > multiline\nmessage\n", }, { name: "preserve leading and trailing newlines", attrs: []slog.Attr{ slog.String("foo", "\nline one\nline two\n"), }, - want: "INF multiline attrs foo=\nline one\nline two\n\n", + want: "INF > multiline attrs foo=\nline one\nline two\n\n", }, + // todo: test multiline attr using WithAttrs } for _, test := range tests { @@ -201,7 +202,7 @@ func TestHandler_GroupEmpty(t *testing.T) { ) AssertNoError(t, h.Handle(context.Background(), rec)) - expected := fmt.Sprintf("%s INF foobar group.foo=bar\n", now.Format(time.DateTime)) + expected := fmt.Sprintf("%s INF > foobar group.foo=bar\n", now.Format(time.DateTime)) AssertEqual(t, expected, buf.String()) } @@ -219,7 +220,7 @@ func TestHandler_GroupInline(t *testing.T) { ) AssertNoError(t, h.Handle(context.Background(), rec)) - expected := fmt.Sprintf("%s INF foobar group.foo=bar foo=bar\n", now.Format(time.DateTime)) + expected := fmt.Sprintf("%s INF > foobar group.foo=bar foo=bar\n", now.Format(time.DateTime)) AssertEqual(t, expected, buf.String()) } @@ -235,7 +236,7 @@ func TestHandler_GroupResolve(t *testing.T) { ) AssertNoError(t, h.Handle(context.Background(), rec)) - expected := fmt.Sprintf("%s INF foobar group.stringer=stringer group.valuer=The word is 'surreal'\n", now.Format(time.DateTime)) + expected := fmt.Sprintf("%s INF > foobar group.stringer=stringer group.valuer=The word is 'surreal'\n", now.Format(time.DateTime)) AssertEqual(t, expected, buf.String()) } @@ -268,12 +269,12 @@ func TestHandler_WithAttr(t *testing.T) { )}) AssertNoError(t, h2.Handle(context.Background(), rec)) - expected := fmt.Sprintf("%s INF foobar bool=true int=-12 uint=12 float=3.14 foo=bar time=%s dur=1s stringer=stringer valuer=The word is 'awesome' group.foo=bar group.subgroup.foo=bar group.stringer=stringer group.valuer=The word is 'pizza'\n", now.Format(time.DateTime), now.Format(time.DateTime)) + expected := fmt.Sprintf("%s INF > foobar bool=true int=-12 uint=12 float=3.14 foo=bar time=%s dur=1s stringer=stringer valuer=The word is 'awesome' group.foo=bar group.subgroup.foo=bar group.stringer=stringer group.valuer=The word is 'pizza'\n", now.Format(time.DateTime), now.Format(time.DateTime)) AssertEqual(t, expected, buf.String()) buf.Reset() AssertNoError(t, h.Handle(context.Background(), rec)) - AssertEqual(t, fmt.Sprintf("%s INF foobar\n", now.Format(time.DateTime)), buf.String()) + AssertEqual(t, fmt.Sprintf("%s INF > foobar\n", now.Format(time.DateTime)), buf.String()) } func TestHandler_WithGroup(t *testing.T) { @@ -284,18 +285,18 @@ func TestHandler_WithGroup(t *testing.T) { rec.Add("int", 12) h2 := h.WithGroup("group1").WithAttrs([]slog.Attr{slog.String("foo", "bar")}) AssertNoError(t, h2.Handle(context.Background(), rec)) - expected := fmt.Sprintf("%s INF foobar group1.foo=bar group1.int=12\n", now.Format(time.DateTime)) + expected := fmt.Sprintf("%s INF > foobar group1.foo=bar group1.int=12\n", now.Format(time.DateTime)) AssertEqual(t, expected, buf.String()) buf.Reset() h3 := h2.WithGroup("group2") AssertNoError(t, h3.Handle(context.Background(), rec)) - expected = fmt.Sprintf("%s INF foobar group1.foo=bar group1.group2.int=12\n", now.Format(time.DateTime)) + expected = fmt.Sprintf("%s INF > foobar group1.foo=bar group1.group2.int=12\n", now.Format(time.DateTime)) AssertEqual(t, expected, buf.String()) buf.Reset() AssertNoError(t, h.Handle(context.Background(), rec)) - AssertEqual(t, fmt.Sprintf("%s INF foobar int=12\n", now.Format(time.DateTime)), buf.String()) + AssertEqual(t, fmt.Sprintf("%s INF > foobar int=12\n", now.Format(time.DateTime)), buf.String()) } func TestHandler_Levels(t *testing.T) { @@ -321,7 +322,7 @@ func TestHandler_Levels(t *testing.T) { rec := slog.NewRecord(now, ll, "foobar", 0) if ll >= l { AssertNoError(t, h.Handle(context.Background(), rec)) - AssertEqual(t, fmt.Sprintf("%s %s foobar\n", now.Format(time.DateTime), s), buf.String()) + AssertEqual(t, fmt.Sprintf("%s %s > foobar\n", now.Format(time.DateTime), s), buf.String()) buf.Reset() } } @@ -342,14 +343,14 @@ func TestHandler_Source(t *testing.T) { AssertEqual(t, fmt.Sprintf("%s INF %s:%d > foobar\n", now.Format(time.DateTime), file, line), buf.String()) buf.Reset() AssertNoError(t, h2.Handle(context.Background(), rec)) - AssertEqual(t, fmt.Sprintf("%s INF foobar\n", now.Format(time.DateTime)), buf.String()) + AssertEqual(t, fmt.Sprintf("%s INF > foobar\n", now.Format(time.DateTime)), buf.String()) buf.Reset() // If the PC is zero then this field and its associated group should not be logged. // '- If r.PC is zero, ignore it.' // https://pkg.go.dev/log/slog@master#Handler rec.PC = 0 AssertNoError(t, h.Handle(context.Background(), rec)) - AssertEqual(t, fmt.Sprintf("%s INF foobar\n", now.Format(time.DateTime)), buf.String()) + AssertEqual(t, fmt.Sprintf("%s INF > foobar\n", now.Format(time.DateTime)), buf.String()) } type valuer struct { @@ -395,7 +396,7 @@ func TestHandler_ReplaceAttr(t *testing.T) { r.Time = time.Time{} }, noSource: true, - want: "INF foobar size=12 color=red\n", + want: "INF > foobar size=12 color=red\n", replaceAttr: func(t *testing.T, s []string, a slog.Attr) slog.Attr { switch a.Key { case slog.TimeKey, slog.SourceKey: @@ -494,7 +495,7 @@ func TestHandler_ReplaceAttr(t *testing.T) { { name: "clear source", replaceAttr: replaceAttrWith(slog.SourceKey, slog.Any(slog.SourceKey, nil)), - want: "2010-05-06 07:08:09 INF foobar size=12 color=red\n", + want: "2010-05-06 07:08:09 INF > foobar size=12 color=red\n", }, { name: "replace source", @@ -523,13 +524,15 @@ func TestHandler_ReplaceAttr(t *testing.T) { want: "2010-05-06 07:08:09 INF path/to/file.go:33 > foobar size=12 color=red\n", }, { - name: "empty source", // should still be called + name: "empty source", // won't be called because PC is 0 modrec: func(r *slog.Record) { r.PC = 0 }, - replaceAttr: replaceAttrWith(slog.SourceKey, slog.Any(slog.SourceKey, &slog.Source{ - File: filepath.Join(cwd, "path", "to", "file.go"), - Line: 33, - })), - want: "2010-05-06 07:08:09 INF path/to/file.go:33 > foobar size=12 color=red\n", + replaceAttr: func(t *testing.T, s []string, a slog.Attr) slog.Attr { + if a.Key == slog.SourceKey { + t.Errorf("should not have been called on source attr, was called on %v", a) + } + return a + }, + want: "2010-05-06 07:08:09 INF > foobar size=12 color=red\n", }, { name: "clear message", @@ -621,11 +624,11 @@ func TestHandler_Headers(t *testing.T) { { name: "no headers", attrs: []slog.Attr{slog.String("foo", "bar")}, - want: "INF with headers foo=bar\n", + want: "INF > with headers foo=bar\n", }, { name: "one header", - opts: HandlerOptions{Headers: []string{"foo"}}, + opts: HandlerOptions{HeaderFormat: "%l %[foo]h > %m"}, attrs: []slog.Attr{ slog.String("foo", "bar"), slog.String("bar", "baz"), @@ -634,27 +637,36 @@ func TestHandler_Headers(t *testing.T) { }, { name: "two headers", - opts: HandlerOptions{Headers: []string{"foo", "bar"}}, + opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m"}, attrs: []slog.Attr{ slog.String("foo", "bar"), slog.String("bar", "baz"), }, want: "INF bar baz > with headers\n", }, + { + name: "two headers alt order", + opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m"}, + attrs: []slog.Attr{ + slog.String("bar", "baz"), + slog.String("foo", "bar"), + }, + want: "INF bar baz > with headers\n", + }, { name: "missing headers", - opts: HandlerOptions{Headers: []string{"foo", "bar"}}, + opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m"}, attrs: []slog.Attr{slog.String("bar", "baz"), slog.String("baz", "foo")}, want: "INF baz > with headers baz=foo\n", }, { name: "missing all headers", - opts: HandlerOptions{Headers: []string{"foo", "bar"}}, - want: "INF with headers\n", + opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m"}, + want: "INF > with headers\n", }, { name: "header and source", - opts: HandlerOptions{Headers: []string{"foo"}, AddSource: true}, + opts: HandlerOptions{HeaderFormat: "%l %[source]h %[foo]h > %m", AddSource: true}, attrs: []slog.Attr{ slog.String("foo", "bar"), slog.String("bar", "baz"), @@ -663,9 +675,8 @@ func TestHandler_Headers(t *testing.T) { }, { name: "withattrs", - opts: HandlerOptions{Headers: []string{"foo"}}, + opts: HandlerOptions{HeaderFormat: "%l %[foo]h > %m"}, attrs: []slog.Attr{ - slog.String("bar", "baz"), }, withAttrs: []slog.Attr{ @@ -675,7 +686,7 @@ func TestHandler_Headers(t *testing.T) { }, { name: "withgroup", - opts: HandlerOptions{Headers: []string{"foo", "bar"}}, + opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m"}, attrs: []slog.Attr{ slog.String("bar", "baz"), slog.String("baz", "foo"), @@ -688,6 +699,8 @@ func TestHandler_Headers(t *testing.T) { }, // todo: add a test for when the record doesn't include the header field, but fixed width headers are enabled // the header should be padded with spaces to the right + // todo: add a test for when the same attribute is repeated in the record + // todo: add a test for when the same attribute is repeated in the headers } for _, test := range tests { @@ -720,9 +733,9 @@ func TestHandler_Headers(t *testing.T) { buf := bytes.Buffer{} h := NewHandler(&buf, &HandlerOptions{ - Headers: []string{"foo", "bar"}, - TimeFormat: "0", - NoColor: true, + HeaderFormat: "%l %[foo]h %[bar]h > %m", + TimeFormat: "0", + NoColor: true, }) assertLog := func(t *testing.T, handler slog.Handler, want string, attrs ...slog.Attr) { @@ -816,7 +829,7 @@ func TestThemes(t *testing.T) { // Source if theme.Source() != "" { checkANSIMod(t, "Source", theme.Source()) - checkANSIMod(t, "AttrKey", theme.AttrKey()) + // checkANSIMod(t, "AttrKey", theme.AttrKey()) } // Message @@ -925,3 +938,235 @@ func TestThemes(t *testing.T) { }) } } + +func TestParseFormat(t *testing.T) { + tests := []struct { + name string + format string + wantFields []any + wantHeaders int + }{ + { + name: "basic format", + format: "%t %l %m", + wantFields: []any{ + timestampField{}, + " ", + levelField{abbreviated: true}, + " ", + messageField{}, + }, + wantHeaders: 0, + }, + { + name: "with header", + format: "%t %[logger]h %l %m", + wantFields: []any{ + timestampField{}, + " ", + headerField{key: "logger", capture: true}, + " ", + levelField{abbreviated: true}, + " ", + messageField{}, + }, + wantHeaders: 1, + }, + { + name: "header with width", + format: "%t %[logger]5h %l %m", + wantFields: []any{ + timestampField{}, + " ", + headerField{key: "logger", width: 5, capture: true}, + " ", + levelField{abbreviated: true}, + " ", + messageField{}, + }, + wantHeaders: 1, + }, + { + name: "header with right align", + format: "%t %[logger]-h %l %m", + wantFields: []any{ + timestampField{}, + " ", + headerField{key: "logger", rightAlign: true, capture: true}, + " ", + levelField{abbreviated: true}, + " ", + messageField{}, + }, + wantHeaders: 1, + }, + { + name: "header with width and right align", + format: "%t %[logger]-5h %l %m", + wantFields: []any{ + timestampField{}, + " ", + headerField{key: "logger", width: 5, rightAlign: true, capture: true}, + " ", + levelField{abbreviated: true}, + " ", + messageField{}, + }, + wantHeaders: 1, + }, + { + name: "non-capturing header", + format: "%t %[logger]+h %l %m", + wantFields: []any{ + timestampField{}, + " ", + headerField{key: "logger", capture: false}, + " ", + levelField{abbreviated: true}, + " ", + messageField{}, + }, + wantHeaders: 1, + }, + { + name: "multiple headers", + format: "%t %[logger]h %[source]h %l %m", + wantFields: []any{ + timestampField{}, + " ", + headerField{key: "logger", capture: true}, + " ", + headerField{key: "source", capture: true}, + " ", + levelField{abbreviated: true}, + " ", + messageField{}, + }, + wantHeaders: 2, + }, + { + name: "with literal text", + format: "prefix %t [%l] %m suffix", + wantFields: []any{ + "prefix ", + timestampField{}, + " [", + levelField{abbreviated: true}, + "] ", + messageField{}, + " suffix", + }, + wantHeaders: 0, + }, + { + name: "with escaped percent", + format: "%% %t %l %m", + wantFields: []any{ + "%", + " ", + timestampField{}, + " ", + levelField{abbreviated: true}, + " ", + messageField{}, + }, + wantHeaders: 0, + }, + { + name: "with non-abbreviated level", + format: "%t %L %m", + wantFields: []any{ + timestampField{}, + " ", + levelField{abbreviated: false}, + " ", + messageField{}, + }, + wantHeaders: 0, + }, + { + name: "with right-aligned non-abbreviated level", + format: "%t %-L %m", + wantFields: []any{ + timestampField{}, + " ", + levelField{abbreviated: false, rightAlign: true}, + " ", + messageField{}, + }, + wantHeaders: 0, + }, + { + name: "error: missing verb", + format: "%t %", + wantFields: []any{ + timestampField{}, + " ", + "%!(MISSING_VERB)", + }, + wantHeaders: 0, + }, + { + name: "error: missing header name", + format: "%t %h %m", + wantFields: []any{ + timestampField{}, + " ", + "%!h(MISSING_HEADER_NAME)", + " ", + messageField{}, + }, + wantHeaders: 0, + }, + { + name: "error: missing closing bracket", + format: "%t %[logger %m", + wantFields: []any{ + timestampField{}, + " ", + "%!(MISSING_CLOSING_BRACKET)", + " ", + messageField{}, + }, + wantHeaders: 0, + }, + { + name: "error: invalid verb", + format: "%t %x %m", + wantFields: []any{ + timestampField{}, + " ", + "%!x(INVALID_VERB)", + " ", + messageField{}, + }, + wantHeaders: 0, + }, + { + name: "with extra whitespace", + format: "%t %l %[logger]h %m", + wantFields: []any{ + timestampField{}, + " ", + levelField{abbreviated: true}, + " ", + headerField{key: "logger", capture: true}, + " ", + messageField{}, + }, + wantHeaders: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotFields, gotHeaders := parseFormat(tt.format) + if gotHeaders != tt.wantHeaders { + t.Errorf("parseFormat() header count = %v, want %v", gotHeaders, tt.wantHeaders) + } + if !reflect.DeepEqual(gotFields, tt.wantFields) { + t.Errorf("parseFormat() fields =\n%#v\nwant:\n%#v", gotFields, tt.wantFields) + } + }) + } +} diff --git a/theme.go b/theme.go index e11ee5e..84e8477 100644 --- a/theme.go +++ b/theme.go @@ -153,12 +153,12 @@ func NewBrightTheme() Theme { func NewDimTheme() Theme { return ThemeDef{ name: "Dim", - timestamp: ToANSICode(BrightBlack), - source: ToANSICode(Bold, BrightBlack), + timestamp: ToANSICode(Faint), + source: ToANSICode(Bold, Faint), message: ToANSICode(Bold), - messageDebug: ToANSICode(), - attrKey: ToANSICode(Cyan), - attrValue: ToANSICode(BrightBlack), + messageDebug: ToANSICode(Bold), + attrKey: ToANSICode(Faint, Cyan), + attrValue: ToANSICode(Faint), attrValueError: ToANSICode(Bold, Red), levelError: ToANSICode(Bold, Red), levelWarn: ToANSICode(Bold, Yellow), diff --git a/utils_test.go b/utils_test.go index abeb4dc..731ef79 100644 --- a/utils_test.go +++ b/utils_test.go @@ -16,7 +16,7 @@ func AssertZero[E comparable](t *testing.T, v E) { func AssertEqual[E comparable](t *testing.T, expected, value E) { t.Helper() if expected != value { - t.Errorf("expected %v, got %v", expected, value) + t.Errorf("\nexpected: %v\n got: %v", expected, value) } } From 037df060fcf5cf4db8c72a20419cd67770f7929a Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Tue, 11 Feb 2025 16:06:34 -0600 Subject: [PATCH 18/44] Removed Headers and HeaderWidth options --- encoding.go | 3 -- example/main.go | 3 +- handler.go | 104 +++++++++++++++++++++++++++--------------------- handler_test.go | 53 +++++++++++++++--------- 4 files changed, 93 insertions(+), 70 deletions(-) diff --git a/encoding.go b/encoding.go index d684276..d5ec36e 100644 --- a/encoding.go +++ b/encoding.go @@ -16,7 +16,6 @@ var encoderPool = &sync.Pool{ e.buf = make(buffer, 0, 1024) e.attrBuf = make(buffer, 0, 1024) e.multilineAttrBuf = make(buffer, 0, 1024) - e.headers = make([]slog.Attr, 0, 6) return e }, } @@ -25,8 +24,6 @@ type encoder struct { h *Handler buf, attrBuf, multilineAttrBuf buffer groups []string - headers []slog.Attr - headersCopied bool } func newEncoder(h *Handler) *encoder { diff --git a/example/main.go b/example/main.go index 3ca82c9..88463e8 100644 --- a/example/main.go +++ b/example/main.go @@ -13,10 +13,9 @@ func main() { console.NewHandler(os.Stderr, &console.HandlerOptions{ Level: slog.LevelDebug, AddSource: true, - Headers: []string{"logger"}, + HeaderFormat: "%t %l %[logger]12h > %m", TruncateSourcePath: 2, TimeFormat: "15:04:05.000", - HeaderWidth: 15, Theme: console.NewDimTheme(), }), ) diff --git a/handler.go b/handler.go index 2a43f17..64c1df9 100644 --- a/handler.go +++ b/handler.go @@ -53,20 +53,6 @@ type HandlerOptions struct { // See [slog.HandlerOptions] ReplaceAttr func(groups []string, a slog.Attr) slog.Attr - // Headers are a list of attribute keys. These attributes will be removed from - // the trailing attr list, and the values will be inserted between - // the level/source and the message, in the configured order. - Headers []string - - // HeaderWidth controls whether the header fields take up a fixed width in the log line. - // If 0, the full value of all headers are printed, meaning this section of the log line - // will vary in length from one line to the next. - // If >0, headers will be truncated or padded as needed to fit in the specified width. This can - // make busy logs easier to scan, as it ensures that the timestamp, headers, level, and message - // fields are always aligned on the same column. - // The available width will be allocated equally to - HeaderWidth int - // TruncateSourcePath shortens the source file path, if AddSource=true. // If 0, no truncation is done. // If >0, the file path is truncated to that many trailing path segments. @@ -77,9 +63,37 @@ type HandlerOptions struct { // ...etc TruncateSourcePath int + // HeaderFormat specifies the format of the log header. + // + // The default format is "%t %l %[source]h > %m". + // + // The format is a string containing verbs, which are expanded as follows: + // + // %t timestamp + // %l abbreviated level (e.g. "INF") + // %L level (e.g. "INFO") + // %m message + // %[key]h header with the given key. + // + // Headers print the value of the attribute with the given key, and remove that + // attribute from the end of the log line. + // + // Headers can be customized with width, alignment, and non-capturing, + // similar to fmt.Printf verbs. For example: + // + // %[key]10h // left-aligned, width 10 + // %[key]-10h // right-aligned, width 10 + // %[key]+h // non-capturing + // %[key]-10+h // right-aligned, width 10, non-capturing + // + // If the header is non-capturing, the header field will be printed, but + // the attribute will still be available for matching subsequent header fields, + // and/or printing in the attributes section of the log line. HeaderFormat string } +const defaultHeaderFormat = "%t %l %[source]h > %m" + type Handler struct { opts HandlerOptions out io.Writer @@ -87,7 +101,7 @@ type Handler struct { groups []string context, multilineContext buffer fields []any - numHeaders int + headerFields []headerField } type timestampField struct{} @@ -123,18 +137,18 @@ func NewHandler(out io.Writer, opts *HandlerOptions) *Handler { opts.Theme = NewDefaultTheme() } if opts.HeaderFormat == "" { - opts.HeaderFormat = "%t %l %[source]h > %m" // default format + opts.HeaderFormat = defaultHeaderFormat // default format } - fields, numHeaders := parseFormat(opts.HeaderFormat) + fields, headerFields := parseFormat(opts.HeaderFormat) return &Handler{ - opts: *opts, // Copy struct - out: out, - groupPrefix: "", - context: nil, - fields: fields, - numHeaders: numHeaders, + opts: *opts, // Copy struct + out: out, + groupPrefix: "", + context: nil, + fields: fields, + headerFields: headerFields, } } @@ -214,21 +228,17 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { } // todo: make this part of the encoder struct - headers := make([]slog.Attr, h.numHeaders) + headers := make([]slog.Attr, len(h.headerFields)) enc.attrBuf.Append(h.context) enc.multilineAttrBuf.Append(h.multilineContext) rec.Attrs(func(a slog.Attr) bool { - headerIdx := -1 - for _, f := range h.fields { - if f, ok := f.(headerField); ok { - headerIdx++ - if f.key == a.Key { - headers[headerIdx] = a - if f.capture { - return true - } + for i, f := range h.headerFields { + if f.key == a.Key { + headers[i] = a + if f.capture { + return true } } } @@ -283,6 +293,7 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { // WithAttrs implements slog.Handler. func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { + // todo: reuse the encode for memoization attrs, fields := h.memoizeHeaders(attrs) enc := newEncoder(h) @@ -309,7 +320,7 @@ func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { multilineContext: newMultiCtx, groups: h.groups, fields: fields, - numHeaders: h.numHeaders, + headerFields: h.headerFields, } } @@ -321,13 +332,13 @@ func (h *Handler) WithGroup(name string) slog.Handler { groupPrefix = h.groupPrefix + "." + name } return &Handler{ - opts: h.opts, - out: h.out, - groupPrefix: groupPrefix, - context: h.context, - groups: append(h.groups, name), - numHeaders: h.numHeaders, - fields: h.fields, + opts: h.opts, + out: h.out, + groupPrefix: groupPrefix, + context: h.context, + groups: append(h.groups, name), + fields: h.fields, + headerFields: h.headerFields, } } @@ -420,9 +431,9 @@ func (p ParseFormatResult) Equal(other ParseFormatResult) bool { // Use the non-capturing header modifier '+' to disable capturing. If a header is not capturing, the attribute // will still be available for matching subsequent header fields, and will be included in the attributes section // of the log line. -func parseFormat(format string) (fields []any, headerCount int) { +func parseFormat(format string) (fields []any, headerFields []headerField) { fields = make([]any, 0) - headerCount = 0 + headerFields = make([]headerField, 0) for i := 0; i < len(format); i++ { if format[i] != '%' { @@ -506,13 +517,14 @@ func parseFormat(format string) (fields []any, headerCount int) { fields = append(fields, "%!h(MISSING_HEADER_NAME)") continue } - field = headerField{ + hf := headerField{ key: key, width: width, rightAlign: rightAlign, capture: capture, } - headerCount++ + field = hf + headerFields = append(headerFields, hf) case 'm': field = messageField{} case 'l': @@ -530,5 +542,5 @@ func parseFormat(format string) (fields []any, headerCount int) { fields = append(fields, field) } - return fields, headerCount + return fields, headerFields } diff --git a/handler_test.go b/handler_test.go index dd661fe..5c7ff1b 100644 --- a/handler_test.go +++ b/handler_test.go @@ -944,7 +944,7 @@ func TestParseFormat(t *testing.T) { name string format string wantFields []any - wantHeaders int + wantHeaders []headerField }{ { name: "basic format", @@ -956,7 +956,7 @@ func TestParseFormat(t *testing.T) { " ", messageField{}, }, - wantHeaders: 0, + wantHeaders: []headerField{}, }, { name: "with header", @@ -970,7 +970,9 @@ func TestParseFormat(t *testing.T) { " ", messageField{}, }, - wantHeaders: 1, + wantHeaders: []headerField{ + {key: "logger", capture: true}, + }, }, { name: "header with width", @@ -984,7 +986,9 @@ func TestParseFormat(t *testing.T) { " ", messageField{}, }, - wantHeaders: 1, + wantHeaders: []headerField{ + {key: "logger", width: 5, capture: true}, + }, }, { name: "header with right align", @@ -998,7 +1002,9 @@ func TestParseFormat(t *testing.T) { " ", messageField{}, }, - wantHeaders: 1, + wantHeaders: []headerField{ + {key: "logger", rightAlign: true, capture: true}, + }, }, { name: "header with width and right align", @@ -1012,7 +1018,9 @@ func TestParseFormat(t *testing.T) { " ", messageField{}, }, - wantHeaders: 1, + wantHeaders: []headerField{ + {key: "logger", width: 5, rightAlign: true, capture: true}, + }, }, { name: "non-capturing header", @@ -1026,7 +1034,9 @@ func TestParseFormat(t *testing.T) { " ", messageField{}, }, - wantHeaders: 1, + wantHeaders: []headerField{ + {key: "logger", capture: false}, + }, }, { name: "multiple headers", @@ -1042,7 +1052,10 @@ func TestParseFormat(t *testing.T) { " ", messageField{}, }, - wantHeaders: 2, + wantHeaders: []headerField{ + {key: "logger", capture: true}, + {key: "source", capture: true}, + }, }, { name: "with literal text", @@ -1056,7 +1069,7 @@ func TestParseFormat(t *testing.T) { messageField{}, " suffix", }, - wantHeaders: 0, + wantHeaders: []headerField{}, }, { name: "with escaped percent", @@ -1070,7 +1083,7 @@ func TestParseFormat(t *testing.T) { " ", messageField{}, }, - wantHeaders: 0, + wantHeaders: []headerField{}, }, { name: "with non-abbreviated level", @@ -1082,7 +1095,7 @@ func TestParseFormat(t *testing.T) { " ", messageField{}, }, - wantHeaders: 0, + wantHeaders: []headerField{}, }, { name: "with right-aligned non-abbreviated level", @@ -1094,7 +1107,7 @@ func TestParseFormat(t *testing.T) { " ", messageField{}, }, - wantHeaders: 0, + wantHeaders: []headerField{}, }, { name: "error: missing verb", @@ -1104,7 +1117,7 @@ func TestParseFormat(t *testing.T) { " ", "%!(MISSING_VERB)", }, - wantHeaders: 0, + wantHeaders: []headerField{}, }, { name: "error: missing header name", @@ -1116,7 +1129,7 @@ func TestParseFormat(t *testing.T) { " ", messageField{}, }, - wantHeaders: 0, + wantHeaders: []headerField{}, }, { name: "error: missing closing bracket", @@ -1128,7 +1141,7 @@ func TestParseFormat(t *testing.T) { " ", messageField{}, }, - wantHeaders: 0, + wantHeaders: []headerField{}, }, { name: "error: invalid verb", @@ -1140,7 +1153,7 @@ func TestParseFormat(t *testing.T) { " ", messageField{}, }, - wantHeaders: 0, + wantHeaders: []headerField{}, }, { name: "with extra whitespace", @@ -1154,15 +1167,17 @@ func TestParseFormat(t *testing.T) { " ", messageField{}, }, - wantHeaders: 1, + wantHeaders: []headerField{ + {key: "logger", capture: true}, + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotFields, gotHeaders := parseFormat(tt.format) - if gotHeaders != tt.wantHeaders { - t.Errorf("parseFormat() header count = %v, want %v", gotHeaders, tt.wantHeaders) + if !reflect.DeepEqual(gotHeaders, tt.wantHeaders) { + t.Errorf("parseFormat() headers =\n%#v\nwant:\n%#v", gotHeaders, tt.wantHeaders) } if !reflect.DeepEqual(gotFields, tt.wantFields) { t.Errorf("parseFormat() fields =\n%#v\nwant:\n%#v", gotFields, tt.wantFields) From 5144273bcee2a41e866c2c3ccdd3800d540d5218 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Wed, 12 Feb 2025 09:50:13 -0600 Subject: [PATCH 19/44] Eliminate the last memory allocation --- encoding.go | 51 +++------------------------------------------------ handler.go | 17 +++++++++++------ 2 files changed, 14 insertions(+), 54 deletions(-) diff --git a/encoding.go b/encoding.go index d5ec36e..c9d11c7 100644 --- a/encoding.go +++ b/encoding.go @@ -16,6 +16,7 @@ var encoderPool = &sync.Pool{ e.buf = make(buffer, 0, 1024) e.attrBuf = make(buffer, 0, 1024) e.multilineAttrBuf = make(buffer, 0, 1024) + e.headerAttrs = make([]slog.Attr, 0, 5) return e }, } @@ -24,6 +25,7 @@ type encoder struct { h *Handler buf, attrBuf, multilineAttrBuf buffer groups []string + headerAttrs []slog.Attr } func newEncoder(h *Handler) *encoder { @@ -44,6 +46,7 @@ func (e *encoder) free() { e.attrBuf.Reset() e.multilineAttrBuf.Reset() e.groups = e.groups[:0] + e.headerAttrs = e.headerAttrs[:0] encoderPool.Put(e) } @@ -73,36 +76,6 @@ func (e *encoder) writeColoredString(w *buffer, s string, c ANSIMod) { }) } -func (e *encoder) writeColoredInt(w *buffer, i int64, c ANSIMod) { - e.withColor(w, c, func() { - w.AppendInt(i) - }) -} - -func (e *encoder) writeColoredUint(w *buffer, i uint64, c ANSIMod) { - e.withColor(w, c, func() { - w.AppendUint(i) - }) -} - -func (e *encoder) writeColoredFloat(w *buffer, i float64, c ANSIMod) { - e.withColor(w, c, func() { - w.AppendFloat(i) - }) -} - -func (e *encoder) writeColoredBool(w *buffer, b bool, c ANSIMod) { - e.withColor(w, c, func() { - w.AppendBool(b) - }) -} - -func (e *encoder) writeColoredDuration(w *buffer, d time.Duration, c ANSIMod) { - e.withColor(w, c, func() { - w.AppendDuration(d) - }) -} - func (e *encoder) writeTimestamp(buf *buffer, tt time.Time) { if tt.IsZero() { // elide, and skip ReplaceAttr @@ -157,20 +130,6 @@ func (e *encoder) writeMessage(buf *buffer, level slog.Level, msg string) { e.writeColoredString(buf, msg, style) } -// func (e encoder) writeHeaders(buf *buffer, headers []slog.Attr) { -// for _, a := range headers { -// if a.Value.Kind() != slog.KindGroup && e.h.opts.ReplaceAttr != nil { -// a = e.h.opts.ReplaceAttr(nil, a) -// a.Value = a.Value.Resolve() -// } -// if a.Value.Equal(slog.Value{}) { -// continue -// } -// e.writeColoredValue(buf, a.Value, e.h.opts.Theme.Source()) -// buf.AppendByte(' ') -// } -// } - func (e encoder) writeHeader(buf *buffer, a slog.Attr, width int, rightAlign bool) { if a.Value.Kind() != slog.KindGroup && e.h.opts.ReplaceAttr != nil { a = e.h.opts.ReplaceAttr(nil, a) @@ -218,10 +177,6 @@ func (e encoder) writeHeader(buf *buffer, a slog.Attr, width int, rightAlign boo }) } -func (e encoder) writeHeaderSeparator(buf *buffer) { - e.writeColoredString(buf, "> ", e.h.opts.Theme.AttrKey()) -} - func (e *encoder) writeAttr(buf *buffer, a slog.Attr, group string) { a.Value = a.Value.Resolve() if a.Value.Kind() != slog.KindGroup && e.h.opts.ReplaceAttr != nil { diff --git a/handler.go b/handler.go index 64c1df9..103aa9c 100644 --- a/handler.go +++ b/handler.go @@ -8,6 +8,7 @@ import ( "log/slog" "os" "runtime" + "slices" "strings" "time" ) @@ -227,8 +228,8 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { rec.AddAttrs(slog.Any(slog.SourceKey, &src)) } - // todo: make this part of the encoder struct - headers := make([]slog.Attr, len(h.headerFields)) + headerAttrs := slices.Grow(enc.headerAttrs, len(h.headerFields))[:len(h.headerFields)] + clear(headerAttrs) enc.attrBuf.Append(h.context) enc.multilineAttrBuf.Append(h.multilineContext) @@ -236,7 +237,7 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { rec.Attrs(func(a slog.Attr) bool { for i, f := range h.headerFields { if f.key == a.Key { - headers[i] = a + headerAttrs[i] = a if f.capture { return true } @@ -253,10 +254,10 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { for _, f := range h.fields { switch f := f.(type) { case headerField: - if headers[headerIdx].Equal(slog.Attr{}) && f.memo != "" { + if headerAttrs[headerIdx].Equal(slog.Attr{}) && f.memo != "" { enc.buf.AppendString(f.memo) } else { - enc.writeHeader(&enc.buf, headers[headerIdx], f.width, f.rightAlign) + enc.writeHeader(&enc.buf, headerAttrs[headerIdx], f.width, f.rightAlign) } headerIdx++ case levelField: @@ -266,8 +267,12 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { case timestampField: enc.writeTimestamp(&enc.buf, rec.Time) case string: + // todo: need to color these strings + // todo: can we generalize this to some form of grouping? if swallow { - f = strings.TrimPrefix(f, " ") + if len(f) > 0 && f[0] == ' ' { + f = f[1:] + } } enc.buf.AppendString(f) l = 0 // ensure the next field is not swallowed From c46037f26a936a65d08466e7355b242d401f1637 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Wed, 12 Feb 2025 10:15:09 -0600 Subject: [PATCH 20/44] Establish a pattern for encoder methods Encoder methods beginning with "encode" write things to known buffers in the encoder, and do not take a buffer as an arg. "write" methods are lower level, and always take a buffer as an argument. --- encoding.go | 299 ++++++++++++++++++++++++++++++---------------------- handler.go | 88 +++------------- 2 files changed, 189 insertions(+), 198 deletions(-) diff --git a/encoding.go b/encoding.go index c9d11c7..40d1cd3 100644 --- a/encoding.go +++ b/encoding.go @@ -1,6 +1,7 @@ package console import ( + "bytes" "fmt" "log/slog" "path/filepath" @@ -50,33 +51,7 @@ func (e *encoder) free() { encoderPool.Put(e) } -func (e *encoder) NewLine(buf *buffer) { - buf.AppendByte('\n') -} - -func (e *encoder) withColor(b *buffer, c ANSIMod, f func()) { - if c == "" || e.h.opts.NoColor { - f() - return - } - b.AppendString(string(c)) - f() - b.AppendString(string(ResetMod)) -} - -func (e *encoder) writeColoredTime(w *buffer, t time.Time, format string, c ANSIMod) { - e.withColor(w, c, func() { - w.AppendTime(t, format) - }) -} - -func (e *encoder) writeColoredString(w *buffer, s string, c ANSIMod) { - e.withColor(w, c, func() { - w.AppendString(s) - }) -} - -func (e *encoder) writeTimestamp(buf *buffer, tt time.Time) { +func (e *encoder) encodeTimestamp(tt time.Time) { if tt.IsZero() { // elide, and skip ReplaceAttr return @@ -94,7 +69,7 @@ func (e *encoder) writeTimestamp(buf *buffer, tt time.Time) { if attr.Value.Kind() != slog.KindTime { // handle all non-time values by printing them like // an attr value - e.writeColoredValue(buf, attr.Value, e.h.opts.Theme.Timestamp()) + e.writeColoredValue(&e.buf, attr.Value, e.h.opts.Theme.Timestamp()) return } @@ -106,10 +81,12 @@ func (e *encoder) writeTimestamp(buf *buffer, tt time.Time) { } } - e.writeColoredTime(buf, tt, e.h.opts.TimeFormat, e.h.opts.Theme.Timestamp()) + e.withColor(&e.buf, e.h.opts.Theme.Timestamp(), func() { + e.buf.AppendTime(tt, e.h.opts.TimeFormat) + }) } -func (e *encoder) writeMessage(buf *buffer, level slog.Level, msg string) { +func (e *encoder) encodeMessage(level slog.Level, msg string) { style := e.h.opts.Theme.Message() if level < slog.LevelInfo { style = e.h.opts.Theme.MessageDebug() @@ -123,14 +100,14 @@ func (e *encoder) writeMessage(buf *buffer, level slog.Level, msg string) { return } - e.writeColoredValue(buf, attr.Value, style) + e.writeColoredValue(&e.buf, attr.Value, style) return } - e.writeColoredString(buf, msg, style) + e.writeColoredString(&e.buf, msg, style) } -func (e encoder) writeHeader(buf *buffer, a slog.Attr, width int, rightAlign bool) { +func (e *encoder) encodeHeader(a slog.Attr, width int, rightAlign bool) { if a.Value.Kind() != slog.KindGroup && e.h.opts.ReplaceAttr != nil { a = e.h.opts.ReplaceAttr(nil, a) a.Value = a.Value.Resolve() @@ -138,42 +115,192 @@ func (e encoder) writeHeader(buf *buffer, a slog.Attr, width int, rightAlign boo if a.Value.Equal(slog.Value{}) { // just pad as needed if width > 0 { - buf.Pad(width, ' ') + e.buf.Pad(width, ' ') } return } - e.withColor(buf, e.h.opts.Theme.Source(), func() { - l := buf.Len() - e.writeValue(buf, a.Value) + e.withColor(&e.buf, e.h.opts.Theme.Source(), func() { + l := e.buf.Len() + e.writeValue(&e.buf, a.Value) if width <= 0 { return } // truncate or pad to required width - remainingWidth := l + width - buf.Len() + remainingWidth := l + width - e.buf.Len() if remainingWidth < 0 { // truncate - buf.Truncate(l + width) + e.buf.Truncate(l + width) } else if remainingWidth > 0 { if rightAlign { // For right alignment, shift the text right in-place: // 1. Get the text length - textLen := buf.Len() - l + textLen := e.buf.Len() - l // 2. Add padding to reach final width - buf.Pad(remainingWidth, ' ') + e.buf.Pad(remainingWidth, ' ') // 3. Move the text to the right by copying from end to start for i := 0; i < textLen; i++ { - (*buf)[buf.Len()-1-i] = (*buf)[l+textLen-1-i] + e.buf[e.buf.Len()-1-i] = e.buf[l+textLen-1-i] } // 4. Fill the left side with spaces for i := 0; i < remainingWidth; i++ { - (*buf)[l+i] = ' ' + e.buf[l+i] = ' ' } } else { // Left align - just pad with spaces - buf.Pad(remainingWidth, ' ') + e.buf.Pad(remainingWidth, ' ') + } + } + }) +} + +func (e *encoder) encodeLevel(l slog.Level, abbreviated bool) { + var val slog.Value + var writeVal bool + + if e.h.opts.ReplaceAttr != nil { + attr := e.h.opts.ReplaceAttr(nil, slog.Any(slog.LevelKey, l)) + attr.Value = attr.Value.Resolve() + + if attr.Value.Equal(slog.Value{}) { + // elide + return + } + + val = attr.Value + writeVal = true + + if val.Kind() == slog.KindAny { + if ll, ok := val.Any().(slog.Level); ok { + // generally, we'll write the returned value, except in one + // case: when the resolved value is itself a slog.Level + l = ll + writeVal = false } } + } + + var style ANSIMod + var str string + var delta int + switch { + case l >= slog.LevelError: + style = e.h.opts.Theme.LevelError() + str = "ERR" + if !abbreviated { + str = "ERROR" + } + delta = int(l - slog.LevelError) + case l >= slog.LevelWarn: + style = e.h.opts.Theme.LevelWarn() + str = "WRN" + if !abbreviated { + str = "WARN" + } + delta = int(l - slog.LevelWarn) + case l >= slog.LevelInfo: + style = e.h.opts.Theme.LevelInfo() + str = "INF" + if !abbreviated { + str = "INFO" + } + delta = int(l - slog.LevelInfo) + case l >= slog.LevelDebug: + style = e.h.opts.Theme.LevelDebug() + str = "DBG" + if !abbreviated { + str = "DEBUG" + } + delta = int(l - slog.LevelDebug) + default: + style = e.h.opts.Theme.LevelDebug() + str = "DBG" + if !abbreviated { + str = "DEBUG" + } + delta = int(l - slog.LevelDebug) + } + if writeVal { + e.writeColoredValue(&e.buf, val, style) + } else { + if delta != 0 { + str = fmt.Sprintf("%s%+d", str, delta) + } + e.writeColoredString(&e.buf, str, style) + } +} + +func (e *encoder) encodeAttr(groupPrefix string, a slog.Attr) { + offset := e.attrBuf.Len() + e.writeAttr(&e.attrBuf, a, groupPrefix) + + // check if the last attr written has newlines in it + // if so, move it to the trailerBuf + lastAttr := e.attrBuf[offset:] + if bytes.IndexByte(lastAttr, '\n') >= 0 { + // todo: consider splitting the key and the value + // components, so the `key=` can be printed on its + // own line, and the value will not share any of its + // lines with anything else. Like: + // + // INF msg key1=val1 + // key2= + // val2 line 1 + // val2 line 2 + // key3= + // val3 line 1 + // val3 line 2 + // + // and maybe consider printing the key for these values + // differently, like: + // + // === key2 === + // val2 line1 + // val2 line2 + // === key3 === + // val3 line 1 + // val3 line 2 + // + // Splitting the key and value doesn't work up here in + // Handle() though, because we don't know where the term + // control characters are. Would need to push this + // multiline handling deeper into encoder, or pass + // offsets back up from writeAttr() + // + // if k, v, ok := bytes.Cut(lastAttr, []byte("=")); ok { + // trailerBuf.AppendString("=== ") + // trailerBuf.Append(k[1:]) + // trailerBuf.AppendString(" ===\n") + // trailerBuf.AppendByte('=') + // trailerBuf.AppendByte('\n') + // trailerBuf.AppendString("---------------------\n") + // trailerBuf.Append(v) + // trailerBuf.AppendString("\n---------------------\n") + // trailerBuf.AppendByte('\n') + // } else { + // trailerBuf.Append(lastAttr[1:]) + // trailerBuf.AppendByte('\n') + // } + e.multilineAttrBuf.Append(lastAttr) + + // rewind the middle buffer + e.attrBuf = e.attrBuf[:offset] + } +} + +func (e *encoder) withColor(b *buffer, c ANSIMod, f func()) { + if c == "" || e.h.opts.NoColor { + f() + return + } + b.AppendString(string(c)) + f() + b.AppendString(string(ResetMod)) +} + +func (e *encoder) writeColoredString(w *buffer, s string, c ANSIMod) { + e.withColor(w, c, func() { + w.AppendString(s) }) } @@ -210,11 +337,11 @@ func (e *encoder) writeAttr(buf *buffer, a slog.Attr, group string) { buf.AppendByte(' ') e.withColor(buf, e.h.opts.Theme.AttrKey(), func() { if group != "" { - buf.AppendString(group) - buf.AppendByte('.') + e.attrBuf.AppendString(group) + e.attrBuf.AppendByte('.') } - buf.AppendString(a.Key) - buf.AppendByte('=') + e.attrBuf.AppendString(a.Key) + e.attrBuf.AppendByte('=') }) style := e.h.opts.Theme.AttrValue() @@ -272,82 +399,6 @@ func (e *encoder) writeColoredValue(buf *buffer, value slog.Value, style ANSIMod }) } -func (e *encoder) writeLevel(buf *buffer, l slog.Level, abbreviated bool) { - var val slog.Value - var writeVal bool - - if e.h.opts.ReplaceAttr != nil { - attr := e.h.opts.ReplaceAttr(nil, slog.Any(slog.LevelKey, l)) - attr.Value = attr.Value.Resolve() - - if attr.Value.Equal(slog.Value{}) { - // elide - return - } - - val = attr.Value - writeVal = true - - if val.Kind() == slog.KindAny { - if ll, ok := val.Any().(slog.Level); ok { - // generally, we'll write the returned value, except in one - // case: when the resolved value is itself a slog.Level - l = ll - writeVal = false - } - } - } - - var style ANSIMod - var str string - var delta int - switch { - case l >= slog.LevelError: - style = e.h.opts.Theme.LevelError() - str = "ERR" - if !abbreviated { - str = "ERROR" - } - delta = int(l - slog.LevelError) - case l >= slog.LevelWarn: - style = e.h.opts.Theme.LevelWarn() - str = "WRN" - if !abbreviated { - str = "WARN" - } - delta = int(l - slog.LevelWarn) - case l >= slog.LevelInfo: - style = e.h.opts.Theme.LevelInfo() - str = "INF" - if !abbreviated { - str = "INFO" - } - delta = int(l - slog.LevelInfo) - case l >= slog.LevelDebug: - style = e.h.opts.Theme.LevelDebug() - str = "DBG" - if !abbreviated { - str = "DEBUG" - } - delta = int(l - slog.LevelDebug) - default: - style = e.h.opts.Theme.LevelDebug() - str = "DBG" - if !abbreviated { - str = "DEBUG" - } - delta = int(l - slog.LevelDebug) - } - if writeVal { - e.writeColoredValue(buf, val, style) - } else { - if delta != 0 { - str = fmt.Sprintf("%s%+d", str, delta) - } - e.writeColoredString(buf, str, style) - } -} - func trimmedPath(path string, cwd string, truncate int) string { // if the file path appears to be under the current // working directory, then we're probably running diff --git a/handler.go b/handler.go index 103aa9c..7e4aa28 100644 --- a/handler.go +++ b/handler.go @@ -1,7 +1,6 @@ package console import ( - "bytes" "context" "fmt" "io" @@ -158,64 +157,6 @@ func (h *Handler) Enabled(_ context.Context, l slog.Level) bool { return l >= h.opts.Level.Level() } -func (e *encoder) encodeAttr(groupPrefix string, a slog.Attr) { - offset := e.attrBuf.Len() - e.writeAttr(&e.attrBuf, a, groupPrefix) - - // check if the last attr written has newlines in it - // if so, move it to the trailerBuf - lastAttr := e.attrBuf[offset:] - if bytes.IndexByte(lastAttr, '\n') >= 0 { - // todo: consider splitting the key and the value - // components, so the `key=` can be printed on its - // own line, and the value will not share any of its - // lines with anything else. Like: - // - // INF msg key1=val1 - // key2= - // val2 line 1 - // val2 line 2 - // key3= - // val3 line 1 - // val3 line 2 - // - // and maybe consider printing the key for these values - // differently, like: - // - // === key2 === - // val2 line1 - // val2 line2 - // === key3 === - // val3 line 1 - // val3 line 2 - // - // Splitting the key and value doesn't work up here in - // Handle() though, because we don't know where the term - // control characters are. Would need to push this - // multiline handling deeper into encoder, or pass - // offsets back up from writeAttr() - // - // if k, v, ok := bytes.Cut(lastAttr, []byte("=")); ok { - // trailerBuf.AppendString("=== ") - // trailerBuf.Append(k[1:]) - // trailerBuf.AppendString(" ===\n") - // trailerBuf.AppendByte('=') - // trailerBuf.AppendByte('\n') - // trailerBuf.AppendString("---------------------\n") - // trailerBuf.Append(v) - // trailerBuf.AppendString("\n---------------------\n") - // trailerBuf.AppendByte('\n') - // } else { - // trailerBuf.Append(lastAttr[1:]) - // trailerBuf.AppendByte('\n') - // } - e.multilineAttrBuf.Append(lastAttr) - - // rewind the middle buffer - e.attrBuf = e.attrBuf[:offset] - } -} - func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { enc := newEncoder(h) @@ -257,15 +198,15 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { if headerAttrs[headerIdx].Equal(slog.Attr{}) && f.memo != "" { enc.buf.AppendString(f.memo) } else { - enc.writeHeader(&enc.buf, headerAttrs[headerIdx], f.width, f.rightAlign) + enc.encodeHeader(headerAttrs[headerIdx], f.width, f.rightAlign) } headerIdx++ case levelField: - enc.writeLevel(&enc.buf, rec.Level, f.abbreviated) + enc.encodeLevel(rec.Level, f.abbreviated) case messageField: - enc.writeMessage(&enc.buf, rec.Level, rec.Message) + enc.encodeMessage(rec.Level, rec.Message) case timestampField: - enc.writeTimestamp(&enc.buf, rec.Time) + enc.encodeTimestamp(rec.Time) case string: // todo: need to color these strings // todo: can we generalize this to some form of grouping? @@ -286,7 +227,7 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { // log line is written in a single Write call enc.buf.copy(&enc.attrBuf) enc.buf.copy(&enc.multilineAttrBuf) - enc.NewLine(&enc.buf) + enc.buf.AppendByte('\n') if _, err := enc.buf.WriteTo(h.out); err != nil { return err @@ -298,10 +239,10 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { // WithAttrs implements slog.Handler. func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { - // todo: reuse the encode for memoization - attrs, fields := h.memoizeHeaders(attrs) - enc := newEncoder(h) + + attrs, fields := h.memoizeHeaders(enc, attrs) + for _, a := range attrs { enc.encodeAttr(h.groupPrefix, a) } @@ -317,6 +258,8 @@ func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { newMultiCtx.Clip() } + enc.free() + return &Handler{ opts: h.opts, out: h.out, @@ -347,10 +290,7 @@ func (h *Handler) WithGroup(name string) slog.Handler { } } -func (h *Handler) memoizeHeaders(attrs []slog.Attr) ([]slog.Attr, []any) { - enc := newEncoder(h) - defer enc.free() - buf := &enc.buf +func (h *Handler) memoizeHeaders(enc *encoder, attrs []slog.Attr) ([]slog.Attr, []any) { newFields := make([]any, len(h.fields)) copy(newFields, h.fields) remainingAttrs := make([]slog.Attr, 0, len(attrs)) @@ -360,9 +300,9 @@ func (h *Handler) memoizeHeaders(attrs []slog.Attr) ([]slog.Attr, []any) { for i, field := range h.fields { if headerField, ok := field.(headerField); ok { if headerField.key == attr.Key { - buf.Reset() - enc.writeHeader(buf, attr, headerField.width, headerField.rightAlign) - headerField.memo = buf.String() + enc.buf.Reset() + enc.encodeHeader(attr, headerField.width, headerField.rightAlign) + headerField.memo = enc.buf.String() newFields[i] = headerField if headerField.capture { capture = true From c46222791ee1dd95fd967629e67157b0f7090d0c Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Wed, 12 Feb 2025 15:33:31 -0600 Subject: [PATCH 21/44] Header capturing works with attrs in groups - the header field now has to reference the full path to the attr, like "group1.group2.attrKey" - header attributes are captured after being resolved, and after handling groups - header attribute capturing is now handled entirely inside the encoder - the "source" attr is now always added without a group --- encoding.go | 73 +++++++++------- handler.go | 85 ++++++++---------- handler_test.go | 228 +++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 292 insertions(+), 94 deletions(-) diff --git a/encoding.go b/encoding.go index 40d1cd3..d0622f8 100644 --- a/encoding.go +++ b/encoding.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "path/filepath" + "slices" "strings" "sync" "time" @@ -35,6 +36,8 @@ func newEncoder(h *Handler) *encoder { if h.opts.ReplaceAttr != nil { e.groups = append(e.groups, h.groups...) } + e.headerAttrs = slices.Grow(e.headerAttrs, len(h.headerFields))[:len(h.headerFields)] + clear(e.headerAttrs) return e } @@ -108,10 +111,6 @@ func (e *encoder) encodeMessage(level slog.Level, msg string) { } func (e *encoder) encodeHeader(a slog.Attr, width int, rightAlign bool) { - if a.Value.Kind() != slog.KindGroup && e.h.opts.ReplaceAttr != nil { - a = e.h.opts.ReplaceAttr(nil, a) - a.Value = a.Value.Resolve() - } if a.Value.Equal(slog.Value{}) { // just pad as needed if width > 0 { @@ -231,6 +230,45 @@ func (e *encoder) encodeLevel(l slog.Level, abbreviated bool) { } func (e *encoder) encodeAttr(groupPrefix string, a slog.Attr) { + + a.Value = a.Value.Resolve() + if a.Value.Kind() != slog.KindGroup && e.h.opts.ReplaceAttr != nil { + a = e.h.opts.ReplaceAttr(e.groups, a) + a.Value = a.Value.Resolve() + } + // Elide empty Attrs. + if a.Equal(slog.Attr{}) { + return + } + + value := a.Value + + if value.Kind() == slog.KindGroup { + subgroup := a.Key + if groupPrefix != "" { + subgroup = groupPrefix + "." + a.Key + } + if e.h.opts.ReplaceAttr != nil { + e.groups = append(e.groups, a.Key) + } + for _, attr := range value.Group() { + e.encodeAttr(subgroup, attr) + } + if e.h.opts.ReplaceAttr != nil { + e.groups = e.groups[:len(e.groups)-1] + } + return + } + + for i, f := range e.h.headerFields { + if f.key == a.Key && f.groupPrefix == groupPrefix { + e.headerAttrs[i] = a + if f.capture { + return + } + } + } + offset := e.attrBuf.Len() e.writeAttr(&e.attrBuf, a, groupPrefix) @@ -305,35 +343,8 @@ func (e *encoder) writeColoredString(w *buffer, s string, c ANSIMod) { } func (e *encoder) writeAttr(buf *buffer, a slog.Attr, group string) { - a.Value = a.Value.Resolve() - if a.Value.Kind() != slog.KindGroup && e.h.opts.ReplaceAttr != nil { - a = e.h.opts.ReplaceAttr(e.groups, a) - a.Value = a.Value.Resolve() - } - // Elide empty Attrs. - if a.Equal(slog.Attr{}) { - return - } - value := a.Value - if value.Kind() == slog.KindGroup { - subgroup := a.Key - if group != "" { - subgroup = group + "." + a.Key - } - if e.h.opts.ReplaceAttr != nil { - e.groups = append(e.groups, a.Key) - } - for _, attr := range value.Group() { - e.writeAttr(buf, attr, subgroup) - } - if e.h.opts.ReplaceAttr != nil { - e.groups = e.groups[:len(e.groups)-1] - } - return - } - buf.AppendByte(' ') e.withColor(buf, e.h.opts.Theme.AttrKey(), func() { if group != "" { diff --git a/handler.go b/handler.go index 7e4aa28..42ad38e 100644 --- a/handler.go +++ b/handler.go @@ -7,7 +7,6 @@ import ( "log/slog" "os" "runtime" - "slices" "strings" "time" ) @@ -106,12 +105,14 @@ type Handler struct { type timestampField struct{} type headerField struct { - key string - width int - rightAlign bool - capture bool - memo string + groupPrefix string + key string + width int + rightAlign bool + capture bool + memo string } + type levelField struct { abbreviated bool rightAlign bool @@ -166,25 +167,18 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { src.Function = frame.Function src.File = frame.File src.Line = frame.Line - rec.AddAttrs(slog.Any(slog.SourceKey, &src)) + // the source attr should not be inside any open groups + groups := enc.groups + enc.groups = nil + enc.encodeAttr("", slog.Any(slog.SourceKey, &src)) + enc.groups = groups + // rec.AddAttrs(slog.Any(slog.SourceKey, &src)) } - headerAttrs := slices.Grow(enc.headerAttrs, len(h.headerFields))[:len(h.headerFields)] - clear(headerAttrs) - enc.attrBuf.Append(h.context) enc.multilineAttrBuf.Append(h.multilineContext) rec.Attrs(func(a slog.Attr) bool { - for i, f := range h.headerFields { - if f.key == a.Key { - headerAttrs[i] = a - if f.capture { - return true - } - } - } - enc.encodeAttr(h.groupPrefix, a) return true }) @@ -195,10 +189,11 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { for _, f := range h.fields { switch f := f.(type) { case headerField: - if headerAttrs[headerIdx].Equal(slog.Attr{}) && f.memo != "" { - enc.buf.AppendString(f.memo) + hf := h.headerFields[headerIdx] + if enc.headerAttrs[headerIdx].Equal(slog.Attr{}) && hf.memo != "" { + enc.buf.AppendString(hf.memo) } else { - enc.encodeHeader(headerAttrs[headerIdx], f.width, f.rightAlign) + enc.encodeHeader(enc.headerAttrs[headerIdx], hf.width, hf.rightAlign) } headerIdx++ case levelField: @@ -241,12 +236,12 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { enc := newEncoder(h) - attrs, fields := h.memoizeHeaders(enc, attrs) - for _, a := range attrs { enc.encodeAttr(h.groupPrefix, a) } + headerFields := memoizeHeaders(enc, h.headerFields) + newCtx := h.context newMultiCtx := h.multilineContext if len(enc.attrBuf) > 0 { @@ -267,8 +262,8 @@ func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { context: newCtx, multilineContext: newMultiCtx, groups: h.groups, - fields: fields, - headerFields: h.headerFields, + fields: h.fields, + headerFields: headerFields, } } @@ -290,32 +285,18 @@ func (h *Handler) WithGroup(name string) slog.Handler { } } -func (h *Handler) memoizeHeaders(enc *encoder, attrs []slog.Attr) ([]slog.Attr, []any) { - newFields := make([]any, len(h.fields)) - copy(newFields, h.fields) - remainingAttrs := make([]slog.Attr, 0, len(attrs)) - - for _, attr := range attrs { - capture := false - for i, field := range h.fields { - if headerField, ok := field.(headerField); ok { - if headerField.key == attr.Key { - enc.buf.Reset() - enc.encodeHeader(attr, headerField.width, headerField.rightAlign) - headerField.memo = enc.buf.String() - newFields[i] = headerField - if headerField.capture { - capture = true - } - // don't break, in case there are multiple headers with the same key - } - } - } - if !capture { - remainingAttrs = append(remainingAttrs, attr) +func memoizeHeaders(enc *encoder, headerFields []headerField) []headerField { + newFields := make([]headerField, len(headerFields)) + copy(newFields, headerFields) + + for i := range newFields { + if !enc.headerAttrs[i].Equal(slog.Attr{}) { + enc.buf.Reset() + enc.encodeHeader(enc.headerAttrs[i], newFields[i].width, newFields[i].rightAlign) + newFields[i].memo = enc.buf.String() } } - return remainingAttrs, newFields + return newFields } // ParseFormatResult contains the parsed fields and header count from a format string @@ -468,6 +449,10 @@ func parseFormat(format string) (fields []any, headerFields []headerField) { rightAlign: rightAlign, capture: capture, } + if idx := strings.LastIndexByte(key, '.'); idx > -1 { + hf.groupPrefix = key[:idx] + hf.key = key[idx+1:] + } field = hf headerFields = append(headerFields, hf) case 'm': diff --git a/handler_test.go b/handler_test.go index 5c7ff1b..1c89393 100644 --- a/handler_test.go +++ b/handler_test.go @@ -16,16 +16,55 @@ import ( ) func TestHandler_TimeFormat(t *testing.T) { - buf := bytes.Buffer{} - h := NewHandler(&buf, &HandlerOptions{TimeFormat: time.RFC3339Nano, NoColor: true}) - now := time.Now() - rec := slog.NewRecord(now, slog.LevelInfo, "foobar", 0) - endTime := now.Add(time.Second) - rec.AddAttrs(slog.Time("endtime", endTime)) - AssertNoError(t, h.Handle(context.Background(), rec)) + testTime := time.Date(2024, 01, 02, 15, 04, 05, 123456789, time.UTC) + tests := []struct { + name string + timeFormat string + wantFormat string + }{ + { + name: "DateTime", + timeFormat: time.DateTime, + wantFormat: "2024-01-02 15:04:05", + }, + { + name: "RFC3339Nano", + timeFormat: time.RFC3339Nano, + wantFormat: "2024-01-02T15:04:05.123456789Z", + }, + { + name: "Kitchen", + timeFormat: time.Kitchen, + wantFormat: "3:04PM", + }, + { + name: "EmptyFormat", + timeFormat: "", // should default to DateTime + wantFormat: "2024-01-02 15:04:05", + }, + { + name: "CustomFormat", + timeFormat: "2006/01/02 15:04:05.000 MST", + wantFormat: "2024/01/02 15:04:05.123 UTC", + }, + } - expected := fmt.Sprintf("%s INF > foobar endtime=%s\n", now.Format(time.RFC3339Nano), endTime.Format(time.RFC3339Nano)) - AssertEqual(t, expected, buf.String()) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := &bytes.Buffer{} + opts := &HandlerOptions{ + TimeFormat: tt.timeFormat, + NoColor: true, + } + h := NewHandler(buf, opts) + rec := slog.NewRecord(testTime, slog.LevelInfo, "test message", 0) + err := h.Handle(context.Background(), rec) + AssertNoError(t, err) + + expected := fmt.Sprintf("%s INF > test message\n", testTime.Format(tt.wantFormat)) + AssertEqual(t, expected, buf.String()) + }) + } } // Handlers should not log the time field if it is zero. @@ -378,6 +417,8 @@ func TestHandler_ReplaceAttr(t *testing.T) { awesomeVal := slog.Any("valuer", valuer{slog.StringValue("awesome")}) + awesomeValuer := valuer{slog.StringValue("awesome")} + tests := []struct { name string replaceAttr func(*testing.T, []string, slog.Attr) slog.Attr @@ -507,17 +548,17 @@ func TestHandler_ReplaceAttr(t *testing.T) { }, { name: "replace source with different kind", - replaceAttr: replaceAttrWith(slog.SourceKey, slog.String("color", "red")), + replaceAttr: replaceAttrWith(slog.SourceKey, slog.String(slog.SourceKey, "red")), want: "2010-05-06 07:08:09 INF red > foobar size=12 color=red\n", }, { name: "replace source with valuer", - replaceAttr: replaceAttrWith(slog.SourceKey, awesomeVal), + replaceAttr: replaceAttrWith(slog.SourceKey, slog.Any(slog.SourceKey, awesomeValuer)), want: "2010-05-06 07:08:09 INF awesome > foobar size=12 color=red\n", }, { name: "replace source with source valuer", - replaceAttr: replaceAttrWith(slog.SourceKey, slog.Any("valuer", valuer{slog.AnyValue(&slog.Source{ + replaceAttr: replaceAttrWith(slog.SourceKey, slog.Any(slog.SourceKey, valuer{slog.AnyValue(&slog.Source{ File: filepath.Join(cwd, "path", "to", "file.go"), Line: 33, })})), @@ -607,6 +648,155 @@ func TestHandler_ReplaceAttr(t *testing.T) { } +func TestHandler_HeaderFormat(t *testing.T) { + pc, file, line, _ := runtime.Caller(0) + cwd, _ := os.Getwd() + file, _ = filepath.Rel(cwd, file) + sourceField := fmt.Sprintf("%s:%d", file, line) + + testTime := time.Date(2024, 01, 02, 15, 04, 05, 123456789, time.UTC) + + tests := []struct { + name string + opts HandlerOptions + attrs []slog.Attr + withAttrs []slog.Attr + withGroups []string + want string + }{ + { + name: "default", + opts: HandlerOptions{AddSource: true, NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "2024-01-02 15:04:05 INF " + sourceField + " > with headers foo=bar\n", + }, + { + name: "one header", + opts: HandlerOptions{HeaderFormat: "%l %[foo]h > %m", NoColor: true}, + attrs: []slog.Attr{ + slog.String("foo", "bar"), + slog.String("bar", "baz"), + }, + want: "INF bar > with headers bar=baz\n", + }, + { + name: "two headers", + opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m", NoColor: true}, + attrs: []slog.Attr{ + slog.String("foo", "bar"), + slog.String("bar", "baz"), + }, + want: "INF bar baz > with headers\n", + }, + { + name: "two headers alt order", + opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m", NoColor: true}, + attrs: []slog.Attr{ + slog.String("bar", "baz"), + slog.String("foo", "bar"), + }, + want: "INF bar baz > with headers\n", + }, + { + name: "missing headers", + opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "INF bar > with headers\n", // missing headers are omitted + }, + { + name: "missing headers, no space", + opts: HandlerOptions{HeaderFormat: "%l%[foo]h%[bar]h>%m", NoColor: true}, // no spaces between headers or level/message + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "INFbar>with headers\n", + }, + { + name: "header without group prefix does not match attr in group", + opts: HandlerOptions{HeaderFormat: "%l %[foo]h > %m", NoColor: true}, // header is an attribute inside a group + attrs: []slog.Attr{slog.String("foo", "bar")}, + withGroups: []string{"group1"}, + want: "INF > with headers group1.foo=bar\n", // header is foo, not group1.foo + }, + { + name: "header with group prefix", + opts: HandlerOptions{HeaderFormat: "%l %[group1.foo]h > %m", NoColor: true}, // header is an attribute inside a group + attrs: []slog.Attr{slog.String("foo", "bar")}, + withGroups: []string{"group1"}, + want: "INF bar > with headers\n", + }, + { + name: "header in nested groups", + opts: HandlerOptions{HeaderFormat: "%l %[group1.group2.foo]h > %m", NoColor: true}, // header is an attribute inside a group + attrs: []slog.Attr{slog.String("foo", "bar")}, + withGroups: []string{"group1", "group2"}, + want: "INF bar > with headers\n", + }, + { + name: "header in group attr, no match", + opts: HandlerOptions{HeaderFormat: "%l %[foo]h > %m", NoColor: true}, // header is an attribute inside a group + attrs: []slog.Attr{slog.Group("group1", slog.String("foo", "bar"))}, + want: "INF > with headers group1.foo=bar\n", + }, + { + name: "header in group attr, match", + opts: HandlerOptions{HeaderFormat: "%l %[group1.foo]h > %m", NoColor: true}, // header is an attribute inside a group + attrs: []slog.Attr{slog.Group("group1", slog.String("foo", "bar"))}, + want: "INF bar > with headers\n", + }, + { + name: "header and withGroup and nested group", + opts: HandlerOptions{HeaderFormat: "%l %[group1.foo]h %[group1.group2.bar]h > %m", NoColor: true}, // header is group2.attr0, attr0 is in root + attrs: []slog.Attr{slog.String("foo", "bar"), slog.Group("group2", slog.String("bar", "baz"))}, + withGroups: []string{"group1"}, + want: "INF bar baz > with headers\n", + }, + { + name: "no header", + opts: HandlerOptions{HeaderFormat: "%l > %m", NoColor: true}, // no header + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "INF > with headers foo=bar\n", + }, + { + name: "just level", + opts: HandlerOptions{HeaderFormat: "%l", NoColor: true}, // no header, no message + want: "INF\n", + }, + { + name: "just message", + opts: HandlerOptions{HeaderFormat: "%m", NoColor: true}, // just message + want: "with headers\n", + }, + // todo: test when the header matches a group attr + // todo: test non-capturing headers + // todo: test an attr matching a header multiple times (non-capturing) + // todo: test repeated fields + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := bytes.Buffer{} + var h slog.Handler = NewHandler(&buf, &tt.opts) + + attrs := tt.attrs + + withAttrs := tt.withAttrs + + rec := slog.NewRecord(testTime, slog.LevelInfo, "with headers", pc) + rec.AddAttrs(attrs...) + + if len(withAttrs) > 0 { + h = h.WithAttrs(withAttrs) + } + + for _, group := range tt.withGroups { + h = h.WithGroup(group) + } + + AssertNoError(t, h.Handle(context.Background(), rec)) + AssertEqual(t, tt.want, buf.String()) + }) + } +} + func TestHandler_Headers(t *testing.T) { pc, file, line, _ := runtime.Caller(0) cwd, _ := os.Getwd() @@ -686,7 +876,7 @@ func TestHandler_Headers(t *testing.T) { }, { name: "withgroup", - opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m"}, + opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[group.bar]h > %m"}, attrs: []slog.Attr{ slog.String("bar", "baz"), slog.String("baz", "foo"), @@ -1171,6 +1361,18 @@ func TestParseFormat(t *testing.T) { {key: "logger", capture: true}, }, }, + { + name: "header with group prefix", + format: "%t %[group.logger]h", + wantFields: []any{ + timestampField{}, + " ", + headerField{groupPrefix: "group", key: "logger", capture: true}, + }, + wantHeaders: []headerField{ + {groupPrefix: "group", key: "logger", capture: true}, + }, + }, } for _, tt := range tests { From 774477c038323ab7c8928e9253277d483138336e Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Wed, 12 Feb 2025 21:14:47 -0600 Subject: [PATCH 22/44] More tests --- handler.go | 6 +- handler_test.go | 1218 +++++++++++++++++++++-------------------------- 2 files changed, 539 insertions(+), 685 deletions(-) diff --git a/handler.go b/handler.go index 42ad38e..72cc540 100644 --- a/handler.go +++ b/handler.go @@ -115,7 +115,6 @@ type headerField struct { type levelField struct { abbreviated bool - rightAlign bool } type messageField struct{} @@ -327,7 +326,7 @@ func (p ParseFormatResult) Equal(other ParseFormatResult) bool { // %h - headerField, requires [name] modifier, supports width, - and + modifiers // %m - messageField // %l - abbreviated levelField -// %L - non-abbreviated levelField, supports - modifier +// %L - non-abbreviated levelField // // Modifiers: // [name]: the key of the attribute to capture as a header, required @@ -412,7 +411,7 @@ func parseFormat(format string) (fields []any, headerFields []headerField) { // Look for modifiers for i < len(format) { - if format[i] == '-' { + if format[i] == '-' && key != "" { // '-' only valid for headers rightAlign = true i++ } else if format[i] == '+' && key != "" { // '+' only valid for headers @@ -462,7 +461,6 @@ func parseFormat(format string) (fields []any, headerFields []headerField) { case 'L': field = levelField{ abbreviated: false, - rightAlign: rightAlign, } default: fields = append(fields, fmt.Sprintf("%%!%c(INVALID_VERB)", format[i])) diff --git a/handler_test.go b/handler_test.go index 1c89393..b4dd585 100644 --- a/handler_test.go +++ b/handler_test.go @@ -11,6 +11,7 @@ import ( "path/filepath" "reflect" "runtime" + "strings" "testing" "time" ) @@ -20,50 +21,55 @@ func TestHandler_TimeFormat(t *testing.T) { tests := []struct { name string timeFormat string - wantFormat string + attrs []slog.Attr + want string }{ { name: "DateTime", timeFormat: time.DateTime, - wantFormat: "2024-01-02 15:04:05", + want: "2024-01-02 15:04:05\n", }, { name: "RFC3339Nano", timeFormat: time.RFC3339Nano, - wantFormat: "2024-01-02T15:04:05.123456789Z", + want: "2024-01-02T15:04:05.123456789Z\n", }, { name: "Kitchen", timeFormat: time.Kitchen, - wantFormat: "3:04PM", + want: "3:04PM\n", }, { name: "EmptyFormat", timeFormat: "", // should default to DateTime - wantFormat: "2024-01-02 15:04:05", + want: "2024-01-02 15:04:05\n", }, { name: "CustomFormat", timeFormat: "2006/01/02 15:04:05.000 MST", - wantFormat: "2024/01/02 15:04:05.123 UTC", + want: "2024/01/02 15:04:05.123 UTC\n", + }, + { + name: "also formats attrs", + timeFormat: time.Kitchen, + attrs: []slog.Attr{slog.Time("foo", time.Date(2025, 01, 02, 5, 03, 05, 22, time.UTC))}, + want: "3:04PM foo=5:03AM\n", }, } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - buf := &bytes.Buffer{} - opts := &HandlerOptions{ - TimeFormat: tt.timeFormat, - NoColor: true, - } - h := NewHandler(buf, opts) - rec := slog.NewRecord(testTime, slog.LevelInfo, "test message", 0) - err := h.Handle(context.Background(), rec) - AssertNoError(t, err) - - expected := fmt.Sprintf("%s INF > test message\n", testTime.Format(tt.wantFormat)) - AssertEqual(t, expected, buf.String()) - }) + ht := &handlerTest{ + name: tt.name, + time: testTime, + opts: HandlerOptions{ + TimeFormat: tt.timeFormat, + NoColor: true, + HeaderFormat: "%t", + }, + attrs: tt.attrs, + want: tt.want, + } + ht.runSubtest(t) } } @@ -71,24 +77,11 @@ func TestHandler_TimeFormat(t *testing.T) { // '- If r.Time is the zero time, ignore the time.' // https://pkg.go.dev/log/slog@master#Handler func TestHandler_TimeZero(t *testing.T) { - buf := bytes.Buffer{} - h := NewHandler(&buf, &HandlerOptions{TimeFormat: time.RFC3339Nano, NoColor: true}) - rec := slog.NewRecord(time.Time{}, slog.LevelInfo, "foobar", 0) - AssertNoError(t, h.Handle(context.Background(), rec)) - - expected := fmt.Sprintf("INF > foobar\n") - AssertEqual(t, expected, buf.String()) -} - -func TestHandler_NoColor(t *testing.T) { - buf := bytes.Buffer{} - h := NewHandler(&buf, &HandlerOptions{NoColor: true}) - now := time.Now() - rec := slog.NewRecord(now, slog.LevelInfo, "foobar", 0) - AssertNoError(t, h.Handle(context.Background(), rec)) - - expected := fmt.Sprintf("%s INF > foobar\n", now.Format(time.DateTime)) - AssertEqual(t, expected, buf.String()) + handlerTest{ + opts: HandlerOptions{TimeFormat: time.RFC3339Nano, NoColor: true}, + msg: "foobar", + want: "INF > foobar\n", + }.run(t) } type theStringer struct{} @@ -125,48 +118,41 @@ func (e *formatterError) Format(f fmt.State, verb rune) { } func TestHandler_Attr(t *testing.T) { - buf := bytes.Buffer{} - h := NewHandler(&buf, &HandlerOptions{NoColor: true}) - now := time.Now() - rec := slog.NewRecord(now, slog.LevelInfo, "foobar", 0) - rec.AddAttrs( - slog.Bool("bool", true), - slog.Int("int", -12), - slog.Uint64("uint", 12), - slog.Float64("float", 3.14), - slog.String("foo", "bar"), - slog.Time("time", now), - slog.Duration("dur", time.Second), - slog.Group("group", slog.String("foo", "bar"), slog.Group("subgroup", slog.String("foo", "bar"))), - slog.Any("err", errors.New("the error")), - slog.Any("formattedError", &formatterError{errors.New("the error")}), - slog.Any("stringer", theStringer{}), - slog.Any("nostringer", noStringer{Foo: "bar"}), - // Resolve LogValuer items in addition to Stringer items. - // '- Attr's values should be resolved.' - // https://pkg.go.dev/log/slog@master#Handler - // https://pkg.go.dev/log/slog@master#LogValuer - slog.Any("valuer", &theValuer{"distant"}), - // Handlers are supposed to avoid logging empty attributes. - // '- If an Attr's key and value are both the zero value, ignore the Attr.' - // https://pkg.go.dev/log/slog@master#Handler - slog.Attr{}, - slog.Any("", nil), - ) - AssertNoError(t, h.Handle(context.Background(), rec)) - - expected := fmt.Sprintf("%s INF > foobar bool=true int=-12 uint=12 float=3.14 foo=bar time=%s dur=1s group.foo=bar group.subgroup.foo=bar err=the error formattedError=formatted the error stringer=stringer nostringer={bar} valuer=The word is 'distant'\n", now.Format(time.DateTime), now.Format(time.DateTime)) - AssertEqual(t, expected, buf.String()) + testTime := time.Date(2024, 01, 02, 15, 04, 05, 123456789, time.UTC) + handlerTest{ + opts: HandlerOptions{NoColor: true}, + msg: "foobar", + time: testTime, + attrs: []slog.Attr{ + slog.Bool("bool", true), + slog.Int("int", -12), + slog.Uint64("uint", 12), + slog.Float64("float", 3.14), + slog.String("foo", "bar"), + slog.Time("time", testTime), + slog.Duration("dur", time.Second), + slog.Group("group", slog.String("foo", "bar"), slog.Group("subgroup", slog.String("foo", "bar"))), + slog.Any("err", errors.New("the error")), + slog.Any("formattedError", &formatterError{errors.New("the error")}), + slog.Any("stringer", theStringer{}), + slog.Any("nostringer", noStringer{Foo: "bar"}), + // Resolve LogValuer items in addition to Stringer items. + // '- Attr's values should be resolved.' + // https://pkg.go.dev/log/slog@master#Handler + // https://pkg.go.dev/log/slog@master#LogValuer + slog.Any("valuer", &theValuer{"distant"}), + // Handlers are supposed to avoid logging empty attributes. + // '- If an Attr's key and value are both the zero value, ignore the Attr.' + // https://pkg.go.dev/log/slog@master#Handler + slog.Attr{}, + slog.Any("", nil), + }, + want: "2024-01-02 15:04:05 INF > foobar bool=true int=-12 uint=12 float=3.14 foo=bar time=2024-01-02 15:04:05 dur=1s group.foo=bar group.subgroup.foo=bar err=the error formattedError=formatted the error stringer=stringer nostringer={bar} valuer=The word is 'distant'\n", + }.run(t) } func TestHandler_AttrsWithNewlines(t *testing.T) { - tests := []struct { - name string - msg string - escapeNewlines bool - attrs []slog.Attr - want string - }{ + tests := []handlerTest{ { name: "single attr", attrs: []slog.Attr{ @@ -205,191 +191,269 @@ func TestHandler_AttrsWithNewlines(t *testing.T) { }, want: "INF > multiline attrs foo=\nline one\nline two\n\n", }, - // todo: test multiline attr using WithAttrs + { + name: "multiline attr using WithAttrs", + attrs: []slog.Attr{ + slog.String("foo", "line one\nline two"), + }, + want: "INF > multiline attrs foo=line one\nline two\n", + }, + { + name: "multiline header value", + opts: HandlerOptions{NoColor: true, HeaderFormat: "%l %[foo]h > %m"}, + attrs: []slog.Attr{ + slog.String("foo", "line one\nline two"), + }, + want: "INF line one\nline two > multiline attrs\n", + }, } for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - buf := bytes.Buffer{} - h := NewHandler(&buf, &HandlerOptions{NoColor: true}) - - msg := test.msg - if msg == "" { - msg = "multiline attrs" - } - rec := slog.NewRecord(time.Time{}, slog.LevelInfo, msg, 0) - rec.AddAttrs(test.attrs...) - AssertNoError(t, h.Handle(context.Background(), rec)) + if test.msg == "" { + test.msg = "multiline attrs" + } + test.opts.NoColor = true + test.runSubtest(t) + } +} - AssertEqual(t, test.want, buf.String()) - }) +func TestHandler_Groups(t *testing.T) { + tests := []handlerTest{ + { + name: "single group", + attrs: []slog.Attr{ + slog.Group("group", slog.String("foo", "bar")), + }, + want: "INF > single group group.foo=bar\n", + }, + { + // '- If a group has no Attrs (even if it has a non-empty key), ignore it.' + // https://pkg.go.dev/log/slog@master#Handler + name: "empty groups should be elided", + attrs: []slog.Attr{ + slog.Group("group", slog.String("foo", "bar")), + slog.Group("empty"), + }, + want: "INF > empty groups should be elided group.foo=bar\n", + }, + { + // Handlers should expand groups named "" (the empty string) into the enclosing log record. + // '- If a group's key is empty, inline the group's Attrs.' + // https://pkg.go.dev/log/slog@master#Handler + name: "inline group", + attrs: []slog.Attr{ + slog.Group("group", slog.String("foo", "bar")), + slog.Group("", slog.String("foo", "bar")), + }, + want: "INF > inline group group.foo=bar foo=bar\n", + }, + { + // A Handler should call Resolve on attribute values in groups. + // https://cs.opensource.google/go/x/exp/+/0dcbfd60:slog/slogtest/slogtest.go + name: "groups with valuer members", + attrs: []slog.Attr{ + slog.Group("group", "stringer", theStringer{}, "valuer", &theValuer{"surreal"}), + }, + want: "INF > groups with valuer members group.stringer=stringer group.valuer=The word is 'surreal'\n", + }, + } + for _, test := range tests { + test.opts.NoColor = true + test.msg = test.name + test.runSubtest(t) } } -// Handlers should not log groups (or subgroups) without fields. -// '- If a group has no Attrs (even if it has a non-empty key), ignore it.' -// https://pkg.go.dev/log/slog@master#Handler -func TestHandler_GroupEmpty(t *testing.T) { - buf := bytes.Buffer{} - h := NewHandler(&buf, &HandlerOptions{NoColor: true}) - now := time.Now() - rec := slog.NewRecord(now, slog.LevelInfo, "foobar", 0) - rec.AddAttrs( - slog.Group("group", slog.String("foo", "bar")), - slog.Group("empty"), - ) - AssertNoError(t, h.Handle(context.Background(), rec)) - - expected := fmt.Sprintf("%s INF > foobar group.foo=bar\n", now.Format(time.DateTime)) - AssertEqual(t, expected, buf.String()) -} +func TestHandler_WithAttr(t *testing.T) { + testTime := time.Date(2024, 01, 02, 15, 04, 05, 123456789, time.UTC) -// Handlers should expand groups named "" (the empty string) into the enclosing log record. -// '- If a group's key is empty, inline the group's Attrs.' -// https://pkg.go.dev/log/slog@master#Handler -func TestHandler_GroupInline(t *testing.T) { - buf := bytes.Buffer{} - h := NewHandler(&buf, &HandlerOptions{NoColor: true}) - now := time.Now() - rec := slog.NewRecord(now, slog.LevelInfo, "foobar", 0) - rec.AddAttrs( - slog.Group("group", slog.String("foo", "bar")), - slog.Group("", slog.String("foo", "bar")), - ) - AssertNoError(t, h.Handle(context.Background(), rec)) - - expected := fmt.Sprintf("%s INF > foobar group.foo=bar foo=bar\n", now.Format(time.DateTime)) - AssertEqual(t, expected, buf.String()) -} + tests := []handlerTest{ + { + name: "with attrs", + handlerFunc: func(h slog.Handler) slog.Handler { + return h.WithAttrs([]slog.Attr{ + slog.Bool("bool", true), + slog.Int("int", -12), + slog.Uint64("uint", 12), + slog.Float64("float", 3.14), + slog.String("foo", "bar"), + slog.Time("time", testTime), + slog.Duration("dur", time.Second), + // A Handler should call Resolve on attribute values from WithAttrs. + // https://cs.opensource.google/go/x/exp/+/0dcbfd60:slog/slogtest/slogtest.go + slog.Any("stringer", theStringer{}), + slog.Any("valuer", &theValuer{"awesome"}), + slog.Group("group", + slog.String("foo", "bar"), + slog.Group("subgroup", + slog.String("foo", "bar"), + ), + // A Handler should call Resolve on attribute values in groups from WithAttrs. + // https://cs.opensource.google/go/x/exp/+/0dcbfd60:slog/slogtest/slogtest.go + "stringer", theStringer{}, + "valuer", &theValuer{"pizza"}, + ), + }) + }, + msg: "foobar", + time: testTime, + want: "2024-01-02 15:04:05 INF > foobar bool=true int=-12 uint=12 float=3.14 foo=bar time=2024-01-02 15:04:05 dur=1s stringer=stringer valuer=The word is 'awesome' group.foo=bar group.subgroup.foo=bar group.stringer=stringer group.valuer=The word is 'pizza'\n", + }, + { + name: "multiple withAttrs", + handlerFunc: func(h slog.Handler) slog.Handler { + return h.WithAttrs([]slog.Attr{ + slog.String("foo", "bar"), + }).WithAttrs([]slog.Attr{ + slog.String("baz", "buz"), + }) + }, + want: "INF > multiple withAttrs foo=bar baz=buz\n", + }, + { + name: "withAttrs and headers", + opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m"}, + handlerFunc: func(h slog.Handler) slog.Handler { + return h.WithAttrs([]slog.Attr{ + slog.String("foo", "bar"), + }) + }, + want: "INF bar > withAttrs and headers\n", + }, + } -// A Handler should call Resolve on attribute values in groups. -// https://cs.opensource.google/go/x/exp/+/0dcbfd60:slog/slogtest/slogtest.go -func TestHandler_GroupResolve(t *testing.T) { - buf := bytes.Buffer{} - h := NewHandler(&buf, &HandlerOptions{NoColor: true}) - now := time.Now() - rec := slog.NewRecord(now, slog.LevelInfo, "foobar", 0) - rec.AddAttrs( - slog.Group("group", "stringer", theStringer{}, "valuer", &theValuer{"surreal"}), - ) - AssertNoError(t, h.Handle(context.Background(), rec)) - - expected := fmt.Sprintf("%s INF > foobar group.stringer=stringer group.valuer=The word is 'surreal'\n", now.Format(time.DateTime)) - AssertEqual(t, expected, buf.String()) -} + for _, test := range tests { + test.opts.NoColor = true + if test.msg == "" { + test.msg = test.name + } + test.runSubtest(t) + } -func TestHandler_WithAttr(t *testing.T) { - buf := bytes.Buffer{} - h := NewHandler(&buf, &HandlerOptions{NoColor: true}) - now := time.Now() - rec := slog.NewRecord(now, slog.LevelInfo, "foobar", 0) - h2 := h.WithAttrs([]slog.Attr{ - slog.Bool("bool", true), - slog.Int("int", -12), - slog.Uint64("uint", 12), - slog.Float64("float", 3.14), - slog.String("foo", "bar"), - slog.Time("time", now), - slog.Duration("dur", time.Second), - // A Handler should call Resolve on attribute values from WithAttrs. - // https://cs.opensource.google/go/x/exp/+/0dcbfd60:slog/slogtest/slogtest.go - slog.Any("stringer", theStringer{}), - slog.Any("valuer", &theValuer{"awesome"}), - slog.Group("group", - slog.String("foo", "bar"), - slog.Group("subgroup", - slog.String("foo", "bar"), - ), - // A Handler should call Resolve on attribute values in groups from WithAttrs. - // https://cs.opensource.google/go/x/exp/+/0dcbfd60:slog/slogtest/slogtest.go - "stringer", theStringer{}, - "valuer", &theValuer{"pizza"}, - )}) - AssertNoError(t, h2.Handle(context.Background(), rec)) + t.Run("state isolation", func(t *testing.T) { + // test to make sure the way that WithAttrs() copies the cached headers doesn't leak + // headers back to the parent handler or to subsequent Handle() calls (i.e. ensure that + // the headers slice is copied at the right times). - expected := fmt.Sprintf("%s INF > foobar bool=true int=-12 uint=12 float=3.14 foo=bar time=%s dur=1s stringer=stringer valuer=The word is 'awesome' group.foo=bar group.subgroup.foo=bar group.stringer=stringer group.valuer=The word is 'pizza'\n", now.Format(time.DateTime), now.Format(time.DateTime)) - AssertEqual(t, expected, buf.String()) + buf := bytes.Buffer{} + h := NewHandler(&buf, &HandlerOptions{ + HeaderFormat: "%l %[foo]h %[bar]h > %m", + TimeFormat: "0", + NoColor: true, + }) + + assertLog := func(t *testing.T, handler slog.Handler, want string, attrs ...slog.Attr) { + buf.Reset() + rec := slog.NewRecord(time.Time{}, slog.LevelInfo, "with headers", 0) + + rec.AddAttrs(attrs...) - buf.Reset() - AssertNoError(t, h.Handle(context.Background(), rec)) - AssertEqual(t, fmt.Sprintf("%s INF > foobar\n", now.Format(time.DateTime)), buf.String()) + AssertNoError(t, handler.Handle(context.Background(), rec)) + AssertEqual(t, want, buf.String()) + } + + assertLog(t, h, "INF bar > with headers\n", slog.String("foo", "bar")) + + h2 := h.WithAttrs([]slog.Attr{slog.String("foo", "baz")}) + assertLog(t, h2, "INF baz > with headers\n") + + h3 := h2.WithAttrs([]slog.Attr{slog.String("foo", "buz")}) + assertLog(t, h3, "INF buz > with headers\n") + // creating h3 should not have affected h2 + assertLog(t, h2, "INF baz > with headers\n") + + // overriding attrs shouldn't affect the handler + assertLog(t, h2, "INF biz > with headers\n", slog.String("foo", "biz")) + assertLog(t, h2, "INF baz > with headers\n") + + }) } func TestHandler_WithGroup(t *testing.T) { - buf := bytes.Buffer{} - h := NewHandler(&buf, &HandlerOptions{NoColor: true}) - now := time.Now() - rec := slog.NewRecord(now, slog.LevelInfo, "foobar", 0) - rec.Add("int", 12) - h2 := h.WithGroup("group1").WithAttrs([]slog.Attr{slog.String("foo", "bar")}) - AssertNoError(t, h2.Handle(context.Background(), rec)) - expected := fmt.Sprintf("%s INF > foobar group1.foo=bar group1.int=12\n", now.Format(time.DateTime)) - AssertEqual(t, expected, buf.String()) - buf.Reset() - - h3 := h2.WithGroup("group2") - AssertNoError(t, h3.Handle(context.Background(), rec)) - expected = fmt.Sprintf("%s INF > foobar group1.foo=bar group1.group2.int=12\n", now.Format(time.DateTime)) - AssertEqual(t, expected, buf.String()) - - buf.Reset() - AssertNoError(t, h.Handle(context.Background(), rec)) - AssertEqual(t, fmt.Sprintf("%s INF > foobar int=12\n", now.Format(time.DateTime)), buf.String()) -} -func TestHandler_Levels(t *testing.T) { - levels := map[slog.Level]string{ - slog.LevelDebug - 1: "DBG-1", - slog.LevelDebug: "DBG", - slog.LevelDebug + 1: "DBG+1", - slog.LevelInfo: "INF", - slog.LevelInfo + 1: "INF+1", - slog.LevelWarn: "WRN", - slog.LevelWarn + 1: "WRN+1", - slog.LevelError: "ERR", - slog.LevelError + 1: "ERR+1", + tests := []handlerTest{ + { + name: "withGroup", + handlerFunc: func(h slog.Handler) slog.Handler { + return h.WithGroup("group1") + }, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "INF > withGroup group1.foo=bar\n", + }, + { + name: "withGroup and headers", + opts: HandlerOptions{HeaderFormat: "%l %[group1.foo]h %[bar]h > %m"}, + handlerFunc: func(h slog.Handler) slog.Handler { + return h.WithGroup("group1").WithAttrs([]slog.Attr{slog.String("foo", "bar"), slog.String("bar", "baz")}) + }, + want: "INF bar > withGroup and headers group1.bar=baz\n", + }, + { + name: "withGroup and withAttrs", + handlerFunc: func(h slog.Handler) slog.Handler { + return h.WithAttrs([]slog.Attr{slog.String("bar", "baz")}).WithGroup("group1").WithAttrs([]slog.Attr{slog.String("foo", "bar")}) + }, + attrs: []slog.Attr{slog.String("baz", "foo")}, + want: "INF > withGroup and withAttrs bar=baz group1.foo=bar group1.baz=foo\n", + }, } - for l := range levels { - t.Run(l.String(), func(t *testing.T) { - buf := bytes.Buffer{} - h := NewHandler(&buf, &HandlerOptions{Level: l, NoColor: true}) - for ll, s := range levels { - AssertEqual(t, ll >= l, h.Enabled(context.Background(), ll)) - now := time.Now() - rec := slog.NewRecord(now, ll, "foobar", 0) - if ll >= l { - AssertNoError(t, h.Handle(context.Background(), rec)) - AssertEqual(t, fmt.Sprintf("%s %s > foobar\n", now.Format(time.DateTime), s), buf.String()) - buf.Reset() + for _, test := range tests { + test.opts.NoColor = true + if test.msg == "" { + test.msg = test.name + } + test.runSubtest(t) + } + + t.Run("state isolation", func(t *testing.T) { + // test to make sure the way that WithGroup() caches state doesn't leak + // back to the parent handler or to subsequent Handle() calls + + buf := bytes.Buffer{} + h := NewHandler(&buf, &HandlerOptions{ + HeaderFormat: "%m", + TimeFormat: "0", + NoColor: true, + // the only state which WithGroup() might corrupt is the list of groups + // passed to ReplaceAttr. So we use a custom ReplaceAttr to test that + // state is not leaked. + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == "foo" { + return slog.String("foo", strings.Join(groups, ".")) } - } + return a + }, }) - } -} -func TestHandler_Source(t *testing.T) { - buf := bytes.Buffer{} - h := NewHandler(&buf, &HandlerOptions{NoColor: true, AddSource: true}) - h2 := NewHandler(&buf, &HandlerOptions{NoColor: true, AddSource: false}) - pc, file, line, _ := runtime.Caller(0) - now := time.Now() - rec := slog.NewRecord(now, slog.LevelInfo, "foobar", pc) - AssertNoError(t, h.Handle(context.Background(), rec)) - cwd, _ := os.Getwd() - file, _ = filepath.Rel(cwd, file) - AssertEqual(t, fmt.Sprintf("%s INF %s:%d > foobar\n", now.Format(time.DateTime), file, line), buf.String()) - buf.Reset() - AssertNoError(t, h2.Handle(context.Background(), rec)) - AssertEqual(t, fmt.Sprintf("%s INF > foobar\n", now.Format(time.DateTime)), buf.String()) - buf.Reset() - // If the PC is zero then this field and its associated group should not be logged. - // '- If r.PC is zero, ignore it.' - // https://pkg.go.dev/log/slog@master#Handler - rec.PC = 0 - AssertNoError(t, h.Handle(context.Background(), rec)) - AssertEqual(t, fmt.Sprintf("%s INF > foobar\n", now.Format(time.DateTime)), buf.String()) + assertLog := func(t *testing.T, handler slog.Handler, want string, attrs ...slog.Attr) { + t.Helper() + + buf.Reset() + rec := slog.NewRecord(time.Time{}, slog.LevelInfo, "state isolation", 0) + + rec.AddAttrs(attrs...) + + AssertNoError(t, handler.Handle(context.Background(), rec)) + AssertEqual(t, want, buf.String()) + } + + assertLog(t, h, "state isolation foo=\n", slog.String("foo", "bar")) + + h2 := h.WithGroup("group1") + assertLog(t, h2, "state isolation group1.foo=group1\n", slog.String("foo", "bar")) + + h3 := h.WithGroup("group2") + assertLog(t, h3, "state isolation group2.foo=group2\n", slog.String("foo", "bar")) + // creating h3 should not have affected h2 + assertLog(t, h2, "state isolation group1.foo=group1\n", slog.String("foo", "bar")) + + // overriding attrs shouldn't affect the handler + assertLog(t, h2, "state isolation group1.group3.foo=group1.group3\n", slog.Group("group3", slog.String("foo", "biz"))) + assertLog(t, h3, "state isolation group2.group3.foo=group2.group3\n", slog.Group("group3", slog.String("foo", "biz"))) + + }) } type valuer struct { @@ -656,14 +720,7 @@ func TestHandler_HeaderFormat(t *testing.T) { testTime := time.Date(2024, 01, 02, 15, 04, 05, 123456789, time.UTC) - tests := []struct { - name string - opts HandlerOptions - attrs []slog.Attr - withAttrs []slog.Attr - withGroups []string - want string - }{ + tests := []handlerTest{ { name: "default", opts: HandlerOptions{AddSource: true, NoColor: true}, @@ -710,25 +767,31 @@ func TestHandler_HeaderFormat(t *testing.T) { want: "INFbar>with headers\n", }, { - name: "header without group prefix does not match attr in group", - opts: HandlerOptions{HeaderFormat: "%l %[foo]h > %m", NoColor: true}, // header is an attribute inside a group - attrs: []slog.Attr{slog.String("foo", "bar")}, - withGroups: []string{"group1"}, - want: "INF > with headers group1.foo=bar\n", // header is foo, not group1.foo + name: "header without group prefix does not match attr in group", + opts: HandlerOptions{HeaderFormat: "%l %[foo]h > %m", NoColor: true}, // header is an attribute inside a group + attrs: []slog.Attr{slog.String("foo", "bar")}, + handlerFunc: func(h slog.Handler) slog.Handler { + return h.WithGroup("group1") + }, + want: "INF > with headers group1.foo=bar\n", // header is foo, not group1.foo }, { - name: "header with group prefix", - opts: HandlerOptions{HeaderFormat: "%l %[group1.foo]h > %m", NoColor: true}, // header is an attribute inside a group - attrs: []slog.Attr{slog.String("foo", "bar")}, - withGroups: []string{"group1"}, - want: "INF bar > with headers\n", + name: "header with group prefix", + opts: HandlerOptions{HeaderFormat: "%l %[group1.foo]h > %m", NoColor: true}, // header is an attribute inside a group + attrs: []slog.Attr{slog.String("foo", "bar")}, + handlerFunc: func(h slog.Handler) slog.Handler { + return h.WithGroup("group1") + }, + want: "INF bar > with headers\n", }, { - name: "header in nested groups", - opts: HandlerOptions{HeaderFormat: "%l %[group1.group2.foo]h > %m", NoColor: true}, // header is an attribute inside a group - attrs: []slog.Attr{slog.String("foo", "bar")}, - withGroups: []string{"group1", "group2"}, - want: "INF bar > with headers\n", + name: "header in nested groups", + opts: HandlerOptions{HeaderFormat: "%l %[group1.group2.foo]h > %m", NoColor: true}, // header is an attribute inside a group + attrs: []slog.Attr{slog.String("foo", "bar")}, + handlerFunc: func(h slog.Handler) slog.Handler { + return h.WithGroup("group1").WithGroup("group2") + }, + want: "INF bar > with headers\n", }, { name: "header in group attr, no match", @@ -743,11 +806,13 @@ func TestHandler_HeaderFormat(t *testing.T) { want: "INF bar > with headers\n", }, { - name: "header and withGroup and nested group", - opts: HandlerOptions{HeaderFormat: "%l %[group1.foo]h %[group1.group2.bar]h > %m", NoColor: true}, // header is group2.attr0, attr0 is in root - attrs: []slog.Attr{slog.String("foo", "bar"), slog.Group("group2", slog.String("bar", "baz"))}, - withGroups: []string{"group1"}, - want: "INF bar baz > with headers\n", + name: "header and withGroup and nested group", + opts: HandlerOptions{HeaderFormat: "%l %[group1.foo]h %[group1.group2.bar]h > %m", NoColor: true}, // header is group2.attr0, attr0 is in root + attrs: []slog.Attr{slog.String("foo", "bar"), slog.Group("group2", slog.String("bar", "baz"))}, + handlerFunc: func(h slog.Handler) slog.Handler { + return h.WithGroup("group1") + }, + want: "INF bar baz > with headers\n", }, { name: "no header", @@ -765,197 +830,247 @@ func TestHandler_HeaderFormat(t *testing.T) { opts: HandlerOptions{HeaderFormat: "%m", NoColor: true}, // just message want: "with headers\n", }, - // todo: test when the header matches a group attr - // todo: test non-capturing headers - // todo: test an attr matching a header multiple times (non-capturing) - // todo: test repeated fields - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - buf := bytes.Buffer{} - var h slog.Handler = NewHandler(&buf, &tt.opts) - - attrs := tt.attrs - - withAttrs := tt.withAttrs - - rec := slog.NewRecord(testTime, slog.LevelInfo, "with headers", pc) - rec.AddAttrs(attrs...) - - if len(withAttrs) > 0 { - h = h.WithAttrs(withAttrs) - } - - for _, group := range tt.withGroups { - h = h.WithGroup(group) - } - - AssertNoError(t, h.Handle(context.Background(), rec)) - AssertEqual(t, tt.want, buf.String()) - }) - } -} - -func TestHandler_Headers(t *testing.T) { - pc, file, line, _ := runtime.Caller(0) - cwd, _ := os.Getwd() - file, _ = filepath.Rel(cwd, file) - sourceField := fmt.Sprintf("%s:%d", file, line) - - tests := []struct { - name string - opts HandlerOptions - attrs []slog.Attr - withAttrs []slog.Attr - withGroups []string - want string - }{ { - name: "no headers", + name: "source not in the header", + handlerFunc: func(h slog.Handler) slog.Handler { + return h.WithGroup("group1").WithAttrs([]slog.Attr{slog.String("foo", "bar")}) + }, + opts: HandlerOptions{HeaderFormat: "%l > %m", NoColor: true, AddSource: true}, // header is foo, not source + want: "INF > with headers source=" + sourceField + " group1.foo=bar\n", + }, + { + name: "header matches a group attr should skip header", + attrs: []slog.Attr{slog.Group("group1", slog.String("foo", "bar"))}, + opts: HandlerOptions{HeaderFormat: "%l %[group1]h > %m", NoColor: true}, + want: "INF > with headers group1.foo=bar\n", + }, + { + name: "repeated header with capture", + opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[foo]h > %m", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, - want: "INF > with headers foo=bar\n", + want: "INF bar > with headers\n", // Second header is ignored since foo was captured by first header }, { - name: "one header", - opts: HandlerOptions{HeaderFormat: "%l %[foo]h > %m"}, - attrs: []slog.Attr{ - slog.String("foo", "bar"), - slog.String("bar", "baz"), - }, - want: "INF bar > with headers bar=baz\n", + name: "non-capturing header", + opts: HandlerOptions{HeaderFormat: "%l %[logger]h %[request_id]+h > %m", NoColor: true}, + attrs: []slog.Attr{slog.String("logger", "app"), slog.String("request_id", "123")}, + want: "INF app 123 > with headers request_id=123\n", }, { - name: "two headers", - opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m"}, - attrs: []slog.Attr{ - slog.String("foo", "bar"), - slog.String("bar", "baz"), - }, - want: "INF bar baz > with headers\n", + name: "non-capturing header captured by another header", + opts: HandlerOptions{HeaderFormat: "%l %[logger]+h %[logger]h > %m", NoColor: true}, + attrs: []slog.Attr{slog.String("logger", "app")}, + want: "INF app app > with headers\n", }, { - name: "two headers alt order", - opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m"}, - attrs: []slog.Attr{ - slog.String("bar", "baz"), - slog.String("foo", "bar"), - }, - want: "INF bar baz > with headers\n", + name: "multiple non-capturing headers matching same attr", + opts: HandlerOptions{HeaderFormat: "%l %[logger]+h %[logger]+h > %m", NoColor: true}, + attrs: []slog.Attr{slog.String("logger", "app")}, + want: "INF app app > with headers logger=app\n", }, { - name: "missing headers", - opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m"}, - attrs: []slog.Attr{slog.String("bar", "baz"), slog.String("baz", "foo")}, - want: "INF baz > with headers baz=foo\n", + name: "repeated timestamp, level and message fields", + opts: HandlerOptions{HeaderFormat: "%t %l %m %t %l %m", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "2024-01-02 15:04:05 INF with headers 2024-01-02 15:04:05 INF with headers foo=bar\n", }, { - name: "missing all headers", - opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m"}, + name: "missing header and multiple spaces", + opts: HandlerOptions{HeaderFormat: "%l %[missing]h %[foo]h > %m", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "INF bar > with headers\n", + }, + { + name: "fixed width header left aligned", + opts: HandlerOptions{HeaderFormat: "%l %[foo]10h > %m", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "INF bar > with headers\n", + }, + { + name: "fixed width header right aligned", + opts: HandlerOptions{HeaderFormat: "%l %[foo]-10h > %m", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "INF bar > with headers\n", + }, + { + name: "fixed width header truncated", + opts: HandlerOptions{HeaderFormat: "%l %[foo]3h > %m", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "barbaz")}, + want: "INF bar > with headers\n", + }, + { + name: "fixed width header with spaces", + opts: HandlerOptions{HeaderFormat: "%l %[foo]10h %[bar]5h > %m", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "hello"), slog.String("bar", "world")}, + want: "INF hello world > with headers\n", + }, + { + name: "fixed width non-capturing header", + opts: HandlerOptions{HeaderFormat: "%l %[foo]+-10h > %m", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "INF bar > with headers foo=bar\n", + }, + { + name: "fixed width header missing attr", + opts: HandlerOptions{HeaderFormat: "%l %[missing]10h > %m", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "INF > with headers foo=bar\n", + }, + { + name: "non-abbreviated levels", + opts: HandlerOptions{HeaderFormat: "%L > %m", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "INFO > with headers foo=bar\n", + }, + { + name: "alternate text", + opts: HandlerOptions{HeaderFormat: "prefix [%l] [%[foo]h] %m suffix > ", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "prefix [INF] [bar] with headers suffix > \n", + }, + { + name: "escaped percent", + opts: HandlerOptions{HeaderFormat: "prefix %% [%l] %m", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "prefix % [INF] with headers foo=bar\n", + }, + { + name: "missing verb", + opts: HandlerOptions{HeaderFormat: "%m %", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "with headers %!(MISSING_VERB) foo=bar\n", + }, + { + name: "invalid modifier", + opts: HandlerOptions{HeaderFormat: "%m %-L", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "with headers %!-(INVALID_VERB)L foo=bar\n", + }, + { + name: "invalid verb", + opts: HandlerOptions{HeaderFormat: "%l %x %m", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "INF %!x(INVALID_VERB) with headers foo=bar\n", + }, + { + name: "missing header name", + opts: HandlerOptions{HeaderFormat: "%m %h", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "with headers %!h(MISSING_HEADER_NAME) foo=bar\n", + }, + { + name: "missing closing bracket in header", + opts: HandlerOptions{HeaderFormat: "%m %[fooh >", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "with headers %!(MISSING_CLOSING_BRACKET) > foo=bar\n", + }, + { + name: "zero PC", + opts: HandlerOptions{HeaderFormat: "%l %[source]h > %m", NoColor: true, AddSource: true}, + recFunc: func(r *slog.Record) { + r.PC = 0 + }, want: "INF > with headers\n", }, { - name: "header and source", - opts: HandlerOptions{HeaderFormat: "%l %[source]h %[foo]h > %m", AddSource: true}, - attrs: []slog.Attr{ - slog.String("foo", "bar"), - slog.String("bar", "baz"), + name: "level DEBUG-3", + opts: HandlerOptions{NoColor: true, HeaderFormat: "%l %L >"}, + recFunc: func(r *slog.Record) { + r.Level = slog.LevelDebug - 3 }, - want: "INF " + sourceField + " bar > with headers bar=baz\n", + want: "DBG-3 DEBUG-3 >\n", }, { - name: "withattrs", - opts: HandlerOptions{HeaderFormat: "%l %[foo]h > %m"}, - attrs: []slog.Attr{ - slog.String("bar", "baz"), + name: "level DEBUG+1", + opts: HandlerOptions{NoColor: true, HeaderFormat: "%l %L >"}, + recFunc: func(r *slog.Record) { + r.Level = slog.LevelDebug + 1 }, - withAttrs: []slog.Attr{ - slog.String("foo", "bar"), + want: "DBG+1 DEBUG+1 >\n", + }, + { + name: "level INFO+1", + opts: HandlerOptions{NoColor: true, HeaderFormat: "%l %L >"}, + recFunc: func(r *slog.Record) { + r.Level = slog.LevelInfo + 1 }, - want: "INF bar > with headers bar=baz\n", + want: "INF+1 INFO+1 >\n", }, { - name: "withgroup", - opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[group.bar]h > %m"}, - attrs: []slog.Attr{ - slog.String("bar", "baz"), - slog.String("baz", "foo"), + name: "level WARN +1", + opts: HandlerOptions{NoColor: true, HeaderFormat: "%l %L >"}, + recFunc: func(r *slog.Record) { + r.Level = slog.LevelWarn + 1 }, - withGroups: []string{"group"}, - withAttrs: []slog.Attr{ - slog.String("foo", "bar"), + want: "WRN+1 WARN+1 >\n", + }, + { + name: "level ERROR+1", + opts: HandlerOptions{NoColor: true, HeaderFormat: "%l %L >"}, + recFunc: func(r *slog.Record) { + r.Level = slog.LevelError + 1 }, - want: "INF bar baz > with headers group.baz=foo\n", + want: "ERR+1 ERROR+1 >\n", }, - // todo: add a test for when the record doesn't include the header field, but fixed width headers are enabled - // the header should be padded with spaces to the right - // todo: add a test for when the same attribute is repeated in the record - // todo: add a test for when the same attribute is repeated in the headers } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - buf := bytes.Buffer{} - - opts := &test.opts - opts.NoColor = true - var h slog.Handler = NewHandler(&buf, &test.opts) - if test.withAttrs != nil { - h = h.WithAttrs(test.withAttrs) - } - for _, g := range test.withGroups { - h = h.WithGroup(g) - } - - rec := slog.NewRecord(time.Time{}, slog.LevelInfo, "with headers", pc) - - rec.AddAttrs(test.attrs...) - - AssertNoError(t, h.Handle(context.Background(), rec)) - AssertEqual(t, test.want, buf.String()) - }) + for _, tt := range tests { + tt.msg = "with headers" + tt.pc = pc + tt.lvl = slog.LevelInfo + tt.time = testTime + tt.runSubtest(t) } +} - t.Run("withAttrs state keeping", func(t *testing.T) { - // test to make sure the way that WithAttrs() copies the cached headers doesn't leak - // headers back to the parent handler or to subsequent Handle() calls (i.e. ensure that - // the headers slice is copied at the right times). - - buf := bytes.Buffer{} - h := NewHandler(&buf, &HandlerOptions{ - HeaderFormat: "%l %[foo]h %[bar]h > %m", - TimeFormat: "0", - NoColor: true, - }) - - assertLog := func(t *testing.T, handler slog.Handler, want string, attrs ...slog.Attr) { - buf.Reset() - rec := slog.NewRecord(time.Time{}, slog.LevelInfo, "with headers", pc) +type handlerTest struct { + name string + opts HandlerOptions + msg string + pc uintptr + lvl slog.Level + time time.Time + attrs []slog.Attr + handlerFunc func(h slog.Handler) slog.Handler + recFunc func(r *slog.Record) + want string + wantErr string +} - rec.AddAttrs(attrs...) +func (ht handlerTest) runSubtest(t *testing.T) { + t.Helper() + t.Run(ht.name, func(t *testing.T) { + ht.run(t) + }) +} - AssertNoError(t, handler.Handle(context.Background(), rec)) - AssertEqual(t, want, buf.String()) - } +func (ht handlerTest) run(t *testing.T) { + t.Helper() + buf := bytes.Buffer{} + var h slog.Handler = NewHandler(&buf, &ht.opts) - assertLog(t, h, "INF bar > with headers\n", slog.String("foo", "bar")) + rec := slog.NewRecord(ht.time, ht.lvl, ht.msg, ht.pc) + rec.AddAttrs(ht.attrs...) - h2 := h.WithAttrs([]slog.Attr{slog.String("foo", "baz")}) - assertLog(t, h2, "INF baz > with headers\n") - - h3 := h2.WithAttrs([]slog.Attr{slog.String("foo", "buz")}) - assertLog(t, h3, "INF buz > with headers\n") - // creating h3 should not have affected h2 - assertLog(t, h2, "INF baz > with headers\n") + if ht.handlerFunc != nil { + h = ht.handlerFunc(h) + } - // overriding attrs shouldn't affect the handler - assertLog(t, h2, "INF biz > with headers\n", slog.String("foo", "biz")) - assertLog(t, h2, "INF baz > with headers\n") + if ht.recFunc != nil { + ht.recFunc(&rec) + } - }) + err := h.Handle(context.Background(), rec) + if ht.wantErr != "" { + AssertError(t, err) + AssertEqual(t, ht.wantErr, err.Error()) + } else { + AssertNoError(t, err) + AssertEqual(t, ht.want, buf.String()) + } } -func TestHandler_Err(t *testing.T) { +func TestHandler_writerErr(t *testing.T) { w := writerFunc(func(b []byte) (int, error) { return 0, errors.New("nope") }) h := NewHandler(w, &HandlerOptions{NoColor: true}) rec := slog.NewRecord(time.Now(), slog.LevelInfo, "foobar", 0) @@ -1128,262 +1243,3 @@ func TestThemes(t *testing.T) { }) } } - -func TestParseFormat(t *testing.T) { - tests := []struct { - name string - format string - wantFields []any - wantHeaders []headerField - }{ - { - name: "basic format", - format: "%t %l %m", - wantFields: []any{ - timestampField{}, - " ", - levelField{abbreviated: true}, - " ", - messageField{}, - }, - wantHeaders: []headerField{}, - }, - { - name: "with header", - format: "%t %[logger]h %l %m", - wantFields: []any{ - timestampField{}, - " ", - headerField{key: "logger", capture: true}, - " ", - levelField{abbreviated: true}, - " ", - messageField{}, - }, - wantHeaders: []headerField{ - {key: "logger", capture: true}, - }, - }, - { - name: "header with width", - format: "%t %[logger]5h %l %m", - wantFields: []any{ - timestampField{}, - " ", - headerField{key: "logger", width: 5, capture: true}, - " ", - levelField{abbreviated: true}, - " ", - messageField{}, - }, - wantHeaders: []headerField{ - {key: "logger", width: 5, capture: true}, - }, - }, - { - name: "header with right align", - format: "%t %[logger]-h %l %m", - wantFields: []any{ - timestampField{}, - " ", - headerField{key: "logger", rightAlign: true, capture: true}, - " ", - levelField{abbreviated: true}, - " ", - messageField{}, - }, - wantHeaders: []headerField{ - {key: "logger", rightAlign: true, capture: true}, - }, - }, - { - name: "header with width and right align", - format: "%t %[logger]-5h %l %m", - wantFields: []any{ - timestampField{}, - " ", - headerField{key: "logger", width: 5, rightAlign: true, capture: true}, - " ", - levelField{abbreviated: true}, - " ", - messageField{}, - }, - wantHeaders: []headerField{ - {key: "logger", width: 5, rightAlign: true, capture: true}, - }, - }, - { - name: "non-capturing header", - format: "%t %[logger]+h %l %m", - wantFields: []any{ - timestampField{}, - " ", - headerField{key: "logger", capture: false}, - " ", - levelField{abbreviated: true}, - " ", - messageField{}, - }, - wantHeaders: []headerField{ - {key: "logger", capture: false}, - }, - }, - { - name: "multiple headers", - format: "%t %[logger]h %[source]h %l %m", - wantFields: []any{ - timestampField{}, - " ", - headerField{key: "logger", capture: true}, - " ", - headerField{key: "source", capture: true}, - " ", - levelField{abbreviated: true}, - " ", - messageField{}, - }, - wantHeaders: []headerField{ - {key: "logger", capture: true}, - {key: "source", capture: true}, - }, - }, - { - name: "with literal text", - format: "prefix %t [%l] %m suffix", - wantFields: []any{ - "prefix ", - timestampField{}, - " [", - levelField{abbreviated: true}, - "] ", - messageField{}, - " suffix", - }, - wantHeaders: []headerField{}, - }, - { - name: "with escaped percent", - format: "%% %t %l %m", - wantFields: []any{ - "%", - " ", - timestampField{}, - " ", - levelField{abbreviated: true}, - " ", - messageField{}, - }, - wantHeaders: []headerField{}, - }, - { - name: "with non-abbreviated level", - format: "%t %L %m", - wantFields: []any{ - timestampField{}, - " ", - levelField{abbreviated: false}, - " ", - messageField{}, - }, - wantHeaders: []headerField{}, - }, - { - name: "with right-aligned non-abbreviated level", - format: "%t %-L %m", - wantFields: []any{ - timestampField{}, - " ", - levelField{abbreviated: false, rightAlign: true}, - " ", - messageField{}, - }, - wantHeaders: []headerField{}, - }, - { - name: "error: missing verb", - format: "%t %", - wantFields: []any{ - timestampField{}, - " ", - "%!(MISSING_VERB)", - }, - wantHeaders: []headerField{}, - }, - { - name: "error: missing header name", - format: "%t %h %m", - wantFields: []any{ - timestampField{}, - " ", - "%!h(MISSING_HEADER_NAME)", - " ", - messageField{}, - }, - wantHeaders: []headerField{}, - }, - { - name: "error: missing closing bracket", - format: "%t %[logger %m", - wantFields: []any{ - timestampField{}, - " ", - "%!(MISSING_CLOSING_BRACKET)", - " ", - messageField{}, - }, - wantHeaders: []headerField{}, - }, - { - name: "error: invalid verb", - format: "%t %x %m", - wantFields: []any{ - timestampField{}, - " ", - "%!x(INVALID_VERB)", - " ", - messageField{}, - }, - wantHeaders: []headerField{}, - }, - { - name: "with extra whitespace", - format: "%t %l %[logger]h %m", - wantFields: []any{ - timestampField{}, - " ", - levelField{abbreviated: true}, - " ", - headerField{key: "logger", capture: true}, - " ", - messageField{}, - }, - wantHeaders: []headerField{ - {key: "logger", capture: true}, - }, - }, - { - name: "header with group prefix", - format: "%t %[group.logger]h", - wantFields: []any{ - timestampField{}, - " ", - headerField{groupPrefix: "group", key: "logger", capture: true}, - }, - wantHeaders: []headerField{ - {groupPrefix: "group", key: "logger", capture: true}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotFields, gotHeaders := parseFormat(tt.format) - if !reflect.DeepEqual(gotHeaders, tt.wantHeaders) { - t.Errorf("parseFormat() headers =\n%#v\nwant:\n%#v", gotHeaders, tt.wantHeaders) - } - if !reflect.DeepEqual(gotFields, tt.wantFields) { - t.Errorf("parseFormat() fields =\n%#v\nwant:\n%#v", gotFields, tt.wantFields) - } - }) - } -} From 519fe7b910d9c02a756c0be89afc725909c18c9b Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Fri, 14 Feb 2025 14:06:03 -0600 Subject: [PATCH 23/44] WIP: elastic spaces and field groups Spaces are still not collapsing correctly though. --- handler.go | 142 +++++++++++++++++++++++++++++++++++++++++------- handler_test.go | 140 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 258 insertions(+), 24 deletions(-) diff --git a/handler.go b/handler.go index 72cc540..ef55d20 100644 --- a/handler.go +++ b/handler.go @@ -68,16 +68,18 @@ type HandlerOptions struct { // // The format is a string containing verbs, which are expanded as follows: // - // %t timestamp - // %l abbreviated level (e.g. "INF") - // %L level (e.g. "INFO") - // %m message - // %[key]h header with the given key. + // %t timestamp + // %l abbreviated level (e.g. "INF") + // %L level (e.g. "INFO") + // %m message + // %[key]h header with the given key. + // %{ group open + // %} group close // // Headers print the value of the attribute with the given key, and remove that // attribute from the end of the log line. // - // Headers can be customized with width, alignment, and non-capturing, + // Headers can be customized with width, alignment, and non-capturing modifiers, // similar to fmt.Printf verbs. For example: // // %[key]10h // left-aligned, width 10 @@ -85,9 +87,46 @@ type HandlerOptions struct { // %[key]+h // non-capturing // %[key]-10+h // right-aligned, width 10, non-capturing // - // If the header is non-capturing, the header field will be printed, but - // the attribute will still be available for matching subsequent header fields, - // and/or printing in the attributes section of the log line. + // Note that headers will "capture" their matching attribute by default, which means that attribute will not + // be included in the attributes section of the log line, and will not be matched by subsequent header fields. + // Use the non-capturing header modifier '+' to disable capturing. If a header is non-capturing, the attribute + // will still be available for matching subsequent header fields, and will be included in the attributes section + // of the log line. + // + // Groups will omit their contents if all the fields in that group are omitted. For example: + // + // "%l %{%[logger]h %[source]h > %} %m" + // + // will print "INF main main.go:123 > msg" if the either the logger or source attribute is present. But if the + // both attributes are not present, or were elided by ReplaceAttr, then this will print "INF msg". Groups can + // be nested. + // + // If a field is followed by a space, and the field is omitted, the following space is also omitted, as if + // the field and space are in a group. This ensures that spaces between fields collapse as expected. + // For example: + // + // "%l %[logger]h %[source]h %t > %m" + // + // If logger and timestamp are omitted, this will print "INF main.go:123 > msg". The spaces after logger and timestamp + // are omitted. + // + // Examples: + // + // "%t %l %m" // timestamp, level, message + // "%t [%l] %m" // timestamp, level in brackets, message + // "%t %l:%m" // timestamp, level:message + // "%t %l %[key]h %m" // timestamp, level, header with key "key", message + // "%t %l %[key1]h %[key2]h %m" // timestamp, level, header with key "key1", header with key "key2", message + // "%t %l %[key]10h %m" // timestamp, level, header with key "key" and width 10, message + // "%t %l %[key]-10h %m" // timestamp, level, right-aligned header with key "key" and width 10, message + // "%t %l %[key]10+h %m" // timestamp, level, captured header with key "key" and width 10, message + // "%t %l %[key]-10+h %m" // timestamp, level, right-aligned captured header with key "key" and width 10, message + // "%t %l %L %m" // timestamp, abbreviated level, non-abbreviated level, message + // "%t %l %L- %m" // timestamp, abbreviated level, right-aligned non-abbreviated level, message + // "%t %l %m string literal" // timestamp, level, message, and then " string literal" + // "prefix %t %l %m suffix" // "prefix ", timestamp, level, message, and then " suffix" + // "%% %t %l %m" // literal "%", timestamp, level, message + // "%{[%t]%} %{[%l]%} %m" // timestamp and level in brackets, message, brackets will be omitted if empty HeaderFormat string } @@ -118,6 +157,9 @@ type levelField struct { } type messageField struct{} +type groupOpen struct{} +type groupClose struct{} + var _ slog.Handler = (*Handler)(nil) // NewHandler creates a Handler that writes to w, @@ -182,12 +224,36 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { return true }) - var swallow bool headerIdx := 0 - var l int - for _, f := range h.fields { + var state encodeState + + // use a fixed size stack to avoid allocations, 3 deep nested groups should be enough for most cases + stackArr := [3]encodeState{} + stack := stackArr[:0] + for i, f := range h.fields { switch f := f.(type) { + case groupOpen: + stack = append(stack, state) + state.groupStart = enc.buf.Len() + state.printedField = false + case groupClose: + if len(stack) == 0 { + // missing group open + // no-op + continue + } + + printedField := state.printedField + if !printedField { + enc.buf.Truncate(state.groupStart) + } + state = stack[len(stack)-1] + stack = stack[:len(stack)-1] + // if a field was printed in the inner group, + // then it was also printed in the outer group + state.printedField = printedField || state.printedField case headerField: + l := enc.buf.Len() hf := h.headerFields[headerIdx] if enc.headerAttrs[headerIdx].Equal(slog.Attr{}) && hf.memo != "" { enc.buf.AppendString(hf.memo) @@ -195,26 +261,36 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { enc.encodeHeader(enc.headerAttrs[headerIdx], hf.width, hf.rightAlign) } headerIdx++ + state.printedField = state.printedField || enc.buf.Len() > l case levelField: + l := enc.buf.Len() enc.encodeLevel(rec.Level, f.abbreviated) + state.printedField = state.printedField || enc.buf.Len() > l case messageField: + l := enc.buf.Len() enc.encodeMessage(rec.Level, rec.Message) + state.printedField = state.printedField || enc.buf.Len() > l case timestampField: + l := enc.buf.Len() enc.encodeTimestamp(rec.Time) + state.printedField = state.printedField || enc.buf.Len() > l case string: - // todo: need to color these strings - // todo: can we generalize this to some form of grouping? - if swallow { - if len(f) > 0 && f[0] == ' ' { + // elide the next space if the buf ends in a trailing space + if enc.buf.Len() == state.trailingSpace && startsWithSingleSpace(f) { + if i > 0 { + // special case: if the first field is a string + // never elide it f = f[1:] } } + + // todo: need to color these strings enc.buf.AppendString(f) - l = 0 // ensure the next field is not swallowed + + if len(f) > 0 && endsWithSpace(f) { + state.trailingSpace = enc.buf.Len() + } } - l2 := enc.buf.Len() - swallow = l2 == l - l = l2 } // concatenate the buffers together before writing to out, so the entire @@ -231,6 +307,28 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { return nil } +type encodeState struct { + groupStart int + // l int + printedField bool + trailingSpace int +} + +func endsWithSpace(s string) bool { + return len(s) > 0 && s[len(s)-1] == ' ' +} + +func startsWithSingleSpace(s string) bool { + lf := len(s) + if lf == 1 && s[0] == ' ' { + return true + } + if lf > 1 && s[0] == ' ' && s[1] != ' ' { + return true + } + return false +} + // WithAttrs implements slog.Handler. func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { enc := newEncoder(h) @@ -462,6 +560,10 @@ func parseFormat(format string) (fields []any, headerFields []headerField) { field = levelField{ abbreviated: false, } + case '{': + field = groupOpen{} + case '}': + field = groupClose{} default: fields = append(fields, fmt.Sprintf("%%!%c(INVALID_VERB)", format[i])) continue diff --git a/handler_test.go b/handler_test.go index b4dd585..b79d805 100644 --- a/handler_test.go +++ b/handler_test.go @@ -193,10 +193,13 @@ func TestHandler_AttrsWithNewlines(t *testing.T) { }, { name: "multiline attr using WithAttrs", - attrs: []slog.Attr{ - slog.String("foo", "line one\nline two"), + handlerFunc: func(h slog.Handler) slog.Handler { + return h.WithAttrs([]slog.Attr{ + slog.String("foo", "line one\nline two"), + }) }, - want: "INF > multiline attrs foo=line one\nline two\n", + attrs: []slog.Attr{slog.String("bar", "baz")}, + want: "INF > multiline attrs bar=baz foo=line one\nline two\n", }, { name: "multiline header value", @@ -712,6 +715,135 @@ func TestHandler_ReplaceAttr(t *testing.T) { } +func TestHandler_CollapseSpaces(t *testing.T) { + tests := []handlerTest{ + { + name: "simple", + opts: HandlerOptions{HeaderFormat: "%l %[foo]h > %m"}, + want: "INF > collapse spaces\n", + }, + { + name: "two fields", + opts: HandlerOptions{HeaderFormat: "%l %t"}, + want: "INF\n", + }, + { + name: "two missing fields", + opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m"}, + want: "INF > collapse spaces\n", + }, + { + name: "adjacent missing fields", + opts: HandlerOptions{HeaderFormat: "%l %t%t > %m"}, + want: "INF > collapse spaces\n", + }, + { + name: "fields in a group", + opts: HandlerOptions{HeaderFormat: "%l %{%t%t%} > %m"}, + want: "INF > collapse spaces\n", + }, + { + name: "groups and spaces", + opts: HandlerOptions{HeaderFormat: "%l %{ %t %t > %} %m"}, + want: "INF collapse spaces\n", + }, + { + name: "leading space is preserved", + opts: HandlerOptions{HeaderFormat: " %t %t %l > %t > %m"}, + want: " INF > > collapse spaces\n", + }, + { + name: "first field is elided", + opts: HandlerOptions{HeaderFormat: "%t %l > %m"}, + want: "INF > collapse spaces\n", + }, + { + name: "extra space is preserved", + opts: HandlerOptions{HeaderFormat: "%t %t %l > %t > %m"}, + want: " INF > > collapse spaces\n", + }, + { + name: "groups", + opts: HandlerOptions{HeaderFormat: "%l %{[%t][%l][%t]%} > %m"}, + want: "INF [][INF][] > collapse spaces\n", + }, + { + name: "more groups", + opts: HandlerOptions{HeaderFormat: "%l %{[%t]%}%{[%l]%}%{[%t]%} > %m"}, + want: "INF [INF] > collapse spaces\n", + }, + { + name: "elided group starts after a non-space, and ends with a space", + opts: HandlerOptions{HeaderFormat: "%l%{%t %} > %m"}, + want: "INF > collapse spaces\n", + }, + { + name: "empty padded header should not elide surrounding spaces", + opts: HandlerOptions{HeaderFormat: "%l %[foo]5h > %m"}, + want: "INF > collapse spaces\n", + }, + { + name: "space between fields", + opts: HandlerOptions{HeaderFormat: "%l [%[foo]h %[bar]h] > %m"}, + want: "INF [] > collapse spaces\n", + }, + { + name: "space between and around fields", + opts: HandlerOptions{HeaderFormat: "%l [ %[foo]h %[bar]h ] > %m"}, + want: "INF [ ] > collapse spaces\n", + }, + } + + for _, tt := range tests { + tt.msg = "collapse spaces" + tt.opts.NoColor = true + tt.runSubtest(t) + } +} + +func TestHandler_HeaderFormat_Groups(t *testing.T) { + tests := []handlerTest{ + { + name: "group not elided", + opts: HandlerOptions{HeaderFormat: "%l %{[%[foo]h]%} > %m"}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "INF [bar] > groups\n", + }, + { + name: "group elided", + opts: HandlerOptions{HeaderFormat: "%l %{[%[foo]h]%} > %m"}, + want: "INF > groups\n", + }, + { + name: "two headers in group, both elided", + opts: HandlerOptions{HeaderFormat: "%l %{[%[foo]h %[bar]h]%} > %m"}, + want: "INF > groups\n", + }, + { + name: "two headers in group, one elided", + opts: HandlerOptions{HeaderFormat: "%l %{[%[foo]h %[bar]h]%} > %m"}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "INF [bar] > groups\n", + }, + { + name: "two headers in group, neither elided", + opts: HandlerOptions{HeaderFormat: "%l %{[%[foo]h %[bar]h]%} > %m"}, + attrs: []slog.Attr{slog.String("foo", "bar"), slog.String("bar", "baz")}, + want: "INF [bar baz] > groups\n", + }, + } + + for _, tt := range tests { + tt.msg = "groups" + tt.opts.NoColor = true + tt.runSubtest(t) + } +} + +// Add a test for header formats with groups +// nested +// extra open/close groups + func TestHandler_HeaderFormat(t *testing.T) { pc, file, line, _ := runtime.Caller(0) cwd, _ := os.Getwd() @@ -878,7 +1010,7 @@ func TestHandler_HeaderFormat(t *testing.T) { name: "missing header and multiple spaces", opts: HandlerOptions{HeaderFormat: "%l %[missing]h %[foo]h > %m", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, - want: "INF bar > with headers\n", + want: "INF bar > with headers\n", }, { name: "fixed width header left aligned", From 96503889e225337e468708ad2d29cf9098d351b0 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Sat, 15 Feb 2025 09:24:28 -0600 Subject: [PATCH 24/44] Close but still not quite right Still not handling cases like `[%t %m]`, you end up with `[t ]`, `[ m]`, or `[ ]`. I'd really like it so that a space between two fields is elided if either field is elided. --- handler.go | 96 ++++++++++++++++++++++++++++++------------------- handler_test.go | 16 ++++----- 2 files changed, 67 insertions(+), 45 deletions(-) diff --git a/handler.go b/handler.go index ef55d20..903fc1e 100644 --- a/handler.go +++ b/handler.go @@ -160,6 +160,8 @@ type messageField struct{} type groupOpen struct{} type groupClose struct{} +type spacerField struct{} + var _ slog.Handler = (*Handler)(nil) // NewHandler creates a Handler that writes to w, @@ -230,12 +232,13 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { // use a fixed size stack to avoid allocations, 3 deep nested groups should be enough for most cases stackArr := [3]encodeState{} stack := stackArr[:0] - for i, f := range h.fields { - switch f := f.(type) { + for _, f := range h.fields { + switch f.(type) { case groupOpen: stack = append(stack, state) state.groupStart = enc.buf.Len() state.printedField = false + continue case groupClose: if len(stack) == 0 { // missing group open @@ -243,17 +246,33 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { continue } - printedField := state.printedField - if !printedField { - enc.buf.Truncate(state.groupStart) - } + poppedState := state state = stack[len(stack)-1] stack = stack[:len(stack)-1] - // if a field was printed in the inner group, - // then it was also printed in the outer group - state.printedField = printedField || state.printedField + + if !poppedState.printedField { + enc.buf.Truncate(poppedState.groupStart) + } else { + // the group was not elide, so push + // back some of the inner state to the + // outer state + state.printedField = true + state.anchored = state.anchored || poppedState.anchored + state.spacePending = state.spacePending || poppedState.spacePending + } + continue + case spacerField: + state.spacePending = true + continue + } + if state.anchored && state.spacePending { + enc.buf.AppendByte(' ') + } + state.spacePending = false + l := enc.buf.Len() + var wasString bool + switch f := f.(type) { case headerField: - l := enc.buf.Len() hf := h.headerFields[headerIdx] if enc.headerAttrs[headerIdx].Equal(slog.Attr{}) && hf.memo != "" { enc.buf.AppendString(hf.memo) @@ -261,36 +280,24 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { enc.encodeHeader(enc.headerAttrs[headerIdx], hf.width, hf.rightAlign) } headerIdx++ - state.printedField = state.printedField || enc.buf.Len() > l + case levelField: - l := enc.buf.Len() enc.encodeLevel(rec.Level, f.abbreviated) - state.printedField = state.printedField || enc.buf.Len() > l case messageField: - l := enc.buf.Len() enc.encodeMessage(rec.Level, rec.Message) - state.printedField = state.printedField || enc.buf.Len() > l case timestampField: - l := enc.buf.Len() enc.encodeTimestamp(rec.Time) - state.printedField = state.printedField || enc.buf.Len() > l case string: - // elide the next space if the buf ends in a trailing space - if enc.buf.Len() == state.trailingSpace && startsWithSingleSpace(f) { - if i > 0 { - // special case: if the first field is a string - // never elide it - f = f[1:] - } - } - - // todo: need to color these strings enc.buf.AppendString(f) - - if len(f) > 0 && endsWithSpace(f) { - state.trailingSpace = enc.buf.Len() - } + wasString = true } + state.anchored = enc.buf.Len() > l + state.printedField = state.printedField || (state.anchored && !wasString) + } + + // trim trailing space + if len(enc.buf) > 0 && enc.buf[enc.buf.Len()-1] == ' ' { + enc.buf.Truncate(enc.buf.Len() - 1) } // concatenate the buffers together before writing to out, so the entire @@ -308,10 +315,13 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { } type encodeState struct { - groupStart int - // l int - printedField bool - trailingSpace int + groupStart int + printedField bool + // True if the last field was not elided. When anchored is true, the + // next pending space will not be elided + anchored bool + // Track if we need to print a space from a previous spacerField + spacePending bool } func endsWithSpace(s string) bool { @@ -458,11 +468,23 @@ func parseFormat(format string) (fields []any, headerFields []headerField) { fields = make([]any, 0) headerFields = make([]headerField, 0) + format = strings.TrimSpace(format) + lastWasSpace := false + for i := 0; i < len(format); i++ { + if format[i] == ' ' { + if !lastWasSpace { + fields = append(fields, spacerField{}) + lastWasSpace = true + } + continue + } + lastWasSpace = false + if format[i] != '%' { - // Find the next % or end of string + // Find the next % or space or end of string start := i - for i < len(format) && format[i] != '%' { + for i < len(format) && format[i] != '%' && format[i] != ' ' { i++ } fields = append(fields, format[start:i]) diff --git a/handler_test.go b/handler_test.go index b79d805..4aac5ba 100644 --- a/handler_test.go +++ b/handler_test.go @@ -645,7 +645,7 @@ func TestHandler_ReplaceAttr(t *testing.T) { { name: "clear message", replaceAttr: replaceAttrWith(slog.MessageKey, slog.Any(slog.MessageKey, nil)), - want: "2010-05-06 07:08:09 INF " + sourceField + " > size=12 color=red\n", + want: "2010-05-06 07:08:09 INF " + sourceField + " > size=12 color=red\n", }, { name: "replace message", @@ -748,19 +748,19 @@ func TestHandler_CollapseSpaces(t *testing.T) { want: "INF collapse spaces\n", }, { - name: "leading space is preserved", + name: "leading space is trimmed", opts: HandlerOptions{HeaderFormat: " %t %t %l > %t > %m"}, - want: " INF > > collapse spaces\n", + want: "INF > > collapse spaces\n", }, { name: "first field is elided", - opts: HandlerOptions{HeaderFormat: "%t %l > %m"}, + opts: HandlerOptions{HeaderFormat: " %t %l > %m"}, want: "INF > collapse spaces\n", }, { - name: "extra space is preserved", + name: "extra space is elided", opts: HandlerOptions{HeaderFormat: "%t %t %l > %t > %m"}, - want: " INF > > collapse spaces\n", + want: "INF > > collapse spaces\n", }, { name: "groups", @@ -1010,7 +1010,7 @@ func TestHandler_HeaderFormat(t *testing.T) { name: "missing header and multiple spaces", opts: HandlerOptions{HeaderFormat: "%l %[missing]h %[foo]h > %m", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, - want: "INF bar > with headers\n", + want: "INF bar > with headers\n", }, { name: "fixed width header left aligned", @@ -1058,7 +1058,7 @@ func TestHandler_HeaderFormat(t *testing.T) { name: "alternate text", opts: HandlerOptions{HeaderFormat: "prefix [%l] [%[foo]h] %m suffix > ", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, - want: "prefix [INF] [bar] with headers suffix > \n", + want: "prefix [INF] [bar] with headers suffix >\n", }, { name: "escaped percent", From 2642549aa9e46a06979510ca48ea3ddfe213f948 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Sun, 16 Feb 2025 13:01:57 -0600 Subject: [PATCH 25/44] got the whitespace handling the way I want it --- handler.go | 140 +++++++++++++++++++++++++++------------------- handler_test.go | 145 +++++++++++++++++++----------------------------- 2 files changed, 141 insertions(+), 144 deletions(-) diff --git a/handler.go b/handler.go index 903fc1e..5a369c1 100644 --- a/handler.go +++ b/handler.go @@ -1,6 +1,7 @@ package console import ( + "bytes" "context" "fmt" "io" @@ -101,14 +102,7 @@ type HandlerOptions struct { // both attributes are not present, or were elided by ReplaceAttr, then this will print "INF msg". Groups can // be nested. // - // If a field is followed by a space, and the field is omitted, the following space is also omitted, as if - // the field and space are in a group. This ensures that spaces between fields collapse as expected. - // For example: - // - // "%l %[logger]h %[source]h %t > %m" - // - // If logger and timestamp are omitted, this will print "INF main.go:123 > msg". The spaces after logger and timestamp - // are omitted. + // Whitespace is generally merged to leave a single space between fields. Leading and trailing whitespace is trimmed. // // Examples: // @@ -160,7 +154,9 @@ type messageField struct{} type groupOpen struct{} type groupClose struct{} -type spacerField struct{} +type spacerField struct { + hard bool +} var _ slog.Handler = (*Handler)(nil) @@ -226,14 +222,40 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { return true }) + // todo: if we keep this, it needs to move into parseFormat or something + var wasString bool + lastSpace := -1 + for i, f := range h.fields { + switch f.(type) { + case headerField, levelField, messageField, timestampField: + wasString = false + lastSpace = -1 + case string: + if lastSpace != -1 { + // string immediately followed space, so the + // space is hard. + h.fields[lastSpace] = spacerField{hard: true} + } + wasString = true + lastSpace = -1 + case spacerField: + if wasString { + // space immedately followed a string, so the space + // is hard + h.fields[i] = spacerField{hard: true} + } + lastSpace = i + wasString = false + } + } + headerIdx := 0 var state encodeState - // use a fixed size stack to avoid allocations, 3 deep nested groups should be enough for most cases stackArr := [3]encodeState{} stack := stackArr[:0] for _, f := range h.fields { - switch f.(type) { + switch f := f.(type) { case groupOpen: stack = append(stack, state) state.groupStart = enc.buf.Len() @@ -246,31 +268,45 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { continue } - poppedState := state - state = stack[len(stack)-1] - stack = stack[:len(stack)-1] - - if !poppedState.printedField { - enc.buf.Truncate(poppedState.groupStart) + if state.printedField { + // keep the current state, and just roll back + // the group start index to the prior group + state.groupStart = stack[len(stack)-1].groupStart } else { - // the group was not elide, so push - // back some of the inner state to the - // outer state - state.printedField = true - state.anchored = state.anchored || poppedState.anchored - state.spacePending = state.spacePending || poppedState.spacePending + // no fields were printed in this group, so + // rollback the entire group and pop back to + // the outer state + enc.buf.Truncate(state.groupStart) + state = stack[len(stack)-1] } + // pop a state off the stack + stack = stack[:len(stack)-1] continue case spacerField: - state.spacePending = true + if state.trailingSpace { + // coalesce spaces + continue + } + if f.hard { + enc.buf.AppendByte(' ') + state.trailingSpace = true + state.pendingSpace = false + state.anchored = false + } + + state.pendingSpace = state.anchored + continue + case string: + state.pendingSpace = false + state.trailingSpace = false + state.anchored = false + enc.buf.AppendString(f) continue } - if state.anchored && state.spacePending { + if state.pendingSpace { enc.buf.AppendByte(' ') } - state.spacePending = false l := enc.buf.Len() - var wasString bool switch f := f.(type) { case headerField: hf := h.headerFields[headerIdx] @@ -287,18 +323,23 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { enc.encodeMessage(rec.Level, rec.Message) case timestampField: enc.encodeTimestamp(rec.Time) - case string: - enc.buf.AppendString(f) - wasString = true } - state.anchored = enc.buf.Len() > l - state.printedField = state.printedField || (state.anchored && !wasString) + printed := enc.buf.Len() > l + state.printedField = state.printedField || printed + if printed { + state.pendingSpace = false + state.trailingSpace = false + state.anchored = true + } else if state.pendingSpace { + // chop the last space + enc.buf = bytes.TrimSpace(enc.buf) + // leave state.spacePending as is for next + // field to handle + } } - // trim trailing space - if len(enc.buf) > 0 && enc.buf[enc.buf.Len()-1] == ' ' { - enc.buf.Truncate(enc.buf.Len() - 1) - } + // trim space + enc.buf = bytes.TrimSpace(enc.buf) // concatenate the buffers together before writing to out, so the entire // log line is written in a single Write call @@ -315,28 +356,15 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { } type encodeState struct { - groupStart int + // index in buffer of where the currently open group started. + // if group ends up being elided, buffer will rollback to this + // index + groupStart int + // whether any field in this group has not been elided. When a group + // closes, if this is false, the entire group will be elided printedField bool - // True if the last field was not elided. When anchored is true, the - // next pending space will not be elided - anchored bool - // Track if we need to print a space from a previous spacerField - spacePending bool -} - -func endsWithSpace(s string) bool { - return len(s) > 0 && s[len(s)-1] == ' ' -} -func startsWithSingleSpace(s string) bool { - lf := len(s) - if lf == 1 && s[0] == ' ' { - return true - } - if lf > 1 && s[0] == ' ' && s[1] != ' ' { - return true - } - return false + anchored, trailingSpace, pendingSpace bool } // WithAttrs implements slog.Handler. diff --git a/handler_test.go b/handler_test.go index 4aac5ba..10f32b5 100644 --- a/handler_test.go +++ b/handler_test.go @@ -716,88 +716,62 @@ func TestHandler_ReplaceAttr(t *testing.T) { } func TestHandler_CollapseSpaces(t *testing.T) { - tests := []handlerTest{ - { - name: "simple", - opts: HandlerOptions{HeaderFormat: "%l %[foo]h > %m"}, - want: "INF > collapse spaces\n", - }, - { - name: "two fields", - opts: HandlerOptions{HeaderFormat: "%l %t"}, - want: "INF\n", - }, - { - name: "two missing fields", - opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m"}, - want: "INF > collapse spaces\n", - }, - { - name: "adjacent missing fields", - opts: HandlerOptions{HeaderFormat: "%l %t%t > %m"}, - want: "INF > collapse spaces\n", - }, - { - name: "fields in a group", - opts: HandlerOptions{HeaderFormat: "%l %{%t%t%} > %m"}, - want: "INF > collapse spaces\n", - }, - { - name: "groups and spaces", - opts: HandlerOptions{HeaderFormat: "%l %{ %t %t > %} %m"}, - want: "INF collapse spaces\n", - }, - { - name: "leading space is trimmed", - opts: HandlerOptions{HeaderFormat: " %t %t %l > %t > %m"}, - want: "INF > > collapse spaces\n", - }, - { - name: "first field is elided", - opts: HandlerOptions{HeaderFormat: " %t %l > %m"}, - want: "INF > collapse spaces\n", - }, - { - name: "extra space is elided", - opts: HandlerOptions{HeaderFormat: "%t %t %l > %t > %m"}, - want: "INF > > collapse spaces\n", - }, - { - name: "groups", - opts: HandlerOptions{HeaderFormat: "%l %{[%t][%l][%t]%} > %m"}, - want: "INF [][INF][] > collapse spaces\n", - }, - { - name: "more groups", - opts: HandlerOptions{HeaderFormat: "%l %{[%t]%}%{[%l]%}%{[%t]%} > %m"}, - want: "INF [INF] > collapse spaces\n", - }, - { - name: "elided group starts after a non-space, and ends with a space", - opts: HandlerOptions{HeaderFormat: "%l%{%t %} > %m"}, - want: "INF > collapse spaces\n", - }, - { - name: "empty padded header should not elide surrounding spaces", - opts: HandlerOptions{HeaderFormat: "%l %[foo]5h > %m"}, - want: "INF > collapse spaces\n", - }, - { - name: "space between fields", - opts: HandlerOptions{HeaderFormat: "%l [%[foo]h %[bar]h] > %m"}, - want: "INF [] > collapse spaces\n", - }, - { - name: "space between and around fields", - opts: HandlerOptions{HeaderFormat: "%l [ %[foo]h %[bar]h ] > %m"}, - want: "INF [ ] > collapse spaces\n", - }, + tests2 := []struct { + desc, format, want string + }{ + {"default", "", "INF > msg"}, + {"trailing space", "%l ", "INF"}, + {"trailing space", "%l %t ", "INF"}, + {"leading space", " %l", "INF"}, + {"leading space", " %t %l", "INF"}, + {"unanchored", "%l%t %t%l", "INF INF"}, + {"unanchored", "%l%t %l", "INF INF"}, + {"unanchored", "%l %t%l", "INF INF"}, + {"unanchored", "%l %t %l", "INF INF"}, + {"unanchored", "%l %t %t %l", "INF INF"}, + {"unanchored", "%l %t", "INF"}, + {"unanchored", "%t %l", "INF"}, + {"unanchored", "%l %t%t %l", "INF INF"}, + {"unanchored", "[%l %t]", "[INF]"}, + {"unanchored", "[%t %l]", "[INF]"}, + {"unanchored", "[%l %t %l]", "[INF INF]"}, + {"unanchored", "[%l%t %l]", "[INF INF]"}, + {"unanchored", "[%l %t%l]", "[INF INF]"}, + {"unanchored", "[%l%t %t%l]", "[INF INF]"}, + {"extra spaces", " %l %t %t %l ", "INF INF"}, + {"anchored", "%l %t > %m", "INF > msg"}, + {"anchored", "[%l] [%t] > %m", "[INF] [] > msg"}, + {"anchored", "[ %l %t]", "[ INF]"}, + {"anchored", "[%l %t ]", "[INF ]"}, + {"anchored", "[%t]", "[]"}, + {"anchored", "[ %t ]", "[ ]"}, + {"groups", "%l %{%t%} %l", "INF INF"}, + {"groups", "%l %{ %t %} %l", "INF INF"}, + {"groups", "%l %{ %t %l%} %l", "INF INF INF"}, + {"groups", "%l %{ %t %l %} %l", "INF INF INF"}, + {"groups", "%l %{%l %t %l %} %l", "INF INF INF INF"}, + {"groups", "%l %{ %l %t %l %} %l", "INF INF INF INF"}, + {"groups", "%l %{ %t %t %t %} %l", "INF INF"}, + {"groups", "%l%{%t %} > %m", "INF > msg"}, + {"groups", "%l%{ %t %}%l", "INFINF"}, + {"groups with strings", "%l %{> %t %} %l", "INF INF"}, + {"groups with strings", "%l %{> %t %t %} %l", "INF INF"}, + {"groups with strings", "%l %{%t %t > %} %l", "INF INF"}, + {"groups with strings", "%l %{[%t][%l][%t]%} > ", "INF [][INF][] >"}, + {"groups with strings", "%l %{[%t]%}%{[%l]%}%{[%t]%} > %m", "INF [INF] > msg"}, + {"padded header", "%l %[foo]5h > %m", "INF > msg"}, + {"nested groups", "%l %{ %{ %{ %t %} %} %} > %m", "INF > msg"}, + {"nested groups", "%l%{ %{ %{%t%}%}%} > %m", "INF > msg"}, + {"deeply nested groups", "%l%{ %{ %{ %{ %{ %{ %t %} %} %} %} %} %} > %m", "INF > msg"}, } - for _, tt := range tests { - tt.msg = "collapse spaces" - tt.opts.NoColor = true - tt.runSubtest(t) + for _, tt := range tests2 { + handlerTest{ + name: tt.desc, + msg: "msg", + opts: HandlerOptions{HeaderFormat: tt.format, NoColor: true}, + want: tt.want + "\n", + }.runSubtest(t) } } @@ -1166,7 +1140,6 @@ type handlerTest struct { handlerFunc func(h slog.Handler) slog.Handler recFunc func(r *slog.Record) want string - wantErr string } func (ht handlerTest) runSubtest(t *testing.T) { @@ -1193,13 +1166,9 @@ func (ht handlerTest) run(t *testing.T) { } err := h.Handle(context.Background(), rec) - if ht.wantErr != "" { - AssertError(t, err) - AssertEqual(t, ht.wantErr, err.Error()) - } else { - AssertNoError(t, err) - AssertEqual(t, ht.want, buf.String()) - } + t.Log("format:", ht.opts.HeaderFormat) + AssertNoError(t, err) + AssertEqual(t, ht.want, buf.String()) } func TestHandler_writerErr(t *testing.T) { From 085e00192d48c9a3d4bad4b4084e4efb382a8e17 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Sun, 16 Feb 2025 13:34:04 -0600 Subject: [PATCH 26/44] optimization of whitespace handling --- handler.go | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/handler.go b/handler.go index 5a369c1..77e3e8d 100644 --- a/handler.go +++ b/handler.go @@ -283,27 +283,31 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { stack = stack[:len(stack)-1] continue case spacerField: - if state.trailingSpace { - // coalesce spaces + if len(enc.buf) == 0 { + // special case, always skip leading space continue } + if f.hard { - enc.buf.AppendByte(' ') - state.trailingSpace = true - state.pendingSpace = false - state.anchored = false + state.pendingHardSpace = true + } else { + // only queue a soft space if the last + // thing printed was not a string field. + state.pendingSpace = state.anchored } - state.pendingSpace = state.anchored continue case string: + if state.pendingHardSpace { + enc.buf.AppendByte(' ') + } + state.pendingHardSpace = false state.pendingSpace = false - state.trailingSpace = false state.anchored = false enc.buf.AppendString(f) continue } - if state.pendingSpace { + if state.pendingSpace || state.pendingHardSpace { enc.buf.AppendByte(' ') } l := enc.buf.Len() @@ -328,9 +332,9 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { state.printedField = state.printedField || printed if printed { state.pendingSpace = false - state.trailingSpace = false + state.pendingHardSpace = false state.anchored = true - } else if state.pendingSpace { + } else if state.pendingSpace || state.pendingHardSpace { // chop the last space enc.buf = bytes.TrimSpace(enc.buf) // leave state.spacePending as is for next @@ -338,9 +342,6 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { } } - // trim space - enc.buf = bytes.TrimSpace(enc.buf) - // concatenate the buffers together before writing to out, so the entire // log line is written in a single Write call enc.buf.copy(&enc.attrBuf) @@ -364,7 +365,7 @@ type encodeState struct { // closes, if this is false, the entire group will be elided printedField bool - anchored, trailingSpace, pendingSpace bool + anchored, pendingSpace, pendingHardSpace bool } // WithAttrs implements slog.Handler. From cc02fded32d9dadd6f8672e772f347099ff0828c Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Sun, 16 Feb 2025 19:54:30 -0600 Subject: [PATCH 27/44] Change source theme color to more generic "header" color --- bench_test.go | 8 +++---- encoding.go | 2 +- example/main.go | 1 - handler.go | 62 ++++++++++++++++++++++++++----------------------- handler_test.go | 45 +++++++++++++++++------------------ theme.go | 12 +++++----- 6 files changed, 67 insertions(+), 63 deletions(-) diff --git a/bench_test.go b/bench_test.go index 08a5c8e..afd7abf 100644 --- a/bench_test.go +++ b/bench_test.go @@ -22,11 +22,11 @@ var handlers = []struct { }{ {"dummy", &DummyHandler{}}, {"console", NewHandler(io.Discard, &HandlerOptions{Level: slog.LevelDebug, AddSource: false})}, - // {"console-headers", NewHandler(io.Discard, &HandlerOptions{Headers: []string{"foo"}, Level: slog.LevelDebug, AddSource: false})}, - // {"console-replaceattr", NewHandler(io.Discard, &HandlerOptions{Level: slog.LevelDebug, AddSource: false, ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { return a }})}, - // {"console-headers-replaceattr", NewHandler(io.Discard, &HandlerOptions{Headers: []string{"foo"}, Level: slog.LevelDebug, AddSource: false, ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { return a }})}, + {"console-headers", NewHandler(io.Discard, &HandlerOptions{HeaderFormat: "%t %{%[foo]h > %}%l %m", Level: slog.LevelDebug, AddSource: false})}, + {"console-replaceattr", NewHandler(io.Discard, &HandlerOptions{Level: slog.LevelDebug, AddSource: false, ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { return a }})}, + {"console-headers-replaceattr", NewHandler(io.Discard, &HandlerOptions{HeaderFormat: "%t %{%[foo]h > %} %l %m", Level: slog.LevelDebug, AddSource: false, ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { return a }})}, {"std-text", slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: false})}, - // {"std-text-replaceattr", slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: false, ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { return a }})}, + {"std-text-replaceattr", slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: false, ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { return a }})}, {"std-json", slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: false})}, } diff --git a/encoding.go b/encoding.go index d0622f8..797d2be 100644 --- a/encoding.go +++ b/encoding.go @@ -119,7 +119,7 @@ func (e *encoder) encodeHeader(a slog.Attr, width int, rightAlign bool) { return } - e.withColor(&e.buf, e.h.opts.Theme.Source(), func() { + e.withColor(&e.buf, e.h.opts.Theme.Header(), func() { l := e.buf.Len() e.writeValue(&e.buf, a.Value) if width <= 0 { diff --git a/example/main.go b/example/main.go index 88463e8..83e9fd9 100644 --- a/example/main.go +++ b/example/main.go @@ -13,7 +13,6 @@ func main() { console.NewHandler(os.Stderr, &console.HandlerOptions{ Level: slog.LevelDebug, AddSource: true, - HeaderFormat: "%t %l %[logger]12h > %m", TruncateSourcePath: 2, TimeFormat: "15:04:05.000", Theme: console.NewDimTheme(), diff --git a/handler.go b/handler.go index 77e3e8d..915d438 100644 --- a/handler.go +++ b/handler.go @@ -124,7 +124,7 @@ type HandlerOptions struct { HeaderFormat string } -const defaultHeaderFormat = "%t %l %[source]h > %m" +const defaultHeaderFormat = "%t %l %{%[source]h >%} %m" type Handler struct { opts HandlerOptions @@ -182,6 +182,35 @@ func NewHandler(out io.Writer, opts *HandlerOptions) *Handler { fields, headerFields := parseFormat(opts.HeaderFormat) + // find spocerFields adjacent to string fields and mark them + // as hard spaces. hard spaces should not be skipped, only + // coalesced + var wasString bool + lastSpace := -1 + for i, f := range fields { + switch f.(type) { + case headerField, levelField, messageField, timestampField: + wasString = false + lastSpace = -1 + case string: + if lastSpace != -1 { + // string immediately followed space, so the + // space is hard. + fields[lastSpace] = spacerField{hard: true} + } + wasString = true + lastSpace = -1 + case spacerField: + if wasString { + // space immedately followed a string, so the space + // is hard + fields[i] = spacerField{hard: true} + } + lastSpace = i + wasString = false + } + } + return &Handler{ opts: *opts, // Copy struct out: out, @@ -222,33 +251,6 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { return true }) - // todo: if we keep this, it needs to move into parseFormat or something - var wasString bool - lastSpace := -1 - for i, f := range h.fields { - switch f.(type) { - case headerField, levelField, messageField, timestampField: - wasString = false - lastSpace = -1 - case string: - if lastSpace != -1 { - // string immediately followed space, so the - // space is hard. - h.fields[lastSpace] = spacerField{hard: true} - } - wasString = true - lastSpace = -1 - case spacerField: - if wasString { - // space immedately followed a string, so the space - // is hard - h.fields[i] = spacerField{hard: true} - } - lastSpace = i - wasString = false - } - } - headerIdx := 0 var state encodeState // use a fixed size stack to avoid allocations, 3 deep nested groups should be enough for most cases @@ -304,7 +306,9 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { state.pendingHardSpace = false state.pendingSpace = false state.anchored = false - enc.buf.AppendString(f) + enc.withColor(&enc.buf, h.opts.Theme.Header(), func() { + enc.buf.AppendString(f) + }) continue } if state.pendingSpace || state.pendingHardSpace { diff --git a/handler_test.go b/handler_test.go index 10f32b5..ed35389 100644 --- a/handler_test.go +++ b/handler_test.go @@ -80,7 +80,7 @@ func TestHandler_TimeZero(t *testing.T) { handlerTest{ opts: HandlerOptions{TimeFormat: time.RFC3339Nano, NoColor: true}, msg: "foobar", - want: "INF > foobar\n", + want: "INF foobar\n", }.run(t) } @@ -147,7 +147,7 @@ func TestHandler_Attr(t *testing.T) { slog.Attr{}, slog.Any("", nil), }, - want: "2024-01-02 15:04:05 INF > foobar bool=true int=-12 uint=12 float=3.14 foo=bar time=2024-01-02 15:04:05 dur=1s group.foo=bar group.subgroup.foo=bar err=the error formattedError=formatted the error stringer=stringer nostringer={bar} valuer=The word is 'distant'\n", + want: "2024-01-02 15:04:05 INF foobar bool=true int=-12 uint=12 float=3.14 foo=bar time=2024-01-02 15:04:05 dur=1s group.foo=bar group.subgroup.foo=bar err=the error formattedError=formatted the error stringer=stringer nostringer={bar} valuer=The word is 'distant'\n", }.run(t) } @@ -158,7 +158,7 @@ func TestHandler_AttrsWithNewlines(t *testing.T) { attrs: []slog.Attr{ slog.String("foo", "line one\nline two"), }, - want: "INF > multiline attrs foo=line one\nline two\n", + want: "INF multiline attrs foo=line one\nline two\n", }, { name: "multiple attrs", @@ -166,7 +166,7 @@ func TestHandler_AttrsWithNewlines(t *testing.T) { slog.String("foo", "line one\nline two"), slog.String("bar", "line three\nline four"), }, - want: "INF > multiline attrs foo=line one\nline two bar=line three\nline four\n", + want: "INF multiline attrs foo=line one\nline two bar=line three\nline four\n", }, { name: "sort multiline attrs to end", @@ -177,19 +177,19 @@ func TestHandler_AttrsWithNewlines(t *testing.T) { slog.String("bar", "line three\nline four"), slog.String("color", "red"), }, - want: "INF > multiline attrs size=big weight=heavy color=red foo=line one\nline two bar=line three\nline four\n", + want: "INF multiline attrs size=big weight=heavy color=red foo=line one\nline two bar=line three\nline four\n", }, { name: "multiline message", msg: "multiline\nmessage", - want: "INF > multiline\nmessage\n", + want: "INF multiline\nmessage\n", }, { name: "preserve leading and trailing newlines", attrs: []slog.Attr{ slog.String("foo", "\nline one\nline two\n"), }, - want: "INF > multiline attrs foo=\nline one\nline two\n\n", + want: "INF multiline attrs foo=\nline one\nline two\n\n", }, { name: "multiline attr using WithAttrs", @@ -199,7 +199,7 @@ func TestHandler_AttrsWithNewlines(t *testing.T) { }) }, attrs: []slog.Attr{slog.String("bar", "baz")}, - want: "INF > multiline attrs bar=baz foo=line one\nline two\n", + want: "INF multiline attrs bar=baz foo=line one\nline two\n", }, { name: "multiline header value", @@ -227,7 +227,7 @@ func TestHandler_Groups(t *testing.T) { attrs: []slog.Attr{ slog.Group("group", slog.String("foo", "bar")), }, - want: "INF > single group group.foo=bar\n", + want: "INF single group group.foo=bar\n", }, { // '- If a group has no Attrs (even if it has a non-empty key), ignore it.' @@ -237,7 +237,7 @@ func TestHandler_Groups(t *testing.T) { slog.Group("group", slog.String("foo", "bar")), slog.Group("empty"), }, - want: "INF > empty groups should be elided group.foo=bar\n", + want: "INF empty groups should be elided group.foo=bar\n", }, { // Handlers should expand groups named "" (the empty string) into the enclosing log record. @@ -248,7 +248,7 @@ func TestHandler_Groups(t *testing.T) { slog.Group("group", slog.String("foo", "bar")), slog.Group("", slog.String("foo", "bar")), }, - want: "INF > inline group group.foo=bar foo=bar\n", + want: "INF inline group group.foo=bar foo=bar\n", }, { // A Handler should call Resolve on attribute values in groups. @@ -257,7 +257,7 @@ func TestHandler_Groups(t *testing.T) { attrs: []slog.Attr{ slog.Group("group", "stringer", theStringer{}, "valuer", &theValuer{"surreal"}), }, - want: "INF > groups with valuer members group.stringer=stringer group.valuer=The word is 'surreal'\n", + want: "INF groups with valuer members group.stringer=stringer group.valuer=The word is 'surreal'\n", }, } @@ -301,7 +301,7 @@ func TestHandler_WithAttr(t *testing.T) { }, msg: "foobar", time: testTime, - want: "2024-01-02 15:04:05 INF > foobar bool=true int=-12 uint=12 float=3.14 foo=bar time=2024-01-02 15:04:05 dur=1s stringer=stringer valuer=The word is 'awesome' group.foo=bar group.subgroup.foo=bar group.stringer=stringer group.valuer=The word is 'pizza'\n", + want: "2024-01-02 15:04:05 INF foobar bool=true int=-12 uint=12 float=3.14 foo=bar time=2024-01-02 15:04:05 dur=1s stringer=stringer valuer=The word is 'awesome' group.foo=bar group.subgroup.foo=bar group.stringer=stringer group.valuer=The word is 'pizza'\n", }, { name: "multiple withAttrs", @@ -312,7 +312,7 @@ func TestHandler_WithAttr(t *testing.T) { slog.String("baz", "buz"), }) }, - want: "INF > multiple withAttrs foo=bar baz=buz\n", + want: "INF multiple withAttrs foo=bar baz=buz\n", }, { name: "withAttrs and headers", @@ -382,7 +382,7 @@ func TestHandler_WithGroup(t *testing.T) { return h.WithGroup("group1") }, attrs: []slog.Attr{slog.String("foo", "bar")}, - want: "INF > withGroup group1.foo=bar\n", + want: "INF withGroup group1.foo=bar\n", }, { name: "withGroup and headers", @@ -398,7 +398,7 @@ func TestHandler_WithGroup(t *testing.T) { return h.WithAttrs([]slog.Attr{slog.String("bar", "baz")}).WithGroup("group1").WithAttrs([]slog.Attr{slog.String("foo", "bar")}) }, attrs: []slog.Attr{slog.String("baz", "foo")}, - want: "INF > withGroup and withAttrs bar=baz group1.foo=bar group1.baz=foo\n", + want: "INF withGroup and withAttrs bar=baz group1.foo=bar group1.baz=foo\n", }, } @@ -504,7 +504,7 @@ func TestHandler_ReplaceAttr(t *testing.T) { r.Time = time.Time{} }, noSource: true, - want: "INF > foobar size=12 color=red\n", + want: "INF foobar size=12 color=red\n", replaceAttr: func(t *testing.T, s []string, a slog.Attr) slog.Attr { switch a.Key { case slog.TimeKey, slog.SourceKey: @@ -603,7 +603,7 @@ func TestHandler_ReplaceAttr(t *testing.T) { { name: "clear source", replaceAttr: replaceAttrWith(slog.SourceKey, slog.Any(slog.SourceKey, nil)), - want: "2010-05-06 07:08:09 INF > foobar size=12 color=red\n", + want: "2010-05-06 07:08:09 INF foobar size=12 color=red\n", }, { name: "replace source", @@ -640,7 +640,7 @@ func TestHandler_ReplaceAttr(t *testing.T) { } return a }, - want: "2010-05-06 07:08:09 INF > foobar size=12 color=red\n", + want: "2010-05-06 07:08:09 INF foobar size=12 color=red\n", }, { name: "clear message", @@ -719,7 +719,7 @@ func TestHandler_CollapseSpaces(t *testing.T) { tests2 := []struct { desc, format, want string }{ - {"default", "", "INF > msg"}, + {"default", "", "INF msg"}, {"trailing space", "%l ", "INF"}, {"trailing space", "%l %t ", "INF"}, {"leading space", " %l", "INF"}, @@ -1233,8 +1233,9 @@ func TestThemes(t *testing.T) { } // Source - if theme.Source() != "" { - checkANSIMod(t, "Source", theme.Source()) + if theme.Header() != "" { + checkANSIMod(t, "Header", theme.Header()) + checkANSIMod(t, "Header", theme.Header()) // checkANSIMod(t, "AttrKey", theme.AttrKey()) } diff --git a/theme.go b/theme.go index 84e8477..f96a6be 100644 --- a/theme.go +++ b/theme.go @@ -62,7 +62,7 @@ func ToANSICode(modes ...int) ANSIMod { type Theme interface { Name() string Timestamp() ANSIMod - Source() ANSIMod + Header() ANSIMod Message() ANSIMod MessageDebug() ANSIMod @@ -79,7 +79,7 @@ type Theme interface { type ThemeDef struct { name string timestamp ANSIMod - source ANSIMod + header ANSIMod message ANSIMod messageDebug ANSIMod attrKey ANSIMod @@ -93,7 +93,7 @@ type ThemeDef struct { func (t ThemeDef) Name() string { return t.name } func (t ThemeDef) Timestamp() ANSIMod { return t.timestamp } -func (t ThemeDef) Source() ANSIMod { return t.source } +func (t ThemeDef) Header() ANSIMod { return t.header } func (t ThemeDef) Message() ANSIMod { return t.message } func (t ThemeDef) MessageDebug() ANSIMod { return t.messageDebug } func (t ThemeDef) AttrKey() ANSIMod { return t.attrKey } @@ -120,7 +120,7 @@ func NewDefaultTheme() Theme { return ThemeDef{ name: "Default", timestamp: ToANSICode(BrightBlack), - source: ToANSICode(Bold, BrightBlack), + header: ToANSICode(Bold, BrightBlack), message: ToANSICode(Bold), messageDebug: ToANSICode(), attrKey: ToANSICode(Cyan), @@ -137,7 +137,7 @@ func NewBrightTheme() Theme { return ThemeDef{ name: "Bright", timestamp: ToANSICode(Gray), - source: ToANSICode(Bold, Gray), + header: ToANSICode(Bold, Gray), message: ToANSICode(Bold, White), messageDebug: ToANSICode(), attrKey: ToANSICode(BrightCyan), @@ -154,7 +154,7 @@ func NewDimTheme() Theme { return ThemeDef{ name: "Dim", timestamp: ToANSICode(Faint), - source: ToANSICode(Bold, Faint), + header: ToANSICode(Bold, Faint), message: ToANSICode(Bold), messageDebug: ToANSICode(Bold), attrKey: ToANSICode(Faint, Cyan), From a3888885d4ba22bfbe88bcd95a5626ee49735ed0 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Mon, 17 Feb 2025 11:21:08 -0600 Subject: [PATCH 28/44] Eliminated the Dim theme... Instead just modified the default theme. --- example/main.go | 1 - handler_test.go | 1 - theme.go | 23 +++-------------------- 3 files changed, 3 insertions(+), 22 deletions(-) diff --git a/example/main.go b/example/main.go index 83e9fd9..4423b73 100644 --- a/example/main.go +++ b/example/main.go @@ -15,7 +15,6 @@ func main() { AddSource: true, TruncateSourcePath: 2, TimeFormat: "15:04:05.000", - Theme: console.NewDimTheme(), }), ) slog.SetDefault(logger) diff --git a/handler_test.go b/handler_test.go index ed35389..a532515 100644 --- a/handler_test.go +++ b/handler_test.go @@ -1182,7 +1182,6 @@ func TestThemes(t *testing.T) { for _, theme := range []Theme{ NewDefaultTheme(), NewBrightTheme(), - NewDimTheme(), } { t.Run(theme.Name(), func(t *testing.T) { level := slog.LevelInfo diff --git a/theme.go b/theme.go index f96a6be..2eb2726 100644 --- a/theme.go +++ b/theme.go @@ -122,9 +122,9 @@ func NewDefaultTheme() Theme { timestamp: ToANSICode(BrightBlack), header: ToANSICode(Bold, BrightBlack), message: ToANSICode(Bold), - messageDebug: ToANSICode(), - attrKey: ToANSICode(Cyan), - attrValue: ToANSICode(Gray), + messageDebug: ToANSICode(Bold), + attrKey: ToANSICode(Faint, Cyan), + attrValue: ToANSICode(Faint), attrValueError: ToANSICode(Bold, Red), levelError: ToANSICode(Red), levelWarn: ToANSICode(Yellow), @@ -149,20 +149,3 @@ func NewBrightTheme() Theme { levelDebug: ToANSICode(), } } - -func NewDimTheme() Theme { - return ThemeDef{ - name: "Dim", - timestamp: ToANSICode(Faint), - header: ToANSICode(Bold, Faint), - message: ToANSICode(Bold), - messageDebug: ToANSICode(Bold), - attrKey: ToANSICode(Faint, Cyan), - attrValue: ToANSICode(Faint), - attrValueError: ToANSICode(Bold, Red), - levelError: ToANSICode(Bold, Red), - levelWarn: ToANSICode(Bold, Yellow), - levelInfo: ToANSICode(Bold, Green), - levelDebug: ToANSICode(), - } -} From 72dc4d7c0b277afd48228b09a6dbdc69ccd0b211 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Mon, 17 Feb 2025 11:22:17 -0600 Subject: [PATCH 29/44] Simplify buffer For the most part, we're just using this as a byte slice, not like a bytes.Buffer(), so we don't really need to emulate that API. Just treat it as a byte slice where possible. --- buffer.go | 39 ----------------------------- buffer_test.go | 64 ++++++++++-------------------------------------- duration_test.go | 4 +-- encoding.go | 12 ++++----- handler.go | 17 +++++++------ 5 files changed, 30 insertions(+), 106 deletions(-) diff --git a/buffer.go b/buffer.go index 57f9b0b..1f12236 100644 --- a/buffer.go +++ b/buffer.go @@ -2,43 +2,22 @@ package console import ( "io" - "slices" "strconv" "time" ) type buffer []byte -func (b *buffer) Grow(n int) { - *b = slices.Grow(*b, n) -} - -func (b *buffer) Bytes() []byte { - return *b -} - func (b *buffer) String() string { return string(*b) } -func (b *buffer) Len() int { - return len(*b) -} - -func (b *buffer) Truncate(n int) { - *b = (*b)[:n] -} - func (b *buffer) Pad(n int, c byte) { for ; n > 0; n-- { b.AppendByte(byte(c)) } } -func (b *buffer) Cap() int { - return cap(*b) -} - func (b *buffer) WriteTo(dst io.Writer) (int64, error) { l := len(*b) if l == 0 { @@ -70,20 +49,6 @@ func (b *buffer) Reset() { *b = (*b)[:0] } -func (b *buffer) Clone() buffer { - return append(buffer(nil), *b...) -} - -func (b *buffer) Clip() { - *b = slices.Clip(*b) -} - -func (b *buffer) copy(src *buffer) { - if src.Len() > 0 { - b.Append(src.Bytes()) - } -} - func (b *buffer) Append(data []byte) { *b = append(*b, data...) } @@ -92,10 +57,6 @@ func (b *buffer) AppendString(s string) { *b = append(*b, s...) } -// func (b *buffer) AppendQuotedString(s string) { -// b.buff = strconv.AppendQuote(b.buff, s) -// } - func (b *buffer) AppendByte(byt byte) { *b = append(*b, byt) } diff --git a/buffer_test.go b/buffer_test.go index 5a4cdde..39170d7 100644 --- a/buffer_test.go +++ b/buffer_test.go @@ -9,16 +9,16 @@ import ( ) func TestBuffer_Append(t *testing.T) { - b := new(buffer) - AssertZero(t, b.Len()) + var b buffer + AssertZero(t, len(b)) b.AppendString("foobar") - AssertEqual(t, 6, b.Len()) + AssertEqual(t, 6, len(b)) b.AppendString("baz") - AssertEqual(t, 9, b.Len()) + AssertEqual(t, 9, len(b)) AssertEqual(t, "foobarbaz", b.String()) b.AppendByte('.') - AssertEqual(t, 10, b.Len()) + AssertEqual(t, 10, len(b)) AssertEqual(t, "foobarbaz.", b.String()) b.AppendBool(true) @@ -36,7 +36,7 @@ func TestBuffer_Append(t *testing.T) { func TestBuffer_WriteTo(t *testing.T) { dest := bytes.Buffer{} - b := new(buffer) + var b buffer n, err := b.WriteTo(&dest) AssertNoError(t, err) AssertZero(t, n) @@ -45,61 +45,23 @@ func TestBuffer_WriteTo(t *testing.T) { AssertEqual(t, len("foobar"), int(n)) AssertNoError(t, err) AssertEqual(t, "foobar", dest.String()) - AssertZero(t, b.Len()) -} - -func TestBuffer_Clone(t *testing.T) { - b := new(buffer) - b.AppendString("foobar") - b2 := b.Clone() - AssertEqual(t, b.String(), b2.String()) - AssertNotEqual(t, &b.Bytes()[0], &b2.Bytes()[0]) -} - -func TestBuffer_Copy(t *testing.T) { - b := new(buffer) - b.AppendString("foobar") - b2 := new(buffer) - b2.copy(b) - AssertEqual(t, b.String(), b2.String()) - AssertNotEqual(t, &b.Bytes()[0], &b2.Bytes()[0]) + AssertZero(t, len(b)) } func TestBuffer_Reset(t *testing.T) { - b := new(buffer) + var b buffer b.AppendString("foobar") AssertEqual(t, "foobar", b.String()) - AssertEqual(t, len("foobar"), b.Len()) - bufCap := b.Cap() + AssertEqual(t, len("foobar"), len(b)) + bufCap := cap(b) b.Reset() - AssertZero(t, b.Len()) - AssertEqual(t, bufCap, b.Cap()) -} - -func TestBuffer_Grow(t *testing.T) { - b := new(buffer) - AssertZero(t, b.Cap()) - b.Grow(12) - AssertGreaterOrEqual(t, 12, b.Cap()) - b.Grow(6) - AssertGreaterOrEqual(t, 12, b.Cap()) - b.Grow(24) - AssertGreaterOrEqual(t, 24, b.Cap()) -} - -func TestBuffer_Clip(t *testing.T) { - b := new(buffer) - b.AppendString("foobar") - b.Grow(12) - AssertGreaterOrEqual(t, 12, b.Cap()) - b.Clip() - AssertEqual(t, "foobar", b.String()) - AssertEqual(t, len("foobar"), b.Cap()) + AssertZero(t, len(b)) + AssertEqual(t, bufCap, cap(b)) } func TestBuffer_WriteTo_Err(t *testing.T) { w := writerFunc(func(b []byte) (int, error) { return 0, errors.New("nope") }) - b := new(buffer) + var b buffer b.AppendString("foobar") _, err := b.WriteTo(w) AssertError(t, err) diff --git a/duration_test.go b/duration_test.go index ee1b5a8..fcdbebe 100644 --- a/duration_test.go +++ b/duration_test.go @@ -2,6 +2,7 @@ package console import ( "bytes" + "slices" "testing" "time" ) @@ -43,8 +44,7 @@ func BenchmarkDuration(b *testing.B) { }) b.Run("append", func(b *testing.B) { - w := new(buffer) - w.Grow(2048) + w := slices.Grow(buffer{}, 2048) b.ResetTimer() for i := 0; i < b.N; i++ { w.AppendDuration(d) diff --git a/encoding.go b/encoding.go index 797d2be..3f33520 100644 --- a/encoding.go +++ b/encoding.go @@ -120,26 +120,26 @@ func (e *encoder) encodeHeader(a slog.Attr, width int, rightAlign bool) { } e.withColor(&e.buf, e.h.opts.Theme.Header(), func() { - l := e.buf.Len() + l := len(e.buf) e.writeValue(&e.buf, a.Value) if width <= 0 { return } // truncate or pad to required width - remainingWidth := l + width - e.buf.Len() + remainingWidth := l + width - len(e.buf) if remainingWidth < 0 { // truncate - e.buf.Truncate(l + width) + e.buf = e.buf[:l+width] } else if remainingWidth > 0 { if rightAlign { // For right alignment, shift the text right in-place: // 1. Get the text length - textLen := e.buf.Len() - l + textLen := len(e.buf) - l // 2. Add padding to reach final width e.buf.Pad(remainingWidth, ' ') // 3. Move the text to the right by copying from end to start for i := 0; i < textLen; i++ { - e.buf[e.buf.Len()-1-i] = e.buf[l+textLen-1-i] + e.buf[len(e.buf)-1-i] = e.buf[l+textLen-1-i] } // 4. Fill the left side with spaces for i := 0; i < remainingWidth; i++ { @@ -269,7 +269,7 @@ func (e *encoder) encodeAttr(groupPrefix string, a slog.Attr) { } } - offset := e.attrBuf.Len() + offset := len(e.attrBuf) e.writeAttr(&e.attrBuf, a, groupPrefix) // check if the last attr written has newlines in it diff --git a/handler.go b/handler.go index 915d438..82935ac 100644 --- a/handler.go +++ b/handler.go @@ -8,6 +8,7 @@ import ( "log/slog" "os" "runtime" + "slices" "strings" "time" ) @@ -260,7 +261,7 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { switch f := f.(type) { case groupOpen: stack = append(stack, state) - state.groupStart = enc.buf.Len() + state.groupStart = len(enc.buf) state.printedField = false continue case groupClose: @@ -278,7 +279,7 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { // no fields were printed in this group, so // rollback the entire group and pop back to // the outer state - enc.buf.Truncate(state.groupStart) + enc.buf = enc.buf[:state.groupStart] state = stack[len(stack)-1] } // pop a state off the stack @@ -314,7 +315,7 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { if state.pendingSpace || state.pendingHardSpace { enc.buf.AppendByte(' ') } - l := enc.buf.Len() + l := len(enc.buf) switch f := f.(type) { case headerField: hf := h.headerFields[headerIdx] @@ -332,7 +333,7 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { case timestampField: enc.encodeTimestamp(rec.Time) } - printed := enc.buf.Len() > l + printed := len(enc.buf) > l state.printedField = state.printedField || printed if printed { state.pendingSpace = false @@ -348,8 +349,8 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { // concatenate the buffers together before writing to out, so the entire // log line is written in a single Write call - enc.buf.copy(&enc.attrBuf) - enc.buf.copy(&enc.multilineAttrBuf) + enc.buf.Append(enc.attrBuf) + enc.buf.Append(enc.multilineAttrBuf) enc.buf.AppendByte('\n') if _, err := enc.buf.WriteTo(h.out); err != nil { @@ -386,11 +387,11 @@ func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { newMultiCtx := h.multilineContext if len(enc.attrBuf) > 0 { newCtx = append(newCtx, enc.attrBuf...) - newCtx.Clip() + newCtx = slices.Clip(newCtx) } if len(enc.multilineAttrBuf) > 0 { newMultiCtx = append(newMultiCtx, enc.multilineAttrBuf...) - newMultiCtx.Clip() + newMultiCtx = slices.Clip(newMultiCtx) } enc.free() From 38e9b594f368a2d990a22f278143b34f652b2c4c Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Mon, 17 Feb 2025 14:39:54 -0600 Subject: [PATCH 30/44] More test coverage --- handler.go | 22 -------- handler_test.go | 131 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 22 deletions(-) diff --git a/handler.go b/handler.go index 82935ac..d6480d5 100644 --- a/handler.go +++ b/handler.go @@ -440,28 +440,6 @@ func memoizeHeaders(enc *encoder, headerFields []headerField) []headerField { return newFields } -// ParseFormatResult contains the parsed fields and header count from a format string -type ParseFormatResult struct { - Fields []any - HeaderCount int -} - -// Equal compares two ParseFormatResults for equality -func (p ParseFormatResult) Equal(other ParseFormatResult) bool { - if p.HeaderCount != other.HeaderCount { - return false - } - if len(p.Fields) != len(other.Fields) { - return false - } - for i := range p.Fields { - if fmt.Sprintf("%#v", p.Fields[i]) != fmt.Sprintf("%#v", other.Fields[i]) { - return false - } - } - return true -} - // parseFormat parses a format string into a list of fields and the number of headerFields. // Supported format verbs: // %t - timestampField diff --git a/handler_test.go b/handler_test.go index a532515..8c1a902 100644 --- a/handler_test.go +++ b/handler_test.go @@ -16,6 +16,34 @@ import ( "time" ) +func TestNewHandler(t *testing.T) { + h := NewHandler(nil, nil) + AssertEqual(t, time.DateTime, h.opts.TimeFormat) + AssertEqual(t, NewDefaultTheme().Name(), h.opts.Theme.Name()) + AssertEqual(t, defaultHeaderFormat, h.opts.HeaderFormat) +} + +func TestHandler_Enabled(t *testing.T) { + tests := []slog.Level{ + slog.LevelDebug - 1, slog.LevelDebug, slog.LevelInfo, slog.LevelWarn, slog.LevelError, slog.LevelError + 1, + } + + for _, lvl := range tests { + t.Run(lvl.String(), func(t *testing.T) { + h := NewHandler(io.Discard, &HandlerOptions{Level: lvl}) + if h.Enabled(context.Background(), lvl-1) { + t.Errorf("Expected %v to be disabled, got: enabled", lvl-1) + } + if !h.Enabled(context.Background(), lvl) { + t.Errorf("Expected %v to be enabled, got: disabled", lvl) + } + if !h.Enabled(context.Background(), lvl+1) { + t.Errorf("Expected %v to be enabled, got: disabled", lvl+1) + } + }) + } +} + func TestHandler_TimeFormat(t *testing.T) { testTime := time.Date(2024, 01, 02, 15, 04, 05, 123456789, time.UTC) tests := []struct { @@ -555,6 +583,11 @@ func TestHandler_ReplaceAttr(t *testing.T) { replaceAttr: replaceAttrWith(slog.TimeKey, slog.Time(slog.TimeKey, time.Time{})), want: "INF " + sourceField + " > foobar size=12 color=red\n", }, + { + name: "clear timestamp attr", + replaceAttr: replaceAttrWith(slog.TimeKey, slog.Attr{}), + want: "INF " + sourceField + " > foobar size=12 color=red\n", + }, { name: "replace timestamp", replaceAttr: replaceAttrWith(slog.TimeKey, slog.Time(slog.TimeKey, time.Date(2000, 2, 3, 4, 5, 6, 0, time.UTC))), @@ -715,6 +748,88 @@ func TestHandler_ReplaceAttr(t *testing.T) { } +func TestHandler_TruncateSourcePath(t *testing.T) { + origCwd := cwd + t.Cleanup(func() { cwd = origCwd }) + + cwd = "/usr/share/proj" + absSource := slog.Source{ + File: "/var/proj/red/blue/green/yellow/main.go", + Line: 23, + } + relSource := slog.Source{ + File: "/usr/share/proj/red/blue/green/yellow/main.go", + Line: 23, + } + + tests := []handlerTest{ + { + name: "abs 1", + opts: HandlerOptions{TruncateSourcePath: 1}, + attrs: []slog.Attr{slog.Any("source", &absSource)}, + want: "INF main.go:23 >", + }, + { + name: "abs 2", + opts: HandlerOptions{TruncateSourcePath: 2}, + attrs: []slog.Attr{slog.Any("source", &absSource)}, + want: "INF yellow/main.go:23 >", + }, + { + name: "abs 3", + opts: HandlerOptions{TruncateSourcePath: 3}, + attrs: []slog.Attr{slog.Any("source", &absSource)}, + want: "INF green/yellow/main.go:23 >", + }, + { + name: "abs 4", + opts: HandlerOptions{TruncateSourcePath: 4}, + attrs: []slog.Attr{slog.Any("source", &absSource)}, + want: "INF blue/green/yellow/main.go:23 >", + }, + { + name: "default", + attrs: []slog.Attr{slog.Any("source", &absSource)}, + want: "INF /var/proj/red/blue/green/yellow/main.go:23 >", + }, + { + name: "relative", + attrs: []slog.Attr{slog.Any("source", &relSource)}, + want: "INF red/blue/green/yellow/main.go:23 >", + }, + { + name: "relative 1", + opts: HandlerOptions{TruncateSourcePath: 1}, + attrs: []slog.Attr{slog.Any("source", &relSource)}, + want: "INF main.go:23 >", + }, + { + name: "relative 2", + opts: HandlerOptions{TruncateSourcePath: 2}, + attrs: []slog.Attr{slog.Any("source", &relSource)}, + want: "INF yellow/main.go:23 >", + }, + { + name: "relative 3", + opts: HandlerOptions{TruncateSourcePath: 3}, + attrs: []slog.Attr{slog.Any("source", &relSource)}, + want: "INF green/yellow/main.go:23 >", + }, + { + name: "relative 4", + opts: HandlerOptions{TruncateSourcePath: 4}, + attrs: []slog.Attr{slog.Any("source", &relSource)}, + want: "INF blue/green/yellow/main.go:23 >", + }, + } + + for _, tt := range tests { + tt.opts.NoColor = true + tt.want += "\n" + tt.runSubtest(t) + } +} + func TestHandler_CollapseSpaces(t *testing.T) { tests2 := []struct { desc, format, want string @@ -805,6 +920,16 @@ func TestHandler_HeaderFormat_Groups(t *testing.T) { attrs: []slog.Attr{slog.String("foo", "bar"), slog.String("bar", "baz")}, want: "INF [bar baz] > groups\n", }, + { + name: "open group not closed", + opts: HandlerOptions{HeaderFormat: "%l %{ > %m"}, + want: "INF > groups\n", + }, + { + name: "closed group not opened", + opts: HandlerOptions{HeaderFormat: "%l %} > %m"}, + want: "INF > groups\n", + }, } for _, tt := range tests { @@ -1046,6 +1171,12 @@ func TestHandler_HeaderFormat(t *testing.T) { attrs: []slog.Attr{slog.String("foo", "bar")}, want: "with headers %!(MISSING_VERB) foo=bar\n", }, + { + name: "missing verb with modifiers", + opts: HandlerOptions{HeaderFormat: "%m %[slog]+-4", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "with headers %!(MISSING_VERB) foo=bar\n", + }, { name: "invalid modifier", opts: HandlerOptions{HeaderFormat: "%m %-L", NoColor: true}, From 5730c10382905e5b166ac43de278339089744b4d Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Fri, 28 Feb 2025 17:52:49 -0600 Subject: [PATCH 31/44] Trim space around the message and the entire log line Just generally favor trimming space over preserving it, since this handler is primarily concerned with readability --- encoding.go | 2 +- handler_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/encoding.go b/encoding.go index 3f33520..d2f66a5 100644 --- a/encoding.go +++ b/encoding.go @@ -107,7 +107,7 @@ func (e *encoder) encodeMessage(level slog.Level, msg string) { return } - e.writeColoredString(&e.buf, msg, style) + e.writeColoredString(&e.buf, strings.TrimSpace(msg), style) } func (e *encoder) encodeHeader(a slog.Attr, width int, rightAlign bool) { diff --git a/handler_test.go b/handler_test.go index 8c1a902..ee2ea7c 100644 --- a/handler_test.go +++ b/handler_test.go @@ -213,11 +213,11 @@ func TestHandler_AttrsWithNewlines(t *testing.T) { want: "INF multiline\nmessage\n", }, { - name: "preserve leading and trailing newlines", + name: "trim leading and trailing newlines", attrs: []slog.Attr{ slog.String("foo", "\nline one\nline two\n"), }, - want: "INF multiline attrs foo=\nline one\nline two\n\n", + want: "INF multiline attrs foo=\nline one\nline two\n", }, { name: "multiline attr using WithAttrs", From 3fa3024a6875406bcd29c4c8253cc74ef88d12be Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Fri, 28 Feb 2025 17:58:36 -0600 Subject: [PATCH 32/44] Tweaked the default theme again --- theme.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/theme.go b/theme.go index 2eb2726..df3aa24 100644 --- a/theme.go +++ b/theme.go @@ -119,12 +119,12 @@ func (t ThemeDef) Level(level slog.Level) ANSIMod { func NewDefaultTheme() Theme { return ThemeDef{ name: "Default", - timestamp: ToANSICode(BrightBlack), - header: ToANSICode(Bold, BrightBlack), + timestamp: ToANSICode(Faint), + header: ToANSICode(Faint, Bold), message: ToANSICode(Bold), messageDebug: ToANSICode(Bold), attrKey: ToANSICode(Faint, Cyan), - attrValue: ToANSICode(Faint), + attrValue: ToANSICode(), attrValueError: ToANSICode(Bold, Red), levelError: ToANSICode(Red), levelWarn: ToANSICode(Yellow), From 07d1f6a5cd5951e700ea9df9c2dddb9e602fc0cc Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Fri, 28 Feb 2025 18:07:13 -0600 Subject: [PATCH 33/44] Added verbs for attrs and source %a verb inserts the attributes (these were implicitly added to the end of the log line before, but now must be explicit) %s verb inserts the source. If it is not present in the format, source is just treated like another attribute. --- encoding.go | 22 +++++++++++ handler.go | 78 ++++++++++++++++++++++++++++-------- handler_test.go | 103 ++++++++++++++++++++++++++---------------------- theme.go | 6 ++- 4 files changed, 143 insertions(+), 66 deletions(-) diff --git a/encoding.go b/encoding.go index d2f66a5..0c87c9d 100644 --- a/encoding.go +++ b/encoding.go @@ -229,6 +229,28 @@ func (e *encoder) encodeLevel(l slog.Level, abbreviated bool) { } } +func (e *encoder) encodeSource(src slog.Source) { + if src.File == "" && src.Line == 0 { + // elide empty source + return + } + + v := slog.AnyValue(&src) + + if e.h.opts.ReplaceAttr != nil { + attr := e.h.opts.ReplaceAttr(nil, slog.Attr{Key: slog.SourceKey, Value: v}) + attr.Value = attr.Value.Resolve() + + if attr.Value.Equal(slog.Value{}) { + // elide + return + } + v = attr.Value + } + // Use source style for the value + e.writeColoredValue(&e.buf, v, e.h.opts.Theme.Source()) +} + func (e *encoder) encodeAttr(groupPrefix string, a slog.Attr) { a.Value = a.Value.Resolve() diff --git a/handler.go b/handler.go index d6480d5..10d775b 100644 --- a/handler.go +++ b/handler.go @@ -74,6 +74,8 @@ type HandlerOptions struct { // %l abbreviated level (e.g. "INF") // %L level (e.g. "INFO") // %m message + // %s source (if omitted, source is just handled as an attribute) + // %a attributes // %[key]h header with the given key. // %{ group open // %} group close @@ -125,7 +127,7 @@ type HandlerOptions struct { HeaderFormat string } -const defaultHeaderFormat = "%t %l %{%[source]h >%} %m" +const defaultHeaderFormat = "%t %l %{%s >%} %m %a" type Handler struct { opts HandlerOptions @@ -135,9 +137,11 @@ type Handler struct { context, multilineContext buffer fields []any headerFields []headerField + sourceAsAttr bool } type timestampField struct{} + type headerField struct { groupPrefix string key string @@ -153,12 +157,16 @@ type levelField struct { type messageField struct{} type groupOpen struct{} +type attrsField struct{} + type groupClose struct{} -type spacerField struct { +type spacer struct { hard bool } +type sourceField struct{} + var _ slog.Handler = (*Handler)(nil) // NewHandler creates a Handler that writes to w, @@ -212,6 +220,16 @@ func NewHandler(out io.Writer, opts *HandlerOptions) *Handler { } } + // Check if the parsed fields include any sourceField instances + // If not, set sourceAsAttr to true so source is handled as a regular attribute + sourceAsAttr := true + for _, f := range fields { + if _, ok := f.(sourceField); ok { + sourceAsAttr = false + break + } + } + return &Handler{ opts: *opts, // Copy struct out: out, @@ -219,6 +237,7 @@ func NewHandler(out io.Writer, opts *HandlerOptions) *Handler { context: nil, fields: fields, headerFields: headerFields, + sourceAsAttr: sourceAsAttr, } } @@ -230,18 +249,21 @@ func (h *Handler) Enabled(_ context.Context, l slog.Level) bool { func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { enc := newEncoder(h) + var src slog.Source + if h.opts.AddSource && rec.PC > 0 { - src := slog.Source{} frame, _ := runtime.CallersFrames([]uintptr{rec.PC}).Next() src.Function = frame.Function src.File = frame.File src.Line = frame.Line + + if h.sourceAsAttr { // the source attr should not be inside any open groups groups := enc.groups enc.groups = nil enc.encodeAttr("", slog.Any(slog.SourceKey, &src)) enc.groups = groups - // rec.AddAttrs(slog.Any(slog.SourceKey, &src)) + } } enc.attrBuf.Append(h.context) @@ -330,6 +352,18 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { enc.encodeLevel(rec.Level, f.abbreviated) case messageField: enc.encodeMessage(rec.Level, rec.Message) + case attrsField: + // trim the attrBuf and multilineAttrBuf to remove leading spaces + // but leave a space between attrBuf and multilineAttrBuf + if len(enc.attrBuf) > 0 { + enc.attrBuf = bytes.TrimSpace(enc.attrBuf) + } else if len(enc.multilineAttrBuf) > 0 { + enc.multilineAttrBuf = bytes.TrimSpace(enc.multilineAttrBuf) + } + enc.buf.Append(enc.attrBuf) + enc.buf.Append(enc.multilineAttrBuf) + case sourceField: + enc.encodeSource(src) case timestampField: enc.encodeTimestamp(rec.Time) } @@ -347,10 +381,6 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { } } - // concatenate the buffers together before writing to out, so the entire - // log line is written in a single Write call - enc.buf.Append(enc.attrBuf) - enc.buf.Append(enc.multilineAttrBuf) enc.buf.AppendByte('\n') if _, err := enc.buf.WriteTo(h.out); err != nil { @@ -405,6 +435,7 @@ func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { groups: h.groups, fields: h.fields, headerFields: headerFields, + sourceAsAttr: h.sourceAsAttr, } } @@ -423,6 +454,7 @@ func (h *Handler) WithGroup(name string) slog.Handler { groups: append(h.groups, name), fields: h.fields, headerFields: h.headerFields, + sourceAsAttr: h.sourceAsAttr, } } @@ -441,18 +473,25 @@ func memoizeHeaders(enc *encoder, headerFields []headerField) []headerField { } // parseFormat parses a format string into a list of fields and the number of headerFields. +// // Supported format verbs: +// // %t - timestampField -// %h - headerField, requires [name] modifier, supports width, - and + modifiers +// %h - headerField, requires the [name] modifier. +// Supports width, right-alignment (-), and non-capturing (+) modifiers. // %m - messageField -// %l - abbreviated levelField -// %L - non-abbreviated levelField +// %l - abbreviated levelField: The log level in abbreviated form (e.g., "INF"). +// %L - non-abbreviated levelField: The log level in full form (e.g., "INFO"). +// %{ - groupOpen +// %} - groupClose +// %s - sourceField // // Modifiers: -// [name]: the key of the attribute to capture as a header, required -// width: int fixed width, optional -// -: for right alignment, optional -// +: for non-capturing header, optional +// +// [name] (for %h): The key of the attribute to capture as a header. This modifier is required for the %h verb. +// width (for %h): An integer specifying the fixed width of the header. This modifier is optional. +// - (for %h): Indicates right-alignment of the header. This modifier is optional. +// + (for %h): Indicates a non-capturing header. This modifier is optional. // // Examples: // @@ -463,13 +502,14 @@ func memoizeHeaders(enc *encoder, headerFields []headerField) []headerField { // "%t %l %[key1]h %[key2]h %m" // timestamp, level, header with key "key1", header with key "key2", message // "%t %l %[key]10h %m" // timestamp, level, header with key "key" and width 10, message // "%t %l %[key]-10h %m" // timestamp, level, right-aligned header with key "key" and width 10, message -// "%t %l %[key]10+h %m" // timestamp, level, captured header with key "key" and width 10, message -// "%t %l %[key]-10+h %m" // timestamp, level, right-aligned captured header with key "key" and width 10, message +// "%t %l %[key]10+h %m" // timestamp, level, non-captured header with key "key" and width 10, message +// "%t %l %[key]-10+h %m" // timestamp, level, right-aligned non-captured header with key "key" and width 10, message // "%t %l %L %m" // timestamp, abbreviated level, non-abbreviated level, message // "%t %l %L- %m" // timestamp, abbreviated level, right-aligned non-abbreviated level, message // "%t %l %m string literal" // timestamp, level, message, and then " string literal" // "prefix %t %l %m suffix" // "prefix ", timestamp, level, message, and then " suffix" // "%% %t %l %m" // literal "%", timestamp, level, message +// "%t %l %s" // timestamp, level, source location (e.g., "file.go:123 functionName") // // Note that headers will "capture" their matching attribute by default, which means that attribute will not // be included in the attributes section of the log line, and will not be matched by subsequent header fields. @@ -598,6 +638,10 @@ func parseFormat(format string) (fields []any, headerFields []headerField) { field = groupOpen{} case '}': field = groupClose{} + case 's': + field = sourceField{} + case 'a': + field = attrsField{} default: fields = append(fields, fmt.Sprintf("%%!%c(INVALID_VERB)", format[i])) continue diff --git a/handler_test.go b/handler_test.go index ee2ea7c..efd37d9 100644 --- a/handler_test.go +++ b/handler_test.go @@ -92,7 +92,7 @@ func TestHandler_TimeFormat(t *testing.T) { opts: HandlerOptions{ TimeFormat: tt.timeFormat, NoColor: true, - HeaderFormat: "%t", + HeaderFormat: "%t %m %a", }, attrs: tt.attrs, want: tt.want, @@ -414,7 +414,7 @@ func TestHandler_WithGroup(t *testing.T) { }, { name: "withGroup and headers", - opts: HandlerOptions{HeaderFormat: "%l %[group1.foo]h %[bar]h > %m"}, + opts: HandlerOptions{HeaderFormat: "%l %[group1.foo]h %[bar]h > %m %a"}, handlerFunc: func(h slog.Handler) slog.Handler { return h.WithGroup("group1").WithAttrs([]slog.Attr{slog.String("foo", "bar"), slog.String("bar", "baz")}) }, @@ -444,7 +444,7 @@ func TestHandler_WithGroup(t *testing.T) { buf := bytes.Buffer{} h := NewHandler(&buf, &HandlerOptions{ - HeaderFormat: "%m", + HeaderFormat: "%m %a", TimeFormat: "0", NoColor: true, // the only state which WithGroup() might corrupt is the list of groups @@ -767,59 +767,59 @@ func TestHandler_TruncateSourcePath(t *testing.T) { name: "abs 1", opts: HandlerOptions{TruncateSourcePath: 1}, attrs: []slog.Attr{slog.Any("source", &absSource)}, - want: "INF main.go:23 >", + want: "INF source=main.go:23", }, { name: "abs 2", opts: HandlerOptions{TruncateSourcePath: 2}, attrs: []slog.Attr{slog.Any("source", &absSource)}, - want: "INF yellow/main.go:23 >", + want: "INF source=yellow/main.go:23", }, { name: "abs 3", opts: HandlerOptions{TruncateSourcePath: 3}, attrs: []slog.Attr{slog.Any("source", &absSource)}, - want: "INF green/yellow/main.go:23 >", + want: "INF source=green/yellow/main.go:23", }, { name: "abs 4", opts: HandlerOptions{TruncateSourcePath: 4}, attrs: []slog.Attr{slog.Any("source", &absSource)}, - want: "INF blue/green/yellow/main.go:23 >", + want: "INF source=blue/green/yellow/main.go:23", }, { name: "default", attrs: []slog.Attr{slog.Any("source", &absSource)}, - want: "INF /var/proj/red/blue/green/yellow/main.go:23 >", + want: "INF source=/var/proj/red/blue/green/yellow/main.go:23", }, { name: "relative", attrs: []slog.Attr{slog.Any("source", &relSource)}, - want: "INF red/blue/green/yellow/main.go:23 >", + want: "INF source=red/blue/green/yellow/main.go:23", }, { name: "relative 1", opts: HandlerOptions{TruncateSourcePath: 1}, attrs: []slog.Attr{slog.Any("source", &relSource)}, - want: "INF main.go:23 >", + want: "INF source=main.go:23", }, { name: "relative 2", opts: HandlerOptions{TruncateSourcePath: 2}, attrs: []slog.Attr{slog.Any("source", &relSource)}, - want: "INF yellow/main.go:23 >", + want: "INF source=yellow/main.go:23", }, { name: "relative 3", opts: HandlerOptions{TruncateSourcePath: 3}, attrs: []slog.Attr{slog.Any("source", &relSource)}, - want: "INF green/yellow/main.go:23 >", + want: "INF source=green/yellow/main.go:23", }, { name: "relative 4", opts: HandlerOptions{TruncateSourcePath: 4}, attrs: []slog.Attr{slog.Any("source", &relSource)}, - want: "INF blue/green/yellow/main.go:23 >", + want: "INF source=blue/green/yellow/main.go:23", }, } @@ -960,7 +960,7 @@ func TestHandler_HeaderFormat(t *testing.T) { }, { name: "one header", - opts: HandlerOptions{HeaderFormat: "%l %[foo]h > %m", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%l %[foo]h > %m %a", NoColor: true}, attrs: []slog.Attr{ slog.String("foo", "bar"), slog.String("bar", "baz"), @@ -969,7 +969,7 @@ func TestHandler_HeaderFormat(t *testing.T) { }, { name: "two headers", - opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m %a", NoColor: true}, attrs: []slog.Attr{ slog.String("foo", "bar"), slog.String("bar", "baz"), @@ -978,7 +978,7 @@ func TestHandler_HeaderFormat(t *testing.T) { }, { name: "two headers alt order", - opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m %a", NoColor: true}, attrs: []slog.Attr{ slog.String("bar", "baz"), slog.String("foo", "bar"), @@ -987,19 +987,19 @@ func TestHandler_HeaderFormat(t *testing.T) { }, { name: "missing headers", - opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[bar]h > %m %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, want: "INF bar > with headers\n", // missing headers are omitted }, { name: "missing headers, no space", - opts: HandlerOptions{HeaderFormat: "%l%[foo]h%[bar]h>%m", NoColor: true}, // no spaces between headers or level/message + opts: HandlerOptions{HeaderFormat: "%l%[foo]h%[bar]h>%m %a", NoColor: true}, // no spaces between headers or level/message attrs: []slog.Attr{slog.String("foo", "bar")}, want: "INFbar>with headers\n", }, { name: "header without group prefix does not match attr in group", - opts: HandlerOptions{HeaderFormat: "%l %[foo]h > %m", NoColor: true}, // header is an attribute inside a group + opts: HandlerOptions{HeaderFormat: "%l %[foo]h > %m %a", NoColor: true}, // header is an attribute inside a group attrs: []slog.Attr{slog.String("foo", "bar")}, handlerFunc: func(h slog.Handler) slog.Handler { return h.WithGroup("group1") @@ -1008,7 +1008,7 @@ func TestHandler_HeaderFormat(t *testing.T) { }, { name: "header with group prefix", - opts: HandlerOptions{HeaderFormat: "%l %[group1.foo]h > %m", NoColor: true}, // header is an attribute inside a group + opts: HandlerOptions{HeaderFormat: "%l %[group1.foo]h > %m %a", NoColor: true}, // header is an attribute inside a group attrs: []slog.Attr{slog.String("foo", "bar")}, handlerFunc: func(h slog.Handler) slog.Handler { return h.WithGroup("group1") @@ -1017,7 +1017,7 @@ func TestHandler_HeaderFormat(t *testing.T) { }, { name: "header in nested groups", - opts: HandlerOptions{HeaderFormat: "%l %[group1.group2.foo]h > %m", NoColor: true}, // header is an attribute inside a group + opts: HandlerOptions{HeaderFormat: "%l %[group1.group2.foo]h > %m %a", NoColor: true}, // header is an attribute inside a group attrs: []slog.Attr{slog.String("foo", "bar")}, handlerFunc: func(h slog.Handler) slog.Handler { return h.WithGroup("group1").WithGroup("group2") @@ -1026,19 +1026,19 @@ func TestHandler_HeaderFormat(t *testing.T) { }, { name: "header in group attr, no match", - opts: HandlerOptions{HeaderFormat: "%l %[foo]h > %m", NoColor: true}, // header is an attribute inside a group + opts: HandlerOptions{HeaderFormat: "%l %[foo]h > %m %a", NoColor: true}, // header is an attribute inside a group attrs: []slog.Attr{slog.Group("group1", slog.String("foo", "bar"))}, want: "INF > with headers group1.foo=bar\n", }, { name: "header in group attr, match", - opts: HandlerOptions{HeaderFormat: "%l %[group1.foo]h > %m", NoColor: true}, // header is an attribute inside a group + opts: HandlerOptions{HeaderFormat: "%l %[group1.foo]h > %m %a", NoColor: true}, // header is an attribute inside a group attrs: []slog.Attr{slog.Group("group1", slog.String("foo", "bar"))}, want: "INF bar > with headers\n", }, { name: "header and withGroup and nested group", - opts: HandlerOptions{HeaderFormat: "%l %[group1.foo]h %[group1.group2.bar]h > %m", NoColor: true}, // header is group2.attr0, attr0 is in root + opts: HandlerOptions{HeaderFormat: "%l %[group1.foo]h %[group1.group2.bar]h > %m %a", NoColor: true}, // header is group2.attr0, attr0 is in root attrs: []slog.Attr{slog.String("foo", "bar"), slog.Group("group2", slog.String("bar", "baz"))}, handlerFunc: func(h slog.Handler) slog.Handler { return h.WithGroup("group1") @@ -1047,7 +1047,7 @@ func TestHandler_HeaderFormat(t *testing.T) { }, { name: "no header", - opts: HandlerOptions{HeaderFormat: "%l > %m", NoColor: true}, // no header + opts: HandlerOptions{HeaderFormat: "%l > %m %a", NoColor: true}, // no header attrs: []slog.Attr{slog.String("foo", "bar")}, want: "INF > with headers foo=bar\n", }, @@ -1061,119 +1061,125 @@ func TestHandler_HeaderFormat(t *testing.T) { opts: HandlerOptions{HeaderFormat: "%m", NoColor: true}, // just message want: "with headers\n", }, + { + name: "just attrs", + opts: HandlerOptions{HeaderFormat: "%a", NoColor: true}, // just attrs + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "foo=bar\n", + }, { name: "source not in the header", handlerFunc: func(h slog.Handler) slog.Handler { return h.WithGroup("group1").WithAttrs([]slog.Attr{slog.String("foo", "bar")}) }, - opts: HandlerOptions{HeaderFormat: "%l > %m", NoColor: true, AddSource: true}, // header is foo, not source + opts: HandlerOptions{HeaderFormat: "%l > %m %a", NoColor: true, AddSource: true}, // header is foo, not source want: "INF > with headers source=" + sourceField + " group1.foo=bar\n", }, { name: "header matches a group attr should skip header", attrs: []slog.Attr{slog.Group("group1", slog.String("foo", "bar"))}, - opts: HandlerOptions{HeaderFormat: "%l %[group1]h > %m", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%l %[group1]h > %m %a", NoColor: true}, want: "INF > with headers group1.foo=bar\n", }, { name: "repeated header with capture", - opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[foo]h > %m", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%l %[foo]h %[foo]h > %m %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, want: "INF bar > with headers\n", // Second header is ignored since foo was captured by first header }, { name: "non-capturing header", - opts: HandlerOptions{HeaderFormat: "%l %[logger]h %[request_id]+h > %m", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%l %[logger]h %[request_id]+h > %m %a", NoColor: true}, attrs: []slog.Attr{slog.String("logger", "app"), slog.String("request_id", "123")}, want: "INF app 123 > with headers request_id=123\n", }, { name: "non-capturing header captured by another header", - opts: HandlerOptions{HeaderFormat: "%l %[logger]+h %[logger]h > %m", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%l %[logger]+h %[logger]h > %m %a", NoColor: true}, attrs: []slog.Attr{slog.String("logger", "app")}, want: "INF app app > with headers\n", }, { name: "multiple non-capturing headers matching same attr", - opts: HandlerOptions{HeaderFormat: "%l %[logger]+h %[logger]+h > %m", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%l %[logger]+h %[logger]+h > %m %a", NoColor: true}, attrs: []slog.Attr{slog.String("logger", "app")}, want: "INF app app > with headers logger=app\n", }, { name: "repeated timestamp, level and message fields", - opts: HandlerOptions{HeaderFormat: "%t %l %m %t %l %m", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%t %l %m %t %l %m %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, want: "2024-01-02 15:04:05 INF with headers 2024-01-02 15:04:05 INF with headers foo=bar\n", }, { name: "missing header and multiple spaces", - opts: HandlerOptions{HeaderFormat: "%l %[missing]h %[foo]h > %m", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%l %[missing]h %[foo]h > %m %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, want: "INF bar > with headers\n", }, { name: "fixed width header left aligned", - opts: HandlerOptions{HeaderFormat: "%l %[foo]10h > %m", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%l %[foo]10h > %m %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, want: "INF bar > with headers\n", }, { name: "fixed width header right aligned", - opts: HandlerOptions{HeaderFormat: "%l %[foo]-10h > %m", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%l %[foo]-10h > %m %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, want: "INF bar > with headers\n", }, { name: "fixed width header truncated", - opts: HandlerOptions{HeaderFormat: "%l %[foo]3h > %m", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%l %[foo]3h > %m %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "barbaz")}, want: "INF bar > with headers\n", }, { name: "fixed width header with spaces", - opts: HandlerOptions{HeaderFormat: "%l %[foo]10h %[bar]5h > %m", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%l %[foo]10h %[bar]5h > %m %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "hello"), slog.String("bar", "world")}, want: "INF hello world > with headers\n", }, { name: "fixed width non-capturing header", - opts: HandlerOptions{HeaderFormat: "%l %[foo]+-10h > %m", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%l %[foo]+-10h > %m %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, want: "INF bar > with headers foo=bar\n", }, { name: "fixed width header missing attr", - opts: HandlerOptions{HeaderFormat: "%l %[missing]10h > %m", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%l %[missing]10h > %m %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, want: "INF > with headers foo=bar\n", }, { name: "non-abbreviated levels", - opts: HandlerOptions{HeaderFormat: "%L > %m", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%L > %m %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, want: "INFO > with headers foo=bar\n", }, { name: "alternate text", - opts: HandlerOptions{HeaderFormat: "prefix [%l] [%[foo]h] %m suffix > ", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "prefix [%l] [%[foo]h] %m suffix > %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, want: "prefix [INF] [bar] with headers suffix >\n", }, { name: "escaped percent", - opts: HandlerOptions{HeaderFormat: "prefix %% [%l] %m", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "prefix %% [%l] %m %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, want: "prefix % [INF] with headers foo=bar\n", }, { name: "missing verb", - opts: HandlerOptions{HeaderFormat: "%m %", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%m % %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, want: "with headers %!(MISSING_VERB) foo=bar\n", }, { name: "missing verb with modifiers", - opts: HandlerOptions{HeaderFormat: "%m %[slog]+-4", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%m %[slog]+-4 %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, want: "with headers %!(MISSING_VERB) foo=bar\n", }, @@ -1185,25 +1191,25 @@ func TestHandler_HeaderFormat(t *testing.T) { }, { name: "invalid verb", - opts: HandlerOptions{HeaderFormat: "%l %x %m", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%l %x %m %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, want: "INF %!x(INVALID_VERB) with headers foo=bar\n", }, { name: "missing header name", - opts: HandlerOptions{HeaderFormat: "%m %h", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%m %h %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, want: "with headers %!h(MISSING_HEADER_NAME) foo=bar\n", }, { name: "missing closing bracket in header", - opts: HandlerOptions{HeaderFormat: "%m %[fooh >", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%m %[fooh > %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, want: "with headers %!(MISSING_CLOSING_BRACKET) > foo=bar\n", }, { name: "zero PC", - opts: HandlerOptions{HeaderFormat: "%l %[source]h > %m", NoColor: true, AddSource: true}, + opts: HandlerOptions{HeaderFormat: "%l %[source]h > %m %a", NoColor: true, AddSource: true}, recFunc: func(r *slog.Record) { r.PC = 0 }, @@ -1298,6 +1304,7 @@ func (ht handlerTest) run(t *testing.T) { err := h.Handle(context.Background(), rec) t.Log("format:", ht.opts.HeaderFormat) + t.Log(buf.String()) AssertNoError(t, err) AssertEqual(t, ht.want, buf.String()) } diff --git a/theme.go b/theme.go index df3aa24..2531c76 100644 --- a/theme.go +++ b/theme.go @@ -63,7 +63,7 @@ type Theme interface { Name() string Timestamp() ANSIMod Header() ANSIMod - + Source() ANSIMod Message() ANSIMod MessageDebug() ANSIMod AttrKey() ANSIMod @@ -80,6 +80,7 @@ type ThemeDef struct { name string timestamp ANSIMod header ANSIMod + source ANSIMod message ANSIMod messageDebug ANSIMod attrKey ANSIMod @@ -94,6 +95,7 @@ type ThemeDef struct { func (t ThemeDef) Name() string { return t.name } func (t ThemeDef) Timestamp() ANSIMod { return t.timestamp } func (t ThemeDef) Header() ANSIMod { return t.header } +func (t ThemeDef) Source() ANSIMod { return t.source } func (t ThemeDef) Message() ANSIMod { return t.message } func (t ThemeDef) MessageDebug() ANSIMod { return t.messageDebug } func (t ThemeDef) AttrKey() ANSIMod { return t.attrKey } @@ -121,6 +123,7 @@ func NewDefaultTheme() Theme { name: "Default", timestamp: ToANSICode(Faint), header: ToANSICode(Faint, Bold), + source: ToANSICode(Faint, Italic), message: ToANSICode(Bold), messageDebug: ToANSICode(Bold), attrKey: ToANSICode(Faint, Cyan), @@ -138,6 +141,7 @@ func NewBrightTheme() Theme { name: "Bright", timestamp: ToANSICode(Gray), header: ToANSICode(Bold, Gray), + source: ToANSICode(Gray, Bold, Italic), message: ToANSICode(Bold, White), messageDebug: ToANSICode(), attrKey: ToANSICode(BrightCyan), From 4a1087a1c5e6ddeedbed2027fbbe7309b62351c4 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Fri, 28 Feb 2025 18:12:13 -0600 Subject: [PATCH 34/44] refactoring --- handler.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/handler.go b/handler.go index 10d775b..aeb5126 100644 --- a/handler.go +++ b/handler.go @@ -205,15 +205,15 @@ func NewHandler(out io.Writer, opts *HandlerOptions) *Handler { if lastSpace != -1 { // string immediately followed space, so the // space is hard. - fields[lastSpace] = spacerField{hard: true} + fields[lastSpace] = spacer{hard: true} } wasString = true lastSpace = -1 - case spacerField: + case spacer: if wasString { // space immedately followed a string, so the space // is hard - fields[i] = spacerField{hard: true} + fields[i] = spacer{hard: true} } lastSpace = i wasString = false @@ -307,7 +307,7 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { // pop a state off the stack stack = stack[:len(stack)-1] continue - case spacerField: + case spacer: if len(enc.buf) == 0 { // special case, always skip leading space continue @@ -526,7 +526,7 @@ func parseFormat(format string) (fields []any, headerFields []headerField) { for i := 0; i < len(format); i++ { if format[i] == ' ' { if !lastWasSpace { - fields = append(fields, spacerField{}) + fields = append(fields, spacer{}) lastWasSpace = true } continue From 9448e7d0c2a2bd6b544369f350943df135659d25 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Fri, 28 Feb 2025 19:17:54 -0600 Subject: [PATCH 35/44] Added a style modifier to group open Styles any fixed strings inside the group. defaults to the header style --- handler.go | 87 +++++++++++- handler_test.go | 371 ++++++++++++++++++++++++++++-------------------- 2 files changed, 299 insertions(+), 159 deletions(-) diff --git a/handler.go b/handler.go index aeb5126..f7f0909 100644 --- a/handler.go +++ b/handler.go @@ -78,6 +78,7 @@ type HandlerOptions struct { // %a attributes // %[key]h header with the given key. // %{ group open + // %(style){ group open with style - applies the specified Theme style to any strings in the group // %} group close // // Headers print the value of the attribute with the given key, and remove that @@ -105,6 +106,12 @@ type HandlerOptions struct { // both attributes are not present, or were elided by ReplaceAttr, then this will print "INF msg". Groups can // be nested. // + // Groups can also be styled using the Theme styles by specifying a style in parentheses after the percent sign: + // + // "%l %(source){ %[logger]h %} %m" + // + // will apply the source style from the Theme to the fixed strings in the group. By default, the Header style is used. + // // Whitespace is generally merged to leave a single space between fields. Leading and trailing whitespace is trimmed. // // Examples: @@ -156,9 +163,11 @@ type levelField struct { } type messageField struct{} -type groupOpen struct{} type attrsField struct{} +type groupOpen struct { + style string +} type groupClose struct{} type spacer struct { @@ -189,7 +198,7 @@ func NewHandler(out io.Writer, opts *HandlerOptions) *Handler { opts.HeaderFormat = defaultHeaderFormat // default format } - fields, headerFields := parseFormat(opts.HeaderFormat) + fields, headerFields := parseFormat(opts.HeaderFormat, opts.Theme) // find spocerFields adjacent to string fields and mark them // as hard spaces. hard spaces should not be skipped, only @@ -285,6 +294,8 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { stack = append(stack, state) state.groupStart = len(enc.buf) state.printedField = false + // Store the style to use for this group + state.style = f.style continue case groupClose: if len(stack) == 0 { @@ -329,7 +340,10 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { state.pendingHardSpace = false state.pendingSpace = false state.anchored = false - enc.withColor(&enc.buf, h.opts.Theme.Header(), func() { + + // Use the style specified for the group if available + style, _ := getThemeStyleByName(h.opts.Theme, state.style) + enc.withColor(&enc.buf, style, func() { enc.buf.AppendString(f) }) continue @@ -399,8 +413,13 @@ type encodeState struct { // whether any field in this group has not been elided. When a group // closes, if this is false, the entire group will be elided printedField bool + // number of fields seen in this group. If this is 0, then + // the group only contains fixed strings, and no fields, and + // should not be elided. + seenFields int anchored, pendingSpace, pendingHardSpace bool + style string } // WithAttrs implements slog.Handler. @@ -510,13 +529,15 @@ func memoizeHeaders(enc *encoder, headerFields []headerField) []headerField { // "prefix %t %l %m suffix" // "prefix ", timestamp, level, message, and then " suffix" // "%% %t %l %m" // literal "%", timestamp, level, message // "%t %l %s" // timestamp, level, source location (e.g., "file.go:123 functionName") +// "%t %l %m %(source){→ %s%}" // timestamp, level, message, and then source wrapped in a group with a custom string. +// // The string in the group will use the "source" style, and the group will be omitted if the source attribute is not present // // Note that headers will "capture" their matching attribute by default, which means that attribute will not // be included in the attributes section of the log line, and will not be matched by subsequent header fields. // Use the non-capturing header modifier '+' to disable capturing. If a header is not capturing, the attribute // will still be available for matching subsequent header fields, and will be included in the attributes section // of the log line. -func parseFormat(format string) (fields []any, headerFields []headerField) { +func parseFormat(format string, theme Theme) (fields []any, headerFields []headerField) { fields = make([]any, 0) headerFields = make([]headerField, 0) @@ -559,11 +580,25 @@ func parseFormat(format string) (fields []any, headerFields []headerField) { } // Check for modifiers before verb - var field any var width int var rightAlign bool var capture bool = true // default to capturing for headers var key string + var style string + if format[i] == '(' { + // Find the next ) or end of string + end := i + 1 + for end < len(format) && format[end] != ')' && format[end] != ' ' { + end++ + } + if end >= len(format) || format[end] != ')' { + fields = append(fields, fmt.Sprintf("%%!%s(MISSING_CLOSING_PARENTHESIS)", format[i:end])) + i = end - 1 // Position just before the next character to process + continue + } + style = format[i+1 : end] + i = end + 1 + } // Look for [name] modifier if format[i] == '[' { @@ -605,6 +640,8 @@ func parseFormat(format string) (fields []any, headerFields []headerField) { break } + var field any + // Parse the verb switch format[i] { case 't': @@ -635,7 +672,11 @@ func parseFormat(format string) (fields []any, headerFields []headerField) { abbreviated: false, } case '{': - field = groupOpen{} + if _, ok := getThemeStyleByName(theme, style); !ok { + fields = append(fields, fmt.Sprintf("%%!{(%s)(INVALID_STYLE_MODIFIER)", style)) + continue + } + field = groupOpen{style: style} case '}': field = groupClose{} case 's': @@ -652,3 +693,37 @@ func parseFormat(format string) (fields []any, headerFields []headerField) { return fields, headerFields } + +// Helper function to get style from theme by name +func getThemeStyleByName(theme Theme, name string) (ANSIMod, bool) { + switch name { + case "": + return theme.Header(), true + case "timestamp": + return theme.Timestamp(), true + case "header": + return theme.Header(), true + case "source": + return theme.Source(), true + case "message": + return theme.Message(), true + case "messageDebug": + return theme.MessageDebug(), true + case "attrKey": + return theme.AttrKey(), true + case "attrValue": + return theme.AttrValue(), true + case "attrValueError": + return theme.AttrValueError(), true + case "levelError": + return theme.LevelError(), true + case "levelWarn": + return theme.LevelWarn(), true + case "levelInfo": + return theme.LevelInfo(), true + case "levelDebug": + return theme.LevelDebug(), true + default: + return theme.Header(), false // Default to header style, but indicate style was not recognized + } +} diff --git a/handler_test.go b/handler_test.go index efd37d9..3133948 100644 --- a/handler_test.go +++ b/handler_test.go @@ -890,51 +890,110 @@ func TestHandler_CollapseSpaces(t *testing.T) { } } +func styled(s string, c ANSIMod) string { + if c == "" { + return s + } + return strings.Join([]string{string(c), s, string(ResetMod)}, "") +} + func TestHandler_HeaderFormat_Groups(t *testing.T) { + theme := NewDefaultTheme() tests := []handlerTest{ { name: "group not elided", - opts: HandlerOptions{HeaderFormat: "%l %{[%[foo]h]%} > %m"}, + opts: HandlerOptions{HeaderFormat: "%l %{[%[foo]h]%} > %m", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, want: "INF [bar] > groups\n", }, { name: "group elided", - opts: HandlerOptions{HeaderFormat: "%l %{[%[foo]h]%} > %m"}, + opts: HandlerOptions{HeaderFormat: "%l %{[%[foo]h]%} > %m", NoColor: true}, want: "INF > groups\n", }, + { + name: "group with only fixed strings not elided", + opts: HandlerOptions{HeaderFormat: "%l %{[fixed string]%} > %m", NoColor: true}, + want: "INF [fixed string] > groups\n", + }, { name: "two headers in group, both elided", - opts: HandlerOptions{HeaderFormat: "%l %{[%[foo]h %[bar]h]%} > %m"}, + opts: HandlerOptions{HeaderFormat: "%l %{[%[foo]h %[bar]h]%} > %m", NoColor: true}, want: "INF > groups\n", }, { name: "two headers in group, one elided", - opts: HandlerOptions{HeaderFormat: "%l %{[%[foo]h %[bar]h]%} > %m"}, + opts: HandlerOptions{HeaderFormat: "%l %{[%[foo]h %[bar]h]%} > %m", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, want: "INF [bar] > groups\n", }, { name: "two headers in group, neither elided", - opts: HandlerOptions{HeaderFormat: "%l %{[%[foo]h %[bar]h]%} > %m"}, + opts: HandlerOptions{HeaderFormat: "%l %{[%[foo]h %[bar]h]%} > %m", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar"), slog.String("bar", "baz")}, want: "INF [bar baz] > groups\n", }, { name: "open group not closed", - opts: HandlerOptions{HeaderFormat: "%l %{ > %m"}, + opts: HandlerOptions{HeaderFormat: "%l %{ > %m", NoColor: true}, want: "INF > groups\n", }, { name: "closed group not opened", - opts: HandlerOptions{HeaderFormat: "%l %} > %m"}, + opts: HandlerOptions{HeaderFormat: "%l %} > %m", NoColor: true}, want: "INF > groups\n", }, + { + name: "styled group", + opts: HandlerOptions{HeaderFormat: "%l %(source){ [%[foo]h] %} > %m"}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: strings.Join([]string{ + styled("INF", theme.LevelInfo()), " ", + styled("[", theme.Source()), + styled("bar", theme.Header()), + styled("]", theme.Source()), " ", + styled(">", theme.Header()), " ", + styled("groups", theme.Message()), + "\n"}, ""), + }, + { + name: "nested styled groups", + opts: HandlerOptions{HeaderFormat: "%l %(source){ [%[foo]h] %(message){ [%[bar]h] %} %} > %m"}, + attrs: []slog.Attr{slog.String("foo", "bar"), slog.String("bar", "baz")}, + want: strings.Join([]string{ + styled("INF", theme.LevelInfo()), " ", + styled("[", theme.Source()), + styled("bar", theme.Header()), + styled("]", theme.Source()), " ", + styled("[", theme.Message()), + styled("baz", theme.Header()), + styled("]", theme.Message()), " ", + styled(">", theme.Header()), " ", + styled("groups", theme.Message()), + "\n"}, ""), + }, + { + name: "invalid style name", + opts: HandlerOptions{HeaderFormat: "%l %(nonexistent){ %[foo]h %} > %m", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "INF %!{(nonexistent)(INVALID_STYLE_MODIFIER) bar > groups\n", + }, + { + name: "unclosed style modifier", + opts: HandlerOptions{HeaderFormat: "%l %(source{ %[foo]h %} > %m", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "INF %!(source{(MISSING_CLOSING_PARENTHESIS) bar > groups\n", + }, + { + name: "empty style modifier", + opts: HandlerOptions{HeaderFormat: "%l %(){ %[foo]h %} > %m", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "INF bar > groups\n", + }, } for _, tt := range tests { tt.msg = "groups" - tt.opts.NoColor = true tt.runSubtest(t) } } @@ -1317,168 +1376,174 @@ func TestHandler_writerErr(t *testing.T) { } func TestThemes(t *testing.T) { + pc, file, line, _ := runtime.Caller(0) + cwd, _ := os.Getwd() + file, _ = filepath.Rel(cwd, file) + sourceField := fmt.Sprintf("%s:%d", file, line) + + testTime := time.Date(2024, 01, 02, 15, 04, 05, 123456789, time.UTC) + for _, theme := range []Theme{ NewDefaultTheme(), NewBrightTheme(), } { t.Run(theme.Name(), func(t *testing.T) { - level := slog.LevelInfo - rec := slog.Record{} - buf := bytes.Buffer{} - bufBytes := buf.Bytes() - now := time.Now() - timeFormat := time.Kitchen - index := -1 - toIndex := -1 - var lastField []byte - h := NewHandler(&buf, &HandlerOptions{ - AddSource: true, - TimeFormat: timeFormat, - Theme: theme, - }).WithAttrs([]slog.Attr{{Key: "pid", Value: slog.IntValue(37556)}}) - var pcs [1]uintptr - runtime.Callers(1, pcs[:]) - - checkANSIMod := func(t *testing.T, name string, ansiMod ANSIMod) { - t.Run(name, func(t *testing.T) { - index = bytes.IndexByte(bufBytes, '\x1b') - AssertNotEqual(t, -1, index) - toIndex = index + len(ansiMod) - AssertEqual(t, ansiMod, ANSIMod(bufBytes[index:toIndex])) - bufBytes = bufBytes[toIndex:] - index = bytes.IndexByte(bufBytes, '\x1b') - AssertNotEqual(t, -1, index) - lastField = bufBytes[:index] - toIndex = index + len(ResetMod) - AssertEqual(t, ResetMod, ANSIMod(bufBytes[index:toIndex])) - bufBytes = bufBytes[toIndex:] - }) + tests := []struct { + lvl slog.Level + msg string + args []any + wantLvlStr string + }{ + { + msg: "Access", + lvl: slog.LevelDebug - 1, + wantLvlStr: "DBG-1", + args: []any{ + "database", "myapp", "host", "localhost:4962", + }, + }, + { + msg: "Access", + lvl: slog.LevelDebug, + wantLvlStr: "DBG", + args: []any{ + "database", "myapp", "host", "localhost:4962", + }, + }, + { + msg: "Access", + lvl: slog.LevelDebug + 1, + wantLvlStr: "DBG+1", + args: []any{ + "database", "myapp", "host", "localhost:4962", + }, + }, + { + msg: "Starting listener", + lvl: slog.LevelInfo, + wantLvlStr: "INF", + args: []any{ + "listen", ":8080", + }, + }, + { + msg: "Access", + lvl: slog.LevelInfo + 1, + wantLvlStr: "INF+1", + args: []any{ + "method", "GET", "path", "/users", "resp_time", time.Millisecond * 10, + }, + }, + { + msg: "Slow request", + lvl: slog.LevelWarn, + wantLvlStr: "WRN", + args: []any{ + "method", "POST", "path", "/posts", "resp_time", time.Second * 532, + }, + }, + { + msg: "Slow request", + lvl: slog.LevelWarn + 1, + wantLvlStr: "WRN+1", + args: []any{ + "method", "POST", "path", "/posts", "resp_time", time.Second * 532, + }, + }, + { + msg: "Database connection lost", + lvl: slog.LevelError, + wantLvlStr: "ERR", + args: []any{ + "database", "myapp", "error", errors.New("connection reset by peer"), + }, + }, + { + msg: "Database connection lost", + lvl: slog.LevelError + 1, + wantLvlStr: "ERR+1", + args: []any{ + "database", "myapp", "error", errors.New("connection reset by peer"), + }, + }, } - checkLog := func(level slog.Level, attrCount int) { - t.Run("CheckLog_"+level.String(), func(t *testing.T) { - println("log: ", string(buf.Bytes())) - - // Timestamp - if theme.Timestamp() != "" { - checkANSIMod(t, "Timestamp", theme.Timestamp()) + for _, tt := range tests { + // put together the expected log line + + var levelStyle ANSIMod + switch { + case tt.lvl >= slog.LevelError: + levelStyle = theme.LevelError() + case tt.lvl >= slog.LevelWarn: + levelStyle = theme.LevelWarn() + case tt.lvl >= slog.LevelInfo: + levelStyle = theme.LevelInfo() + default: + levelStyle = theme.LevelDebug() } - // Level - if theme.Level(level) != "" { - checkANSIMod(t, level.String(), theme.Level(level)) + var messageStyle ANSIMod + switch { + case tt.lvl >= slog.LevelInfo: + messageStyle = theme.Message() + default: + messageStyle = theme.MessageDebug() } - // Source - if theme.Header() != "" { - checkANSIMod(t, "Header", theme.Header()) - checkANSIMod(t, "Header", theme.Header()) - // checkANSIMod(t, "AttrKey", theme.AttrKey()) - } + withAttrs := []slog.Attr{{Key: "pid", Value: slog.IntValue(37556)}} + attrs := withAttrs + var rec slog.Record + rec.Add(tt.args...) + rec.Attrs(func(a slog.Attr) bool { + attrs = append(attrs, a) + return true + }) - // Message - if level >= slog.LevelInfo { - if theme.Message() != "" { - checkANSIMod(t, "Message", theme.Message()) - } + want := styled(testTime.Format(time.Kitchen), theme.Timestamp()) + + " " + + styled(tt.wantLvlStr, levelStyle) + + " " + + styled("http", theme.Header()) + + " " + + styled(sourceField, theme.Source()) + + " " + + styled(">", theme.Header()) + + " " + + styled(tt.msg, messageStyle) + + for _, attr := range attrs { + if attr.Key == "error" { + want += " " + + styled(attr.Key+"=", theme.AttrKey()) + + styled(attr.Value.String(), theme.AttrValueError()) } else { - if theme.MessageDebug() != "" { - checkANSIMod(t, "MessageDebug", theme.MessageDebug()) - } + want += " " + + styled(attr.Key+"=", theme.AttrKey()) + + styled(attr.Value.String(), theme.AttrValue()) } - - for i := 0; i < attrCount; i++ { - // AttrKey - if theme.AttrKey() != "" { - checkANSIMod(t, "AttrKey", theme.AttrKey()) - } - - if string(lastField) == "error=" { - // AttrValueError - if theme.AttrValueError() != "" { - checkANSIMod(t, "AttrValueError", theme.AttrValueError()) - } - } else { - // AttrValue - if theme.AttrValue() != "" { - checkANSIMod(t, "AttrValue", theme.AttrValue()) - } } + want += "\n" + + ht := handlerTest{ + opts: HandlerOptions{ + AddSource: true, + TimeFormat: time.Kitchen, + Theme: theme, + HeaderFormat: "%t %l %{%[logger]h %s >%} %m %a", + }, + attrs: append(withAttrs, slog.String("logger", "http")), + pc: pc, + time: testTime, + want: want, + lvl: tt.lvl, + msg: tt.msg, + recFunc: func(r *slog.Record) { + r.Add(tt.args...) + }, } - }) + t.Run(tt.wantLvlStr, ht.run) } - - buf.Reset() - level = slog.LevelDebug - 1 - rec = slog.NewRecord(now, level, "Access", pcs[0]) - rec.Add("database", "myapp", "host", "localhost:4962") - h.Handle(context.Background(), rec) - bufBytes = buf.Bytes() - checkLog(level, 3) - - buf.Reset() - level = slog.LevelDebug - rec = slog.NewRecord(now, level, "Access", pcs[0]) - rec.Add("database", "myapp", "host", "localhost:4962") - h.Handle(context.Background(), rec) - bufBytes = buf.Bytes() - checkLog(level, 3) - - buf.Reset() - level = slog.LevelDebug + 1 - rec = slog.NewRecord(now, level, "Access", pcs[0]) - rec.Add("database", "myapp", "host", "localhost:4962") - h.Handle(context.Background(), rec) - bufBytes = buf.Bytes() - checkLog(level, 3) - - buf.Reset() - level = slog.LevelInfo - rec = slog.NewRecord(now, level, "Starting listener", pcs[0]) - rec.Add("listen", ":8080") - h.Handle(context.Background(), rec) - bufBytes = buf.Bytes() - checkLog(level, 2) - - buf.Reset() - level = slog.LevelInfo + 1 - rec = slog.NewRecord(now, level, "Access", pcs[0]) - rec.Add("method", "GET", "path", "/users", "resp_time", time.Millisecond*10) - h.Handle(context.Background(), rec) - bufBytes = buf.Bytes() - checkLog(level, 4) - - buf.Reset() - level = slog.LevelWarn - rec = slog.NewRecord(now, level, "Slow request", pcs[0]) - rec.Add("method", "POST", "path", "/posts", "resp_time", time.Second*532) - h.Handle(context.Background(), rec) - bufBytes = buf.Bytes() - checkLog(level, 4) - - buf.Reset() - level = slog.LevelWarn + 1 - rec = slog.NewRecord(now, level, "Slow request", pcs[0]) - rec.Add("method", "POST", "path", "/posts", "resp_time", time.Second*532) - h.Handle(context.Background(), rec) - bufBytes = buf.Bytes() - checkLog(level, 4) - - buf.Reset() - level = slog.LevelError - rec = slog.NewRecord(now, level, "Database connection lost", pcs[0]) - rec.Add("database", "myapp", "error", errors.New("connection reset by peer")) - h.Handle(context.Background(), rec) - bufBytes = buf.Bytes() - checkLog(level, 3) - - buf.Reset() - level = slog.LevelError + 1 - rec = slog.NewRecord(now, level, "Database connection lost", pcs[0]) - rec.Add("database", "myapp", "error", errors.New("connection reset by peer")) - h.Handle(context.Background(), rec) - bufBytes = buf.Bytes() - checkLog(level, 3) }) } } From 755041b106d603bc100ea321009223e3f8705dfc Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Fri, 28 Feb 2025 19:19:41 -0600 Subject: [PATCH 36/44] If a group only contains strings, and no fields, then never elide it --- handler.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/handler.go b/handler.go index f7f0909..277d63e 100644 --- a/handler.go +++ b/handler.go @@ -294,6 +294,7 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { stack = append(stack, state) state.groupStart = len(enc.buf) state.printedField = false + state.seenFields = 0 // Store the style to use for this group state.style = f.style continue @@ -304,10 +305,12 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { continue } - if state.printedField { - // keep the current state, and just roll back - // the group start index to the prior group - state.groupStart = stack[len(stack)-1].groupStart + if state.printedField || state.seenFields == 0 { + // merge the current state with the prior state + lastState := stack[len(stack)-1] + state.groupStart = lastState.groupStart + state.style = lastState.style + state.seenFields += lastState.seenFields } else { // no fields were printed in this group, so // rollback the entire group and pop back to @@ -352,6 +355,7 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { enc.buf.AppendByte(' ') } l := len(enc.buf) + state.seenFields++ switch f := f.(type) { case headerField: hf := h.headerFields[headerIdx] From b51a493b51703942d7454939671422e065300e8f Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Fri, 28 Feb 2025 19:19:50 -0600 Subject: [PATCH 37/44] Improve some of the error messages --- handler.go | 95 +++++++++++++++++++++++++++++++------------------ handler_test.go | 34 +++++++++++++----- 2 files changed, 87 insertions(+), 42 deletions(-) diff --git a/handler.go b/handler.go index 277d63e..1727395 100644 --- a/handler.go +++ b/handler.go @@ -70,16 +70,16 @@ type HandlerOptions struct { // // The format is a string containing verbs, which are expanded as follows: // - // %t timestamp - // %l abbreviated level (e.g. "INF") - // %L level (e.g. "INFO") - // %m message + // %t timestamp + // %l abbreviated level (e.g. "INF") + // %L level (e.g. "INFO") + // %m message // %s source (if omitted, source is just handled as an attribute) // %a attributes - // %[key]h header with the given key. - // %{ group open + // %[key]h header with the given key. + // %{ group open // %(style){ group open with style - applies the specified Theme style to any strings in the group - // %} group close + // %} group close // // Headers print the value of the attribute with the given key, and remove that // attribute from the end of the log line. @@ -267,11 +267,11 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { src.Line = frame.Line if h.sourceAsAttr { - // the source attr should not be inside any open groups - groups := enc.groups - enc.groups = nil - enc.encodeAttr("", slog.Any(slog.SourceKey, &src)) - enc.groups = groups + // the source attr should not be inside any open groups + groups := enc.groups + enc.groups = nil + enc.encodeAttr("", slog.Any(slog.SourceKey, &src)) + enc.groups = groups } } @@ -499,10 +499,10 @@ func memoizeHeaders(enc *encoder, headerFields []headerField) []headerField { // // Supported format verbs: // -// %t - timestampField +// %t - timestampField // %h - headerField, requires the [name] modifier. // Supports width, right-alignment (-), and non-capturing (+) modifiers. -// %m - messageField +// %m - messageField // %l - abbreviated levelField: The log level in abbreviated form (e.g., "INF"). // %L - non-abbreviated levelField: The log level in full form (e.g., "INFO"). // %{ - groupOpen @@ -518,20 +518,20 @@ func memoizeHeaders(enc *encoder, headerFields []headerField) []headerField { // // Examples: // -// "%t %l %m" // timestamp, level, message -// "%t [%l] %m" // timestamp, level in brackets, message -// "%t %l:%m" // timestamp, level:message -// "%t %l %[key]h %m" // timestamp, level, header with key "key", message -// "%t %l %[key1]h %[key2]h %m" // timestamp, level, header with key "key1", header with key "key2", message -// "%t %l %[key]10h %m" // timestamp, level, header with key "key" and width 10, message -// "%t %l %[key]-10h %m" // timestamp, level, right-aligned header with key "key" and width 10, message +// "%t %l %m" // timestamp, level, message +// "%t [%l] %m" // timestamp, level in brackets, message +// "%t %l:%m" // timestamp, level:message +// "%t %l %[key]h %m" // timestamp, level, header with key "key", message +// "%t %l %[key1]h %[key2]h %m" // timestamp, level, header with key "key1", header with key "key2", message +// "%t %l %[key]10h %m" // timestamp, level, header with key "key" and width 10, message +// "%t %l %[key]-10h %m" // timestamp, level, right-aligned header with key "key" and width 10, message // "%t %l %[key]10+h %m" // timestamp, level, non-captured header with key "key" and width 10, message // "%t %l %[key]-10+h %m" // timestamp, level, right-aligned non-captured header with key "key" and width 10, message -// "%t %l %L %m" // timestamp, abbreviated level, non-abbreviated level, message -// "%t %l %L- %m" // timestamp, abbreviated level, right-aligned non-abbreviated level, message -// "%t %l %m string literal" // timestamp, level, message, and then " string literal" -// "prefix %t %l %m suffix" // "prefix ", timestamp, level, message, and then " suffix" -// "%% %t %l %m" // literal "%", timestamp, level, message +// "%t %l %L %m" // timestamp, abbreviated level, non-abbreviated level, message +// "%t %l %L- %m" // timestamp, abbreviated level, right-aligned non-abbreviated level, message +// "%t %l %m string literal" // timestamp, level, message, and then " string literal" +// "prefix %t %l %m suffix" // "prefix ", timestamp, level, message, and then " suffix" +// "%% %t %l %m" // literal "%", timestamp, level, message // "%t %l %s" // timestamp, level, source location (e.g., "file.go:123 functionName") // "%t %l %m %(source){→ %s%}" // timestamp, level, message, and then source wrapped in a group with a custom string. // // The string in the group will use the "source" style, and the group will be omitted if the source attribute is not present @@ -589,7 +589,11 @@ func parseFormat(format string, theme Theme) (fields []any, headerFields []heade var capture bool = true // default to capturing for headers var key string var style string + var styleSeen, keySeen, widthSeen bool + + // Look for (style) modifier for groupOpen if format[i] == '(' { + styleSeen = true // Find the next ) or end of string end := i + 1 for end < len(format) && format[end] != ')' && format[end] != ' ' { @@ -606,14 +610,15 @@ func parseFormat(format string, theme Theme) (fields []any, headerFields []heade // Look for [name] modifier if format[i] == '[' { + keySeen = true // Find the next ] or end of string end := i + 1 for end < len(format) && format[end] != ']' && format[end] != ' ' { end++ } if end >= len(format) || format[end] != ']' { + fields = append(fields, fmt.Sprintf("%%!%s(MISSING_CLOSING_BRACKET)", format[i:end])) i = end - 1 // Position just before the next character to process - fields = append(fields, "%!(MISSING_CLOSING_BRACKET)") continue } key = format[i+1 : end] @@ -622,13 +627,14 @@ func parseFormat(format string, theme Theme) (fields []any, headerFields []heade // Look for modifiers for i < len(format) { - if format[i] == '-' && key != "" { // '-' only valid for headers + if format[i] == '-' { rightAlign = true i++ - } else if format[i] == '+' && key != "" { // '+' only valid for headers + } else if format[i] == '+' { capture = false i++ - } else if format[i] >= '0' && format[i] <= '9' && key != "" { // width only valid for headers + } else if format[i] >= '0' && format[i] <= '9' { + widthSeen = true width = 0 for i < len(format) && format[i] >= '0' && format[i] <= '9' { width = width*10 + int(format[i]-'0') @@ -648,6 +654,11 @@ func parseFormat(format string, theme Theme) (fields []any, headerFields []heade // Parse the verb switch format[i] { + case ' ': + fields = append(fields, "%!(MISSING_VERB)") + // backtrack so the space is included in the next field + i-- + continue case 't': field = timestampField{} case 'h': @@ -666,15 +677,12 @@ func parseFormat(format string, theme Theme) (fields []any, headerFields []heade hf.key = key[idx+1:] } field = hf - headerFields = append(headerFields, hf) case 'm': field = messageField{} case 'l': field = levelField{abbreviated: true} case 'L': - field = levelField{ - abbreviated: false, - } + field = levelField{abbreviated: false} case '{': if _, ok := getThemeStyleByName(theme, style); !ok { fields = append(fields, fmt.Sprintf("%%!{(%s)(INVALID_STYLE_MODIFIER)", style)) @@ -692,7 +700,26 @@ func parseFormat(format string, theme Theme) (fields []any, headerFields []heade continue } + // Check for invalid combinations + switch { + case styleSeen && format[i] != '{': + fields = append(fields, fmt.Sprintf("%%!((INVALID_MODIFIER)%c", format[i])) + continue + case keySeen && format[i] != 'h': + fields = append(fields, fmt.Sprintf("%%![(INVALID_MODIFIER)%c", format[i])) + continue + case widthSeen && format[i] != 'h': + fields = append(fields, fmt.Sprintf("%%!%d(INVALID_MODIFIER)%c", width, format[i])) + continue + case rightAlign && format[i] != 'h': + fields = append(fields, fmt.Sprintf("%%!-(INVALID_MODIFIER)%c", format[i])) + continue + } + fields = append(fields, field) + if _, ok := field.(headerField); ok { + headerFields = append(headerFields, field.(headerField)) + } } return fields, headerFields diff --git a/handler_test.go b/handler_test.go index 3133948..80c96ae 100644 --- a/handler_test.go +++ b/handler_test.go @@ -1243,10 +1243,28 @@ func TestHandler_HeaderFormat(t *testing.T) { want: "with headers %!(MISSING_VERB) foo=bar\n", }, { - name: "invalid modifier", - opts: HandlerOptions{HeaderFormat: "%m %-L", NoColor: true}, + name: "invalid right align modifier", + opts: HandlerOptions{HeaderFormat: "%m %-L %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, - want: "with headers %!-(INVALID_VERB)L foo=bar\n", + want: "with headers %!-(INVALID_MODIFIER)L foo=bar\n", + }, + { + name: "invalid width modifier", + opts: HandlerOptions{HeaderFormat: "%m %43L %a", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "with headers %!43(INVALID_MODIFIER)L foo=bar\n", + }, + { + name: "invalid style modifier", + opts: HandlerOptions{HeaderFormat: "%m %(source)L %a", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "with headers %!((INVALID_MODIFIER)L foo=bar\n", + }, + { + name: "invalid key modifier", + opts: HandlerOptions{HeaderFormat: "%m %[source]L %a", NoColor: true}, + attrs: []slog.Attr{slog.String("foo", "bar")}, + want: "with headers %![(INVALID_MODIFIER)L foo=bar\n", }, { name: "invalid verb", @@ -1264,7 +1282,7 @@ func TestHandler_HeaderFormat(t *testing.T) { name: "missing closing bracket in header", opts: HandlerOptions{HeaderFormat: "%m %[fooh > %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, - want: "with headers %!(MISSING_CLOSING_BRACKET) > foo=bar\n", + want: "with headers %![fooh(MISSING_CLOSING_BRACKET) > foo=bar\n", }, { name: "zero PC", @@ -1481,7 +1499,7 @@ func TestThemes(t *testing.T) { levelStyle = theme.LevelInfo() default: levelStyle = theme.LevelDebug() - } + } var messageStyle ANSIMod switch { @@ -1489,7 +1507,7 @@ func TestThemes(t *testing.T) { messageStyle = theme.Message() default: messageStyle = theme.MessageDebug() - } + } withAttrs := []slog.Attr{{Key: "pid", Value: slog.IntValue(37556)}} attrs := withAttrs @@ -1522,7 +1540,7 @@ func TestThemes(t *testing.T) { styled(attr.Key+"=", theme.AttrKey()) + styled(attr.Value.String(), theme.AttrValue()) } - } + } want += "\n" ht := handlerTest{ @@ -1541,7 +1559,7 @@ func TestThemes(t *testing.T) { recFunc: func(r *slog.Record) { r.Add(tt.args...) }, - } + } t.Run(tt.wantLvlStr, ht.run) } }) From 63df22b4663b2f45701c886c0a42ae9266974679 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Fri, 28 Feb 2025 19:58:18 -0600 Subject: [PATCH 38/44] Removed the non-capturing modifier --- encoding.go | 4 +--- handler.go | 29 ++--------------------------- handler_test.go | 26 +------------------------- 3 files changed, 4 insertions(+), 55 deletions(-) diff --git a/encoding.go b/encoding.go index 0c87c9d..a4a2674 100644 --- a/encoding.go +++ b/encoding.go @@ -285,9 +285,7 @@ func (e *encoder) encodeAttr(groupPrefix string, a slog.Attr) { for i, f := range e.h.headerFields { if f.key == a.Key && f.groupPrefix == groupPrefix { e.headerAttrs[i] = a - if f.capture { - return - } + return } } diff --git a/handler.go b/handler.go index 1727395..6a6252c 100644 --- a/handler.go +++ b/handler.go @@ -84,19 +84,11 @@ type HandlerOptions struct { // Headers print the value of the attribute with the given key, and remove that // attribute from the end of the log line. // - // Headers can be customized with width, alignment, and non-capturing modifiers, + // Headers can be customized with width and alignment modifiers, // similar to fmt.Printf verbs. For example: // // %[key]10h // left-aligned, width 10 // %[key]-10h // right-aligned, width 10 - // %[key]+h // non-capturing - // %[key]-10+h // right-aligned, width 10, non-capturing - // - // Note that headers will "capture" their matching attribute by default, which means that attribute will not - // be included in the attributes section of the log line, and will not be matched by subsequent header fields. - // Use the non-capturing header modifier '+' to disable capturing. If a header is non-capturing, the attribute - // will still be available for matching subsequent header fields, and will be included in the attributes section - // of the log line. // // Groups will omit their contents if all the fields in that group are omitted. For example: // @@ -123,8 +115,6 @@ type HandlerOptions struct { // "%t %l %[key1]h %[key2]h %m" // timestamp, level, header with key "key1", header with key "key2", message // "%t %l %[key]10h %m" // timestamp, level, header with key "key" and width 10, message // "%t %l %[key]-10h %m" // timestamp, level, right-aligned header with key "key" and width 10, message - // "%t %l %[key]10+h %m" // timestamp, level, captured header with key "key" and width 10, message - // "%t %l %[key]-10+h %m" // timestamp, level, right-aligned captured header with key "key" and width 10, message // "%t %l %L %m" // timestamp, abbreviated level, non-abbreviated level, message // "%t %l %L- %m" // timestamp, abbreviated level, right-aligned non-abbreviated level, message // "%t %l %m string literal" // timestamp, level, message, and then " string literal" @@ -154,7 +144,6 @@ type headerField struct { key string width int rightAlign bool - capture bool memo string } @@ -501,7 +490,7 @@ func memoizeHeaders(enc *encoder, headerFields []headerField) []headerField { // // %t - timestampField // %h - headerField, requires the [name] modifier. -// Supports width, right-alignment (-), and non-capturing (+) modifiers. +// Supports width, right-alignment (-) modifiers. // %m - messageField // %l - abbreviated levelField: The log level in abbreviated form (e.g., "INF"). // %L - non-abbreviated levelField: The log level in full form (e.g., "INFO"). @@ -514,7 +503,6 @@ func memoizeHeaders(enc *encoder, headerFields []headerField) []headerField { // [name] (for %h): The key of the attribute to capture as a header. This modifier is required for the %h verb. // width (for %h): An integer specifying the fixed width of the header. This modifier is optional. // - (for %h): Indicates right-alignment of the header. This modifier is optional. -// + (for %h): Indicates a non-capturing header. This modifier is optional. // // Examples: // @@ -525,8 +513,6 @@ func memoizeHeaders(enc *encoder, headerFields []headerField) []headerField { // "%t %l %[key1]h %[key2]h %m" // timestamp, level, header with key "key1", header with key "key2", message // "%t %l %[key]10h %m" // timestamp, level, header with key "key" and width 10, message // "%t %l %[key]-10h %m" // timestamp, level, right-aligned header with key "key" and width 10, message -// "%t %l %[key]10+h %m" // timestamp, level, non-captured header with key "key" and width 10, message -// "%t %l %[key]-10+h %m" // timestamp, level, right-aligned non-captured header with key "key" and width 10, message // "%t %l %L %m" // timestamp, abbreviated level, non-abbreviated level, message // "%t %l %L- %m" // timestamp, abbreviated level, right-aligned non-abbreviated level, message // "%t %l %m string literal" // timestamp, level, message, and then " string literal" @@ -535,12 +521,6 @@ func memoizeHeaders(enc *encoder, headerFields []headerField) []headerField { // "%t %l %s" // timestamp, level, source location (e.g., "file.go:123 functionName") // "%t %l %m %(source){→ %s%}" // timestamp, level, message, and then source wrapped in a group with a custom string. // // The string in the group will use the "source" style, and the group will be omitted if the source attribute is not present -// -// Note that headers will "capture" their matching attribute by default, which means that attribute will not -// be included in the attributes section of the log line, and will not be matched by subsequent header fields. -// Use the non-capturing header modifier '+' to disable capturing. If a header is not capturing, the attribute -// will still be available for matching subsequent header fields, and will be included in the attributes section -// of the log line. func parseFormat(format string, theme Theme) (fields []any, headerFields []headerField) { fields = make([]any, 0) headerFields = make([]headerField, 0) @@ -586,7 +566,6 @@ func parseFormat(format string, theme Theme) (fields []any, headerFields []heade // Check for modifiers before verb var width int var rightAlign bool - var capture bool = true // default to capturing for headers var key string var style string var styleSeen, keySeen, widthSeen bool @@ -630,9 +609,6 @@ func parseFormat(format string, theme Theme) (fields []any, headerFields []heade if format[i] == '-' { rightAlign = true i++ - } else if format[i] == '+' { - capture = false - i++ } else if format[i] >= '0' && format[i] <= '9' { widthSeen = true width = 0 @@ -670,7 +646,6 @@ func parseFormat(format string, theme Theme) (fields []any, headerFields []heade key: key, width: width, rightAlign: rightAlign, - capture: capture, } if idx := strings.LastIndexByte(key, '.'); idx > -1 { hf.groupPrefix = key[:idx] diff --git a/handler_test.go b/handler_test.go index 80c96ae..0e482aa 100644 --- a/handler_test.go +++ b/handler_test.go @@ -1146,24 +1146,6 @@ func TestHandler_HeaderFormat(t *testing.T) { attrs: []slog.Attr{slog.String("foo", "bar")}, want: "INF bar > with headers\n", // Second header is ignored since foo was captured by first header }, - { - name: "non-capturing header", - opts: HandlerOptions{HeaderFormat: "%l %[logger]h %[request_id]+h > %m %a", NoColor: true}, - attrs: []slog.Attr{slog.String("logger", "app"), slog.String("request_id", "123")}, - want: "INF app 123 > with headers request_id=123\n", - }, - { - name: "non-capturing header captured by another header", - opts: HandlerOptions{HeaderFormat: "%l %[logger]+h %[logger]h > %m %a", NoColor: true}, - attrs: []slog.Attr{slog.String("logger", "app")}, - want: "INF app app > with headers\n", - }, - { - name: "multiple non-capturing headers matching same attr", - opts: HandlerOptions{HeaderFormat: "%l %[logger]+h %[logger]+h > %m %a", NoColor: true}, - attrs: []slog.Attr{slog.String("logger", "app")}, - want: "INF app app > with headers logger=app\n", - }, { name: "repeated timestamp, level and message fields", opts: HandlerOptions{HeaderFormat: "%t %l %m %t %l %m %a", NoColor: true}, @@ -1200,12 +1182,6 @@ func TestHandler_HeaderFormat(t *testing.T) { attrs: []slog.Attr{slog.String("foo", "hello"), slog.String("bar", "world")}, want: "INF hello world > with headers\n", }, - { - name: "fixed width non-capturing header", - opts: HandlerOptions{HeaderFormat: "%l %[foo]+-10h > %m %a", NoColor: true}, - attrs: []slog.Attr{slog.String("foo", "bar")}, - want: "INF bar > with headers foo=bar\n", - }, { name: "fixed width header missing attr", opts: HandlerOptions{HeaderFormat: "%l %[missing]10h > %m %a", NoColor: true}, @@ -1238,7 +1214,7 @@ func TestHandler_HeaderFormat(t *testing.T) { }, { name: "missing verb with modifiers", - opts: HandlerOptions{HeaderFormat: "%m %[slog]+-4 %a", NoColor: true}, + opts: HandlerOptions{HeaderFormat: "%m %[slog]-4 %a", NoColor: true}, attrs: []slog.Attr{slog.String("foo", "bar")}, want: "with headers %!(MISSING_VERB) foo=bar\n", }, From 59d9d2967b8bb8f91d3a0eac96b5d6e5fb900059 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Fri, 28 Feb 2025 20:07:27 -0600 Subject: [PATCH 39/44] Refactoring --- handler_test.go | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/handler_test.go b/handler_test.go index 0e482aa..d5911fd 100644 --- a/handler_test.go +++ b/handler_test.go @@ -97,7 +97,7 @@ func TestHandler_TimeFormat(t *testing.T) { attrs: tt.attrs, want: tt.want, } - ht.runSubtest(t) + t.Run(tt.name, ht.run) } } @@ -244,7 +244,7 @@ func TestHandler_AttrsWithNewlines(t *testing.T) { test.msg = "multiline attrs" } test.opts.NoColor = true - test.runSubtest(t) + t.Run(test.name, test.run) } } @@ -292,7 +292,7 @@ func TestHandler_Groups(t *testing.T) { for _, test := range tests { test.opts.NoColor = true test.msg = test.name - test.runSubtest(t) + t.Run(test.name, test.run) } } @@ -359,7 +359,7 @@ func TestHandler_WithAttr(t *testing.T) { if test.msg == "" { test.msg = test.name } - test.runSubtest(t) + t.Run(test.name, test.run) } t.Run("state isolation", func(t *testing.T) { @@ -435,7 +435,7 @@ func TestHandler_WithGroup(t *testing.T) { if test.msg == "" { test.msg = test.name } - test.runSubtest(t) + t.Run(test.name, test.run) } t.Run("state isolation", func(t *testing.T) { @@ -826,7 +826,7 @@ func TestHandler_TruncateSourcePath(t *testing.T) { for _, tt := range tests { tt.opts.NoColor = true tt.want += "\n" - tt.runSubtest(t) + t.Run(tt.name, tt.run) } } @@ -881,12 +881,13 @@ func TestHandler_CollapseSpaces(t *testing.T) { } for _, tt := range tests2 { - handlerTest{ + ht := handlerTest{ name: tt.desc, msg: "msg", opts: HandlerOptions{HeaderFormat: tt.format, NoColor: true}, want: tt.want + "\n", - }.runSubtest(t) + } + t.Run(ht.name, ht.run) } } @@ -994,7 +995,7 @@ func TestHandler_HeaderFormat_Groups(t *testing.T) { for _, tt := range tests { tt.msg = "groups" - tt.runSubtest(t) + t.Run(tt.name, tt.run) } } @@ -1315,7 +1316,7 @@ func TestHandler_HeaderFormat(t *testing.T) { tt.pc = pc tt.lvl = slog.LevelInfo tt.time = testTime - tt.runSubtest(t) + t.Run(tt.name, tt.run) } } @@ -1332,13 +1333,6 @@ type handlerTest struct { want string } -func (ht handlerTest) runSubtest(t *testing.T) { - t.Helper() - t.Run(ht.name, func(t *testing.T) { - ht.run(t) - }) -} - func (ht handlerTest) run(t *testing.T) { t.Helper() buf := bytes.Buffer{} From 933716bd738d36a143358f8929614fef1bc60f0a Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Sun, 2 Mar 2025 09:06:45 -0600 Subject: [PATCH 40/44] Made theme easier to use It's just a simple struct with fields now. Much easier for consumers to create their own themes. --- bench_test.go | 4 +- encoding.go | 28 +++++------ handler.go | 30 ++++++------ handler_test.go | 62 +++++++++++------------ theme.go | 128 ++++++++++++++++-------------------------------- 5 files changed, 104 insertions(+), 148 deletions(-) diff --git a/bench_test.go b/bench_test.go index afd7abf..e6f88cd 100644 --- a/bench_test.go +++ b/bench_test.go @@ -22,9 +22,9 @@ var handlers = []struct { }{ {"dummy", &DummyHandler{}}, {"console", NewHandler(io.Discard, &HandlerOptions{Level: slog.LevelDebug, AddSource: false})}, - {"console-headers", NewHandler(io.Discard, &HandlerOptions{HeaderFormat: "%t %{%[foo]h > %}%l %m", Level: slog.LevelDebug, AddSource: false})}, + {"console-headers", NewHandler(io.Discard, &HandlerOptions{HeaderFormat: "%t %{%[foo]h > %}%l %m %a", Level: slog.LevelDebug, AddSource: false})}, {"console-replaceattr", NewHandler(io.Discard, &HandlerOptions{Level: slog.LevelDebug, AddSource: false, ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { return a }})}, - {"console-headers-replaceattr", NewHandler(io.Discard, &HandlerOptions{HeaderFormat: "%t %{%[foo]h > %} %l %m", Level: slog.LevelDebug, AddSource: false, ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { return a }})}, + {"console-headers-replaceattr", NewHandler(io.Discard, &HandlerOptions{HeaderFormat: "%t %{%[foo]h > %} %l %m %a", Level: slog.LevelDebug, AddSource: false, ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { return a }})}, {"std-text", slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: false})}, {"std-text-replaceattr", slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: false, ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { return a }})}, {"std-json", slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: false})}, diff --git a/encoding.go b/encoding.go index a4a2674..72c7229 100644 --- a/encoding.go +++ b/encoding.go @@ -72,7 +72,7 @@ func (e *encoder) encodeTimestamp(tt time.Time) { if attr.Value.Kind() != slog.KindTime { // handle all non-time values by printing them like // an attr value - e.writeColoredValue(&e.buf, attr.Value, e.h.opts.Theme.Timestamp()) + e.writeColoredValue(&e.buf, attr.Value, e.h.opts.Theme.Timestamp) return } @@ -84,15 +84,15 @@ func (e *encoder) encodeTimestamp(tt time.Time) { } } - e.withColor(&e.buf, e.h.opts.Theme.Timestamp(), func() { + e.withColor(&e.buf, e.h.opts.Theme.Timestamp, func() { e.buf.AppendTime(tt, e.h.opts.TimeFormat) }) } func (e *encoder) encodeMessage(level slog.Level, msg string) { - style := e.h.opts.Theme.Message() + style := e.h.opts.Theme.Message if level < slog.LevelInfo { - style = e.h.opts.Theme.MessageDebug() + style = e.h.opts.Theme.MessageDebug } if e.h.opts.ReplaceAttr != nil { @@ -119,7 +119,7 @@ func (e *encoder) encodeHeader(a slog.Attr, width int, rightAlign bool) { return } - e.withColor(&e.buf, e.h.opts.Theme.Header(), func() { + e.withColor(&e.buf, e.h.opts.Theme.Header, func() { l := len(e.buf) e.writeValue(&e.buf, a.Value) if width <= 0 { @@ -184,35 +184,35 @@ func (e *encoder) encodeLevel(l slog.Level, abbreviated bool) { var delta int switch { case l >= slog.LevelError: - style = e.h.opts.Theme.LevelError() + style = e.h.opts.Theme.LevelError str = "ERR" if !abbreviated { str = "ERROR" } delta = int(l - slog.LevelError) case l >= slog.LevelWarn: - style = e.h.opts.Theme.LevelWarn() + style = e.h.opts.Theme.LevelWarn str = "WRN" if !abbreviated { str = "WARN" } delta = int(l - slog.LevelWarn) case l >= slog.LevelInfo: - style = e.h.opts.Theme.LevelInfo() + style = e.h.opts.Theme.LevelInfo str = "INF" if !abbreviated { str = "INFO" } delta = int(l - slog.LevelInfo) case l >= slog.LevelDebug: - style = e.h.opts.Theme.LevelDebug() + style = e.h.opts.Theme.LevelDebug str = "DBG" if !abbreviated { str = "DEBUG" } delta = int(l - slog.LevelDebug) default: - style = e.h.opts.Theme.LevelDebug() + style = e.h.opts.Theme.LevelDebug str = "DBG" if !abbreviated { str = "DEBUG" @@ -248,7 +248,7 @@ func (e *encoder) encodeSource(src slog.Source) { v = attr.Value } // Use source style for the value - e.writeColoredValue(&e.buf, v, e.h.opts.Theme.Source()) + e.writeColoredValue(&e.buf, v, e.h.opts.Theme.Source) } func (e *encoder) encodeAttr(groupPrefix string, a slog.Attr) { @@ -366,7 +366,7 @@ func (e *encoder) writeAttr(buf *buffer, a slog.Attr, group string) { value := a.Value buf.AppendByte(' ') - e.withColor(buf, e.h.opts.Theme.AttrKey(), func() { + e.withColor(buf, e.h.opts.Theme.AttrKey, func() { if group != "" { e.attrBuf.AppendString(group) e.attrBuf.AppendByte('.') @@ -375,10 +375,10 @@ func (e *encoder) writeAttr(buf *buffer, a slog.Attr, group string) { e.attrBuf.AppendByte('=') }) - style := e.h.opts.Theme.AttrValue() + style := e.h.opts.Theme.AttrValue if value.Kind() == slog.KindAny { if _, ok := value.Any().(error); ok { - style = e.h.opts.Theme.AttrValueError() + style = e.h.opts.Theme.AttrValueError } } e.writeColoredValue(buf, value, style) diff --git a/handler.go b/handler.go index 6a6252c..bee146e 100644 --- a/handler.go +++ b/handler.go @@ -180,7 +180,7 @@ func NewHandler(out io.Writer, opts *HandlerOptions) *Handler { if opts.TimeFormat == "" { opts.TimeFormat = time.DateTime } - if opts.Theme == nil { + if opts.Theme.Name == "" { opts.Theme = NewDefaultTheme() } if opts.HeaderFormat == "" { @@ -704,32 +704,32 @@ func parseFormat(format string, theme Theme) (fields []any, headerFields []heade func getThemeStyleByName(theme Theme, name string) (ANSIMod, bool) { switch name { case "": - return theme.Header(), true + return theme.Header, true case "timestamp": - return theme.Timestamp(), true + return theme.Timestamp, true case "header": - return theme.Header(), true + return theme.Header, true case "source": - return theme.Source(), true + return theme.Source, true case "message": - return theme.Message(), true + return theme.Message, true case "messageDebug": - return theme.MessageDebug(), true + return theme.MessageDebug, true case "attrKey": - return theme.AttrKey(), true + return theme.AttrKey, true case "attrValue": - return theme.AttrValue(), true + return theme.AttrValue, true case "attrValueError": - return theme.AttrValueError(), true + return theme.AttrValueError, true case "levelError": - return theme.LevelError(), true + return theme.LevelError, true case "levelWarn": - return theme.LevelWarn(), true + return theme.LevelWarn, true case "levelInfo": - return theme.LevelInfo(), true + return theme.LevelInfo, true case "levelDebug": - return theme.LevelDebug(), true + return theme.LevelDebug, true default: - return theme.Header(), false // Default to header style, but indicate style was not recognized + return theme.Header, false // Default to header style, but indicate style was not recognized } } diff --git a/handler_test.go b/handler_test.go index d5911fd..4ee2c70 100644 --- a/handler_test.go +++ b/handler_test.go @@ -19,7 +19,7 @@ import ( func TestNewHandler(t *testing.T) { h := NewHandler(nil, nil) AssertEqual(t, time.DateTime, h.opts.TimeFormat) - AssertEqual(t, NewDefaultTheme().Name(), h.opts.Theme.Name()) + AssertEqual(t, NewDefaultTheme().Name, h.opts.Theme.Name) AssertEqual(t, defaultHeaderFormat, h.opts.HeaderFormat) } @@ -949,12 +949,12 @@ func TestHandler_HeaderFormat_Groups(t *testing.T) { opts: HandlerOptions{HeaderFormat: "%l %(source){ [%[foo]h] %} > %m"}, attrs: []slog.Attr{slog.String("foo", "bar")}, want: strings.Join([]string{ - styled("INF", theme.LevelInfo()), " ", - styled("[", theme.Source()), - styled("bar", theme.Header()), - styled("]", theme.Source()), " ", - styled(">", theme.Header()), " ", - styled("groups", theme.Message()), + styled("INF", theme.LevelInfo), " ", + styled("[", theme.Source), + styled("bar", theme.Header), + styled("]", theme.Source), " ", + styled(">", theme.Header), " ", + styled("groups", theme.Message), "\n"}, ""), }, { @@ -962,15 +962,15 @@ func TestHandler_HeaderFormat_Groups(t *testing.T) { opts: HandlerOptions{HeaderFormat: "%l %(source){ [%[foo]h] %(message){ [%[bar]h] %} %} > %m"}, attrs: []slog.Attr{slog.String("foo", "bar"), slog.String("bar", "baz")}, want: strings.Join([]string{ - styled("INF", theme.LevelInfo()), " ", - styled("[", theme.Source()), - styled("bar", theme.Header()), - styled("]", theme.Source()), " ", - styled("[", theme.Message()), - styled("baz", theme.Header()), - styled("]", theme.Message()), " ", - styled(">", theme.Header()), " ", - styled("groups", theme.Message()), + styled("INF", theme.LevelInfo), " ", + styled("[", theme.Source), + styled("bar", theme.Header), + styled("]", theme.Source), " ", + styled("[", theme.Message), + styled("baz", theme.Header), + styled("]", theme.Message), " ", + styled(">", theme.Header), " ", + styled("groups", theme.Message), "\n"}, ""), }, { @@ -1375,7 +1375,7 @@ func TestThemes(t *testing.T) { NewDefaultTheme(), NewBrightTheme(), } { - t.Run(theme.Name(), func(t *testing.T) { + t.Run(theme.Name, func(t *testing.T) { tests := []struct { lvl slog.Level msg string @@ -1462,21 +1462,21 @@ func TestThemes(t *testing.T) { var levelStyle ANSIMod switch { case tt.lvl >= slog.LevelError: - levelStyle = theme.LevelError() + levelStyle = theme.LevelError case tt.lvl >= slog.LevelWarn: - levelStyle = theme.LevelWarn() + levelStyle = theme.LevelWarn case tt.lvl >= slog.LevelInfo: - levelStyle = theme.LevelInfo() + levelStyle = theme.LevelInfo default: - levelStyle = theme.LevelDebug() + levelStyle = theme.LevelDebug } var messageStyle ANSIMod switch { case tt.lvl >= slog.LevelInfo: - messageStyle = theme.Message() + messageStyle = theme.Message default: - messageStyle = theme.MessageDebug() + messageStyle = theme.MessageDebug } withAttrs := []slog.Attr{{Key: "pid", Value: slog.IntValue(37556)}} @@ -1488,27 +1488,27 @@ func TestThemes(t *testing.T) { return true }) - want := styled(testTime.Format(time.Kitchen), theme.Timestamp()) + + want := styled(testTime.Format(time.Kitchen), theme.Timestamp) + " " + styled(tt.wantLvlStr, levelStyle) + " " + - styled("http", theme.Header()) + + styled("http", theme.Header) + " " + - styled(sourceField, theme.Source()) + + styled(sourceField, theme.Source) + " " + - styled(">", theme.Header()) + + styled(">", theme.Header) + " " + styled(tt.msg, messageStyle) for _, attr := range attrs { if attr.Key == "error" { want += " " + - styled(attr.Key+"=", theme.AttrKey()) + - styled(attr.Value.String(), theme.AttrValueError()) + styled(attr.Key+"=", theme.AttrKey) + + styled(attr.Value.String(), theme.AttrValueError) } else { want += " " + - styled(attr.Key+"=", theme.AttrKey()) + - styled(attr.Value.String(), theme.AttrValue()) + styled(attr.Key+"=", theme.AttrKey) + + styled(attr.Value.String(), theme.AttrValue) } } want += "\n" diff --git a/theme.go b/theme.go index 2531c76..9173e64 100644 --- a/theme.go +++ b/theme.go @@ -2,7 +2,6 @@ package console import ( "fmt" - "log/slog" ) type ANSIMod string @@ -59,97 +58,54 @@ func ToANSICode(modes ...int) ANSIMod { return ANSIMod("\x1b[" + s + "m") } -type Theme interface { - Name() string - Timestamp() ANSIMod - Header() ANSIMod - Source() ANSIMod - Message() ANSIMod - MessageDebug() ANSIMod - AttrKey() ANSIMod - AttrValue() ANSIMod - AttrValueError() ANSIMod - LevelError() ANSIMod - LevelWarn() ANSIMod - LevelInfo() ANSIMod - LevelDebug() ANSIMod - Level(level slog.Level) ANSIMod -} - -type ThemeDef struct { - name string - timestamp ANSIMod - header ANSIMod - source ANSIMod - message ANSIMod - messageDebug ANSIMod - attrKey ANSIMod - attrValue ANSIMod - attrValueError ANSIMod - levelError ANSIMod - levelWarn ANSIMod - levelInfo ANSIMod - levelDebug ANSIMod -} - -func (t ThemeDef) Name() string { return t.name } -func (t ThemeDef) Timestamp() ANSIMod { return t.timestamp } -func (t ThemeDef) Header() ANSIMod { return t.header } -func (t ThemeDef) Source() ANSIMod { return t.source } -func (t ThemeDef) Message() ANSIMod { return t.message } -func (t ThemeDef) MessageDebug() ANSIMod { return t.messageDebug } -func (t ThemeDef) AttrKey() ANSIMod { return t.attrKey } -func (t ThemeDef) AttrValue() ANSIMod { return t.attrValue } -func (t ThemeDef) AttrValueError() ANSIMod { return t.attrValueError } -func (t ThemeDef) LevelError() ANSIMod { return t.levelError } -func (t ThemeDef) LevelWarn() ANSIMod { return t.levelWarn } -func (t ThemeDef) LevelInfo() ANSIMod { return t.levelInfo } -func (t ThemeDef) LevelDebug() ANSIMod { return t.levelDebug } -func (t ThemeDef) Level(level slog.Level) ANSIMod { - switch { - case level >= slog.LevelError: - return t.LevelError() - case level >= slog.LevelWarn: - return t.LevelWarn() - case level >= slog.LevelInfo: - return t.LevelInfo() - default: - return t.LevelDebug() - } +type Theme struct { + Name string + Timestamp ANSIMod + Header ANSIMod + Source ANSIMod + Message ANSIMod + MessageDebug ANSIMod + AttrKey ANSIMod + AttrValue ANSIMod + AttrValueError ANSIMod + LevelError ANSIMod + LevelWarn ANSIMod + LevelInfo ANSIMod + LevelDebug ANSIMod } func NewDefaultTheme() Theme { - return ThemeDef{ - name: "Default", - timestamp: ToANSICode(Faint), - header: ToANSICode(Faint, Bold), - source: ToANSICode(Faint, Italic), - message: ToANSICode(Bold), - messageDebug: ToANSICode(Bold), - attrKey: ToANSICode(Faint, Cyan), - attrValue: ToANSICode(), - attrValueError: ToANSICode(Bold, Red), - levelError: ToANSICode(Red), - levelWarn: ToANSICode(Yellow), - levelInfo: ToANSICode(Green), - levelDebug: ToANSICode(), + return Theme{ + Name: "Default", + Timestamp: ToANSICode(Faint), + Header: ToANSICode(Faint, Bold), + Source: ToANSICode(BrightBlack, Italic), + Message: ToANSICode(Bold), + MessageDebug: ToANSICode(Bold), + AttrKey: ToANSICode(Faint, Green), + AttrValue: ToANSICode(), + AttrValueError: ToANSICode(Bold, Red), + LevelError: ToANSICode(Red), + LevelWarn: ToANSICode(Yellow), + LevelInfo: ToANSICode(Cyan), + LevelDebug: ToANSICode(BrightMagenta), } } func NewBrightTheme() Theme { - return ThemeDef{ - name: "Bright", - timestamp: ToANSICode(Gray), - header: ToANSICode(Bold, Gray), - source: ToANSICode(Gray, Bold, Italic), - message: ToANSICode(Bold, White), - messageDebug: ToANSICode(), - attrKey: ToANSICode(BrightCyan), - attrValue: ToANSICode(), - attrValueError: ToANSICode(Bold, BrightRed), - levelError: ToANSICode(BrightRed), - levelWarn: ToANSICode(BrightYellow), - levelInfo: ToANSICode(BrightGreen), - levelDebug: ToANSICode(), + return Theme{ + Name: "Bright", + Timestamp: ToANSICode(Gray), + Header: ToANSICode(Bold, Gray), + Source: ToANSICode(Gray, Bold, Italic), + Message: ToANSICode(Bold, White), + MessageDebug: ToANSICode(), + AttrKey: ToANSICode(BrightCyan), + AttrValue: ToANSICode(), + AttrValueError: ToANSICode(Bold, BrightRed), + LevelError: ToANSICode(BrightRed), + LevelWarn: ToANSICode(BrightYellow), + LevelInfo: ToANSICode(BrightGreen), + LevelDebug: ToANSICode(), } } From 22580cc1c0edf171cf9ff07606780f76a6456525 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Wed, 5 Mar 2025 19:24:01 -0600 Subject: [PATCH 41/44] Experimental support for an alternate way to print multiline attributes --- encoding.go | 86 +++++++++++---------------- handler.go | 14 ++++- handler_test.go | 119 ++++++++++++++++++++++++++------------ internal/feature_flags.go | 13 +++++ 4 files changed, 141 insertions(+), 91 deletions(-) create mode 100644 internal/feature_flags.go diff --git a/encoding.go b/encoding.go index 72c7229..e1b1987 100644 --- a/encoding.go +++ b/encoding.go @@ -9,6 +9,8 @@ import ( "strings" "sync" "time" + + "github.com/ansel1/console-slog/internal" ) var encoderPool = &sync.Pool{ @@ -290,56 +292,17 @@ func (e *encoder) encodeAttr(groupPrefix string, a slog.Attr) { } offset := len(e.attrBuf) - e.writeAttr(&e.attrBuf, a, groupPrefix) + valOffset := e.writeAttr(a, groupPrefix) // check if the last attr written has newlines in it // if so, move it to the trailerBuf - lastAttr := e.attrBuf[offset:] - if bytes.IndexByte(lastAttr, '\n') >= 0 { - // todo: consider splitting the key and the value - // components, so the `key=` can be printed on its - // own line, and the value will not share any of its - // lines with anything else. Like: - // - // INF msg key1=val1 - // key2= - // val2 line 1 - // val2 line 2 - // key3= - // val3 line 1 - // val3 line 2 - // - // and maybe consider printing the key for these values - // differently, like: - // - // === key2 === - // val2 line1 - // val2 line2 - // === key3 === - // val3 line 1 - // val3 line 2 - // - // Splitting the key and value doesn't work up here in - // Handle() though, because we don't know where the term - // control characters are. Would need to push this - // multiline handling deeper into encoder, or pass - // offsets back up from writeAttr() - // - // if k, v, ok := bytes.Cut(lastAttr, []byte("=")); ok { - // trailerBuf.AppendString("=== ") - // trailerBuf.Append(k[1:]) - // trailerBuf.AppendString(" ===\n") - // trailerBuf.AppendByte('=') - // trailerBuf.AppendByte('\n') - // trailerBuf.AppendString("---------------------\n") - // trailerBuf.Append(v) - // trailerBuf.AppendString("\n---------------------\n") - // trailerBuf.AppendByte('\n') - // } else { - // trailerBuf.Append(lastAttr[1:]) - // trailerBuf.AppendByte('\n') - // } - e.multilineAttrBuf.Append(lastAttr) + if bytes.IndexByte(e.attrBuf[offset:], '\n') >= 0 { + if internal.FeatureFlagNewMultilineAttrs { + val := e.attrBuf[valOffset:] + e.writeMultilineAttr(a.Key, groupPrefix, val) + } else { + e.multilineAttrBuf.Append(e.attrBuf[offset:]) + } // rewind the middle buffer e.attrBuf = e.attrBuf[:offset] @@ -362,11 +325,16 @@ func (e *encoder) writeColoredString(w *buffer, s string, c ANSIMod) { }) } -func (e *encoder) writeAttr(buf *buffer, a slog.Attr, group string) { +// writeAttr encodes the attr to the attrBuf. The group will be prepended +// to the key, joined with a '.' +// +// returns the offset where the value starts, which may be used by the +// caller to split the key and value +func (e *encoder) writeAttr(a slog.Attr, group string) int { value := a.Value - buf.AppendByte(' ') - e.withColor(buf, e.h.opts.Theme.AttrKey, func() { + e.attrBuf.AppendByte(' ') + e.withColor(&e.attrBuf, e.h.opts.Theme.AttrKey, func() { if group != "" { e.attrBuf.AppendString(group) e.attrBuf.AppendByte('.') @@ -381,7 +349,23 @@ func (e *encoder) writeAttr(buf *buffer, a slog.Attr, group string) { style = e.h.opts.Theme.AttrValueError } } - e.writeColoredValue(buf, value, style) + valOffset := len(e.attrBuf) + e.writeColoredValue(&e.attrBuf, value, style) + return valOffset +} + +func (e *encoder) writeMultilineAttr(key, group string, value []byte) { + e.multilineAttrBuf.AppendByte('\n') + e.withColor(&e.multilineAttrBuf, e.h.opts.Theme.AttrKey, func() { + e.multilineAttrBuf.AppendString("=== ") + if group != "" { + e.multilineAttrBuf.AppendString(group) + e.multilineAttrBuf.AppendByte('.') + } + e.multilineAttrBuf.AppendString(key) + e.multilineAttrBuf.AppendString(" ===\n") + }) + e.multilineAttrBuf.Append(value) } func (e *encoder) writeValue(buf *buffer, value slog.Value) { diff --git a/handler.go b/handler.go index bee146e..b7f4353 100644 --- a/handler.go +++ b/handler.go @@ -11,6 +11,8 @@ import ( "slices" "strings" "time" + + "github.com/ansel1/console-slog/internal" ) var cwd string @@ -277,6 +279,7 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { // use a fixed size stack to avoid allocations, 3 deep nested groups should be enough for most cases stackArr := [3]encodeState{} stack := stackArr[:0] + var attrsFieldSeen bool for _, f := range h.fields { switch f := f.(type) { case groupOpen: @@ -364,11 +367,14 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { // but leave a space between attrBuf and multilineAttrBuf if len(enc.attrBuf) > 0 { enc.attrBuf = bytes.TrimSpace(enc.attrBuf) - } else if len(enc.multilineAttrBuf) > 0 { + } else if len(enc.multilineAttrBuf) > 0 && !internal.FeatureFlagNewMultilineAttrs { enc.multilineAttrBuf = bytes.TrimSpace(enc.multilineAttrBuf) } + attrsFieldSeen = true enc.buf.Append(enc.attrBuf) - enc.buf.Append(enc.multilineAttrBuf) + if !internal.FeatureFlagNewMultilineAttrs { + enc.buf.Append(enc.multilineAttrBuf) + } case sourceField: enc.encodeSource(src) case timestampField: @@ -388,6 +394,10 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { } } + if internal.FeatureFlagNewMultilineAttrs && attrsFieldSeen && len(enc.multilineAttrBuf) > 0 { + enc.buf.Append(enc.multilineAttrBuf) + } + enc.buf.AppendByte('\n') if _, err := enc.buf.WriteTo(h.out); err != nil { diff --git a/handler_test.go b/handler_test.go index 4ee2c70..1f170df 100644 --- a/handler_test.go +++ b/handler_test.go @@ -14,6 +14,8 @@ import ( "strings" "testing" "time" + + "github.com/ansel1/console-slog/internal" ) func TestNewHandler(t *testing.T) { @@ -180,62 +182,87 @@ func TestHandler_Attr(t *testing.T) { } func TestHandler_AttrsWithNewlines(t *testing.T) { - tests := []handlerTest{ + tests := []struct { + handlerTest + altWant string + }{ { - name: "single attr", - attrs: []slog.Attr{ - slog.String("foo", "line one\nline two"), + handlerTest: handlerTest{ + name: "single attr", + attrs: []slog.Attr{ + slog.String("foo", "line one\nline two"), + }, + want: "INF multiline attrs foo=line one\nline two\n", }, - want: "INF multiline attrs foo=line one\nline two\n", + altWant: "INF multiline attrs\n=== foo ===\nline one\nline two\n", }, { - name: "multiple attrs", - attrs: []slog.Attr{ - slog.String("foo", "line one\nline two"), - slog.String("bar", "line three\nline four"), + handlerTest: handlerTest{ + name: "multiple attrs", + attrs: []slog.Attr{ + slog.String("foo", "line one\nline two"), + slog.String("bar", "line three\nline four"), + }, + want: "INF multiline attrs foo=line one\nline two bar=line three\nline four\n", }, - want: "INF multiline attrs foo=line one\nline two bar=line three\nline four\n", + altWant: "INF multiline attrs\n=== foo ===\nline one\nline two\n=== bar ===\nline three\nline four\n", }, { - name: "sort multiline attrs to end", - attrs: []slog.Attr{ - slog.String("size", "big"), - slog.String("foo", "line one\nline two"), - slog.String("weight", "heavy"), - slog.String("bar", "line three\nline four"), - slog.String("color", "red"), + handlerTest: handlerTest{ + name: "sort multiline attrs to end", + attrs: []slog.Attr{ + slog.String("size", "big"), + slog.String("foo", "line one\nline two"), + slog.String("weight", "heavy"), + slog.String("bar", "line three\nline four"), + slog.String("color", "red"), + }, + want: "INF multiline attrs size=big weight=heavy color=red foo=line one\nline two bar=line three\nline four\n", }, - want: "INF multiline attrs size=big weight=heavy color=red foo=line one\nline two bar=line three\nline four\n", + altWant: "INF multiline attrs size=big weight=heavy color=red\n=== foo ===\nline one\nline two\n=== bar ===\nline three\nline four\n", }, { - name: "multiline message", - msg: "multiline\nmessage", - want: "INF multiline\nmessage\n", + handlerTest: handlerTest{ + name: "multiline message", + msg: "multiline\nmessage", + want: "INF multiline\nmessage\n", + }, + altWant: "INF multiline\nmessage\n", }, { - name: "trim leading and trailing newlines", - attrs: []slog.Attr{ - slog.String("foo", "\nline one\nline two\n"), + handlerTest: handlerTest{ + name: "preserve leading and trailing newlines", + attrs: []slog.Attr{ + slog.String("foo", "\nline one\nline two\n"), + slog.String("bar", "line three\nline four\n"), + }, + want: "INF multiline attrs foo=\nline one\nline two\n bar=line three\nline four\n", }, - want: "INF multiline attrs foo=\nline one\nline two\n", + altWant: "INF multiline attrs\n=== foo ===\n\nline one\nline two\n\n=== bar ===\nline three\nline four\n\n", }, { - name: "multiline attr using WithAttrs", - handlerFunc: func(h slog.Handler) slog.Handler { - return h.WithAttrs([]slog.Attr{ - slog.String("foo", "line one\nline two"), - }) + handlerTest: handlerTest{ + name: "multiline attr using WithAttrs", + handlerFunc: func(h slog.Handler) slog.Handler { + return h.WithAttrs([]slog.Attr{ + slog.String("foo", "line one\nline two"), + }) + }, + attrs: []slog.Attr{slog.String("bar", "baz")}, + want: "INF multiline attrs bar=baz foo=line one\nline two\n", }, - attrs: []slog.Attr{slog.String("bar", "baz")}, - want: "INF multiline attrs bar=baz foo=line one\nline two\n", + altWant: "INF multiline attrs bar=baz\n=== foo ===\nline one\nline two\n", }, { - name: "multiline header value", - opts: HandlerOptions{NoColor: true, HeaderFormat: "%l %[foo]h > %m"}, - attrs: []slog.Attr{ - slog.String("foo", "line one\nline two"), + handlerTest: handlerTest{ + name: "multiline header value", + opts: HandlerOptions{NoColor: true, HeaderFormat: "%l %[foo]h > %m"}, + attrs: []slog.Attr{ + slog.String("foo", "line one\nline two"), + }, + want: "INF line one\nline two > multiline attrs\n", }, - want: "INF line one\nline two > multiline attrs\n", + altWant: "INF line one\nline two > multiline attrs\n", }, } @@ -244,7 +271,23 @@ func TestHandler_AttrsWithNewlines(t *testing.T) { test.msg = "multiline attrs" } test.opts.NoColor = true - t.Run(test.name, test.run) + t.Run(test.name+" - old multiline", func(t *testing.T) { + oldValue := internal.FeatureFlagNewMultilineAttrs + internal.FeatureFlagNewMultilineAttrs = false + t.Cleanup(func() { + internal.FeatureFlagNewMultilineAttrs = oldValue + }) + test.run(t) + }) + test.want = test.altWant + t.Run(test.name+" - new multiline", func(t *testing.T) { + oldValue := internal.FeatureFlagNewMultilineAttrs + internal.FeatureFlagNewMultilineAttrs = true + t.Cleanup(func() { + internal.FeatureFlagNewMultilineAttrs = oldValue + }) + test.run(t) + }) } } diff --git a/internal/feature_flags.go b/internal/feature_flags.go new file mode 100644 index 0000000..b1e13b7 --- /dev/null +++ b/internal/feature_flags.go @@ -0,0 +1,13 @@ +package internal + +// FeatureFlagNewMultilineAttrs changes how attribute values containing newlines are handled. +// +// When true, multiline attributes are appended to the end of the log line, after everything in +// in the HeaderFormat, and the keys are printed on their own line, in the form "=== ===" +// +// When false, multiline attribute are printed right after the regular attributes, where ever that +// may be in the HeaderFormat. Keys are printed just like single-line attributes. +// +// In either case, multiline attributes will only be printed if the HeaderFormat contains an +// a %a directive. +var FeatureFlagNewMultilineAttrs = true From cc0cdc429510578bbfdf4b30387d57dc8a223db4 Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Thu, 6 Mar 2025 10:35:12 -0600 Subject: [PATCH 42/44] Added CI build (using github actions) --- .github/workflows/go.yml | 38 ++++++++++++++++++++------------------ handler_test.go | 4 ++-- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index bf6a993..c654b0b 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -1,31 +1,33 @@ -# This workflow will build a golang project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go - name: Build on: push: - branches: [ "main" ] + branches: [ master ] pull_request: - branches: [ "main" ] + branches: [ master ] + workflow_dispatch: jobs: - + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: stable + - uses: golangci/golangci-lint-action@v6 build: runs-on: ubuntu-latest + strategy: + matrix: + go: [ '^1.21', 'oldstable', 'stable' ] steps: - - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: - go-version: '1.21' - + go-version: ${{ matrix.go }} + - uses: actions/checkout@v4 - name: Build - run: go build -v ./... - + run: | + go build "./..." - name: Test - run: go test -race -coverprofile=coverage.txt -covermode=atomic -v ./... - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 \ No newline at end of file + run: go test -v -json ./... \ No newline at end of file diff --git a/handler_test.go b/handler_test.go index 1f170df..f167ba5 100644 --- a/handler_test.go +++ b/handler_test.go @@ -142,9 +142,9 @@ type formatterError struct { func (e *formatterError) Format(f fmt.State, verb rune) { if verb == 'v' && f.Flag('+') { - io.WriteString(f, "formatted ") + _, _ = io.WriteString(f, "formatted ") } - io.WriteString(f, e.Error()) + _, _ = io.WriteString(f, e.Error()) } func TestHandler_Attr(t *testing.T) { From 45ad6073c35ab6d0175ab990b5f33815ea3f2b7a Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Thu, 13 Mar 2025 11:09:56 -0500 Subject: [PATCH 43/44] Synchronize writes Handlers don't necessarily *need* to synchronize writes, as the underlying writer itself may handle this, but in practice, most of the slog handlers are doing this, include the builtin handlers. Particularly since this handler isn't performance focussed and isn't intended to be used in production, better safe than sorry. --- handler.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/handler.go b/handler.go index b7f4353..d7e5c36 100644 --- a/handler.go +++ b/handler.go @@ -10,6 +10,7 @@ import ( "runtime" "slices" "strings" + "sync" "time" "github.com/ansel1/console-slog/internal" @@ -137,6 +138,7 @@ type Handler struct { fields []any headerFields []headerField sourceAsAttr bool + mu *sync.Mutex } type timestampField struct{} @@ -238,6 +240,7 @@ func NewHandler(out io.Writer, opts *HandlerOptions) *Handler { fields: fields, headerFields: headerFields, sourceAsAttr: sourceAsAttr, + mu: &sync.Mutex{}, } } @@ -400,6 +403,8 @@ func (h *Handler) Handle(ctx context.Context, rec slog.Record) error { enc.buf.AppendByte('\n') + h.mu.Lock() + defer h.mu.Unlock() if _, err := enc.buf.WriteTo(h.out); err != nil { return err } @@ -417,7 +422,7 @@ type encodeState struct { // closes, if this is false, the entire group will be elided printedField bool // number of fields seen in this group. If this is 0, then - // the group only contains fixed strings, and no fields, and + // the group only contains fixed strings, and no fields, adn // should not be elided. seenFields int @@ -458,6 +463,7 @@ func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler { fields: h.fields, headerFields: headerFields, sourceAsAttr: h.sourceAsAttr, + mu: h.mu, } } @@ -477,6 +483,7 @@ func (h *Handler) WithGroup(name string) slog.Handler { fields: h.fields, headerFields: h.headerFields, sourceAsAttr: h.sourceAsAttr, + mu: h.mu, } } From bcedc3e63b317c54b62ff99e1ce3eac25bf12c1c Mon Sep 17 00:00:00 2001 From: Russ Egan Date: Wed, 26 Mar 2025 17:05:01 -0500 Subject: [PATCH 44/44] Update the README --- README.md | 33 ++++++++------------------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 193e151..cb96637 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # console-slog -[![Go Reference](https://pkg.go.dev/badge/github.com/phsym/console-slog.svg)](https://pkg.go.dev/github.com/phsym/console-slog) [![license](http://img.shields.io/badge/license-MIT-red.svg?style=flat)](https://raw.githubusercontent.com/phsym/console-slog/master/LICENSE) [![Build](https://github.com/phsym/console-slog/actions/workflows/go.yml/badge.svg?branch=main)](https://github.com/phsym/slog-console/actions/workflows/go.yml) [![codecov](https://codecov.io/gh/phsym/console-slog/graph/badge.svg?token=ZIJT9L79QP)](https://codecov.io/gh/phsym/console-slog) [![Go Report Card](https://goreportcard.com/badge/github.com/phsym/console-slog)](https://goreportcard.com/report/github.com/phsym/console-slog) +[![Go Reference](https://pkg.go.dev/badge/github.com/ansel1/console-slog.svg)](https://pkg.go.dev/github.com/ansel1/console-slog) [![license](http://img.shields.io/badge/license-MIT-red.svg?style=flat)](https://raw.githubusercontent.com/ansel1/console-slog/master/LICENSE) [![Build](https://github.com/ansel1/console-slog/actions/workflows/go.yml/badge.svg?branch=main)](https://github.com/ansel1/slog-console/actions/workflows/go.yml) [![codecov](https://codecov.io/gh/ansel1/console-slog/graph/badge.svg?token=ZIJT9L79QP)](https://codecov.io/gh/ansel1/console-slog) [![Go Report Card](https://goreportcard.com/badge/github.com/ansel1/console-slog)](https://goreportcard.com/report/github.com/ansel1/console-slog) A handler for slog that prints colorized logs, similar to zerolog's console writer output without sacrificing performances. ## Installation ```bash -go get github.com/phsym/console-slog@latest +go get github.com/ansel1/console-slog@latest ``` ## Example @@ -18,7 +18,7 @@ import ( "log/slog" "os" - "github.com/phsym/console-slog" + "github.com/ansel1/console-slog" ) func main() { @@ -50,26 +50,9 @@ console.NewHandler(os.Stderr, &console.HandlerOptions{Level: slog.LevelDebug, Ad ## Performances See [benchmark file](./bench_test.go) for details. -The handler itself performs quite well compared to std-lib's handlers. It does no allocation: -``` -goos: linux -goarch: amd64 -pkg: github.com/phsym/console-slog -cpu: Intel(R) Core(TM) i5-6300U CPU @ 2.40GHz -BenchmarkHandlers/dummy-4 128931026 8.732 ns/op 0 B/op 0 allocs/op -BenchmarkHandlers/console-4 849837 1294 ns/op 0 B/op 0 allocs/op -BenchmarkHandlers/std-text-4 542583 2097 ns/op 4 B/op 2 allocs/op -BenchmarkHandlers/std-json-4 583784 1911 ns/op 120 B/op 3 allocs/op -``` +The handler itself performs quite well compared to std-lib's handlers. It does no allocation. It is generally faster +then slog.TextHandler, and a little slower than slog.JSONHandler. -However, the go 1.21.0 `slog.Logger` adds some overhead: -``` -goos: linux -goarch: amd64 -pkg: github.com/phsym/console-slog -cpu: Intel(R) Core(TM) i5-6300U CPU @ 2.40GHz -BenchmarkLoggers/dummy-4 1239873 893.2 ns/op 128 B/op 1 allocs/op -BenchmarkLoggers/console-4 483354 2338 ns/op 128 B/op 1 allocs/op -BenchmarkLoggers/std-text-4 368828 3141 ns/op 132 B/op 3 allocs/op -BenchmarkLoggers/std-json-4 393322 2909 ns/op 248 B/op 4 allocs/op -``` \ No newline at end of file +## Credit + +This is a forked and heavily modified variant of github.com/phsym/console-slog. \ No newline at end of file