slog-handler-guide: WithXXX methods without pre-formatting

Change-Id: Ia95d8ddd50eb72bef057768d88b216d7b9809c58
Reviewed-on: https://go-review.googlesource.com/c/example/+/511576
TryBot-Result: Gopher Robot <gobot@golang.org>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Ian Cottrell <iancottrell@google.com>
This commit is contained in:
Jonathan Amsterdam 2023-07-20 13:13:02 -04:00
Родитель f15fe1c962
Коммит 183ca08161
4 изменённых файлов: 495 добавлений и 11 удалений

Просмотреть файл

@ -325,13 +325,13 @@ Next, it follows the handler rule that says that empty attributes should be
ignored.
Then it switches on the attribute kind to determine what format to use. For most
(the default case of the switch), it relies on `slog.Value`'s `String` method to
kinds (the default case of the switch), it relies on `slog.Value`'s `String` method to
produce something reasonable. It handles strings and times specially:
strings by quoting them, and times by formatting them in a standard way.
When `appendAttr` sees a `Group`, it calls itself recursively on the group's
attributes, after applying two more handler rules.
First, a group with no attributes is ignored&emdash;not even its key is displayed.
First, a group with no attributes is ignored&mdash;not even its key is displayed.
Second, a group with an empty key is inlined: the group boundary isn't marked in
any way. In our case, that means the group's attributes aren't indented.
@ -360,8 +360,7 @@ the original handler (its receiver) unchanged. For example, this call:
creates a new logger, `logger2`, with an additional attribute, but has no
effect on `logger1`.
We will show an example implementation of `WithAttrs` below, when we discuss `WithGroup`.
We will show example implementations of `WithAttrs` below, when we discuss `WithGroup`.
## The `WithGroup` method
@ -390,7 +389,128 @@ the implementations of `Handler.WithGroup` and `Handler.WithAttrs`.
We will look at two implementations of `WithGroup` and `WithAttrs`, one that pre-formats and
one that doesn't.
TODO(jba): add IndentHandler examples
### Without pre-formatting
Our first implementation will collect the information from `WithGroup` and
`WithAttrs` calls to build up a slice of group names and attribute lists,
and loop over that slice in `Handle`. We start with a struct that can hold
either a group name or some attributes:
```
// groupOrAttrs holds either a group name or a list of slog.Attrs.
type groupOrAttrs struct {
group string // group name if non-empty
attrs []slog.Attr // attrs if non-empty
}
```
Then we add a slice of `groupOrAttrs` to our handler:
```
type IndentHandler struct {
opts Options
goas []groupOrAttrs
mu *sync.Mutex
out io.Writer
}
```
As stated above, The `WithGroup` and `WithAttrs` methods should not modify their
receiver.
To that end, we define a method that will copy our handler struct
and append one `groupOrAttrs` to the copy:
```
func (h *IndentHandler) withGroupOrAttrs(goa groupOrAttrs) *IndentHandler {
h2 := *h
h2.goas = make([]groupOrAttrs, len(h.goas)+1)
copy(h2.goas, h.goas)
h2.goas[len(h2.goas)-1] = goa
return &h2
}
```
Most of the fields of `IndentHandler` can be copied shallowly, but the slice of
`groupOrAttrs` requires a deep copy, or the clone and the original will point to
the same underlying array. If we used `append` instead of making an explicit
copy, we would introduce that subtle aliasing bug.
Using `withGroupOrAttrs`, the `With` methods are easy:
```
func (h *IndentHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
return h.withGroupOrAttrs(groupOrAttrs{group: name})
}
func (h *IndentHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return h
}
return h.withGroupOrAttrs(groupOrAttrs{attrs: attrs})
}
```
The `Handle` method can now process the groupOrAttrs slice after
the built-in attributes and before the ones in the record:
```
func (h *IndentHandler) Handle(ctx context.Context, r slog.Record) error {
buf := make([]byte, 0, 1024)
if !r.Time.IsZero() {
buf = h.appendAttr(buf, slog.Time(slog.TimeKey, r.Time), 0)
}
buf = h.appendAttr(buf, slog.Any(slog.LevelKey, r.Level), 0)
if r.PC != 0 {
fs := runtime.CallersFrames([]uintptr{r.PC})
f, _ := fs.Next()
buf = h.appendAttr(buf, slog.String(slog.SourceKey, fmt.Sprintf("%s:%d", f.File, f.Line)), 0)
}
buf = h.appendAttr(buf, slog.String(slog.MessageKey, r.Message), 0)
indentLevel := 0
// Handle state from WithGroup and WithAttrs.
goas := h.goas
if r.NumAttrs() == 0 {
// If the record has no Attrs, remove groups at the end of the list; they are empty.
for len(goas) > 0 && goas[len(goas)-1].group != "" {
goas = goas[:len(goas)-1]
}
}
for _, goa := range goas {
if goa.group != "" {
buf = fmt.Appendf(buf, "%*s%s:\n", indentLevel*4, "", goa.group)
indentLevel++
} else {
for _, a := range goa.attrs {
buf = h.appendAttr(buf, a, indentLevel)
}
}
}
r.Attrs(func(a slog.Attr) bool {
buf = h.appendAttr(buf, a, indentLevel)
return true
})
buf = append(buf, "---\n"...)
h.mu.Lock()
defer h.mu.Unlock()
_, err := h.out.Write(buf)
return err
}
```
You may have noticed that our algorithm for
recording `WithGroup` and `WithAttrs` information is quadratic in the
number of calls to those methods, because of the repeated copying.
That is unlikely to matter in practice, but if it bothers you,
you can use a linked list instead,
which `Handle` will have to reverse or visit recursively.
See [github.com/jba/slog/withsupport](https://github.com/jba/slog/withsupport) for an implementation.
### With pre-formatting
TODO(jba): write
## Testing
@ -465,7 +585,7 @@ to format a value will probably switch on the value's kind:
What should happen in the default case, when the handler encounters a `Kind`
that it doesn't know about?
The built-in handlers try to muddle through by using the result of the value's
`String` method.
`String` method, as our example handler does.
They do not panic or return an error.
Your own handlers might in addition want to report the problem through your production monitoring
or error-tracking telemetry system.

Просмотреть файл

@ -228,7 +228,7 @@ strings by quoting them, and times by formatting them in a standard way.
When `appendAttr` sees a `Group`, it calls itself recursively on the group's
attributes, after applying two more handler rules.
First, a group with no attributes is ignored&emdash;not even its key is displayed.
First, a group with no attributes is ignored&mdash;not even its key is displayed.
Second, a group with an empty key is inlined: the group boundary isn't marked in
any way. In our case, that means the group's attributes aren't indented.
@ -257,8 +257,7 @@ the original handler (its receiver) unchanged. For example, this call:
creates a new logger, `logger2`, with an additional attribute, but has no
effect on `logger1`.
We will show an example implementation of `WithAttrs` below, when we discuss `WithGroup`.
We will show example implementations of `WithAttrs` below, when we discuss `WithGroup`.
## The `WithGroup` method
@ -287,7 +286,51 @@ the implementations of `Handler.WithGroup` and `Handler.WithAttrs`.
We will look at two implementations of `WithGroup` and `WithAttrs`, one that pre-formats and
one that doesn't.
TODO(jba): add IndentHandler examples
### Without pre-formatting
Our first implementation will collect the information from `WithGroup` and
`WithAttrs` calls to build up a slice of group names and attribute lists,
and loop over that slice in `Handle`. We start with a struct that can hold
either a group name or some attributes:
%include indenthandler2/indent_handler.go gora -
Then we add a slice of `groupOrAttrs` to our handler:
%include indenthandler2/indent_handler.go IndentHandler -
As stated above, The `WithGroup` and `WithAttrs` methods should not modify their
receiver.
To that end, we define a method that will copy our handler struct
and append one `groupOrAttrs` to the copy:
%include indenthandler2/indent_handler.go withgora -
Most of the fields of `IndentHandler` can be copied shallowly, but the slice of
`groupOrAttrs` requires a deep copy, or the clone and the original will point to
the same underlying array. If we used `append` instead of making an explicit
copy, we would introduce that subtle aliasing bug.
Using `withGroupOrAttrs`, the `With` methods are easy:
%include indenthandler2/indent_handler.go withs -
The `Handle` method can now process the groupOrAttrs slice after
the built-in attributes and before the ones in the record:
%include indenthandler2/indent_handler.go handle -
You may have noticed that our algorithm for
recording `WithGroup` and `WithAttrs` information is quadratic in the
number of calls to those methods, because of the repeated copying.
That is unlikely to matter in practice, but if it bothers you,
you can use a linked list instead,
which `Handle` will have to reverse or visit recursively.
See [github.com/jba/slog/withsupport](https://github.com/jba/slog/withsupport) for an implementation.
### With pre-formatting
TODO(jba): write
## Testing
@ -362,7 +405,7 @@ to format a value will probably switch on the value's kind:
What should happen in the default case, when the handler encounters a `Kind`
that it doesn't know about?
The built-in handlers try to muddle through by using the result of the value's
`String` method.
`String` method, as our example handler does.
They do not panic or return an error.
Your own handlers might in addition want to report the problem through your production monitoring
or error-tracking telemetry system.

Просмотреть файл

@ -0,0 +1,164 @@
//go:build go1.21
package indenthandler
import (
"context"
"fmt"
"io"
"log/slog"
"runtime"
"sync"
"time"
)
// !+IndentHandler
type IndentHandler struct {
opts Options
goas []groupOrAttrs
mu *sync.Mutex
out io.Writer
}
//!-IndentHandler
type Options struct {
// Level reports the minimum level to log.
// Levels with lower levels are discarded.
// If nil, the Handler uses [slog.LevelInfo].
Level slog.Leveler
}
// !+gora
// groupOrAttrs holds either a group name or a list of slog.Attrs.
type groupOrAttrs struct {
group string // group name if non-empty
attrs []slog.Attr // attrs if non-empty
}
//!-gora
func New(out io.Writer, opts *Options) *IndentHandler {
h := &IndentHandler{out: out, mu: &sync.Mutex{}}
if opts != nil {
h.opts = *opts
}
if h.opts.Level == nil {
h.opts.Level = slog.LevelInfo
}
return h
}
func (h *IndentHandler) Enabled(ctx context.Context, level slog.Level) bool {
return level >= h.opts.Level.Level()
}
// !+withs
func (h *IndentHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
return h.withGroupOrAttrs(groupOrAttrs{group: name})
}
func (h *IndentHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return h
}
return h.withGroupOrAttrs(groupOrAttrs{attrs: attrs})
}
//!-withs
// !+withgora
func (h *IndentHandler) withGroupOrAttrs(goa groupOrAttrs) *IndentHandler {
h2 := *h
h2.goas = make([]groupOrAttrs, len(h.goas)+1)
copy(h2.goas, h.goas)
h2.goas[len(h2.goas)-1] = goa
return &h2
}
//!-withgora
// !+handle
func (h *IndentHandler) Handle(ctx context.Context, r slog.Record) error {
buf := make([]byte, 0, 1024)
if !r.Time.IsZero() {
buf = h.appendAttr(buf, slog.Time(slog.TimeKey, r.Time), 0)
}
buf = h.appendAttr(buf, slog.Any(slog.LevelKey, r.Level), 0)
if r.PC != 0 {
fs := runtime.CallersFrames([]uintptr{r.PC})
f, _ := fs.Next()
buf = h.appendAttr(buf, slog.String(slog.SourceKey, fmt.Sprintf("%s:%d", f.File, f.Line)), 0)
}
buf = h.appendAttr(buf, slog.String(slog.MessageKey, r.Message), 0)
indentLevel := 0
// Handle state from WithGroup and WithAttrs.
goas := h.goas
if r.NumAttrs() == 0 {
// If the record has no Attrs, remove groups at the end of the list; they are empty.
for len(goas) > 0 && goas[len(goas)-1].group != "" {
goas = goas[:len(goas)-1]
}
}
for _, goa := range goas {
if goa.group != "" {
buf = fmt.Appendf(buf, "%*s%s:\n", indentLevel*4, "", goa.group)
indentLevel++
} else {
for _, a := range goa.attrs {
buf = h.appendAttr(buf, a, indentLevel)
}
}
}
r.Attrs(func(a slog.Attr) bool {
buf = h.appendAttr(buf, a, indentLevel)
return true
})
buf = append(buf, "---\n"...)
h.mu.Lock()
defer h.mu.Unlock()
_, err := h.out.Write(buf)
return err
}
//!-handle
func (h *IndentHandler) appendAttr(buf []byte, a slog.Attr, indentLevel int) []byte {
// Resolve the Attr's value before doing anything else.
a.Value = a.Value.Resolve()
// Ignore empty Attrs.
if a.Equal(slog.Attr{}) {
return buf
}
// Indent 4 spaces per level.
buf = fmt.Appendf(buf, "%*s", indentLevel*4, "")
switch a.Value.Kind() {
case slog.KindString:
// Quote string values, to make them easy to parse.
buf = fmt.Appendf(buf, "%s: %q\n", a.Key, a.Value.String())
case slog.KindTime:
// Write times in a standard way, without the monotonic time.
buf = fmt.Appendf(buf, "%s: %s\n", a.Key, a.Value.Time().Format(time.RFC3339Nano))
case slog.KindGroup:
attrs := a.Value.Group()
// Ignore empty groups.
if len(attrs) == 0 {
return buf
}
// If the key is non-empty, write it out and indent the rest of the attrs.
// Otherwise, inline the attrs.
if a.Key != "" {
buf = fmt.Appendf(buf, "%s:\n", a.Key)
indentLevel++
}
for _, ga := range attrs {
buf = h.appendAttr(buf, ga, indentLevel)
}
default:
buf = fmt.Appendf(buf, "%s: %s\n", a.Key, a.Value)
}
return buf
}

Просмотреть файл

@ -0,0 +1,157 @@
//go:build go1.21
package indenthandler
import (
"bufio"
"bytes"
"fmt"
"reflect"
"regexp"
"strconv"
"strings"
"testing"
"unicode"
"log/slog"
"testing/slogtest"
)
func TestSlogtest(t *testing.T) {
var buf bytes.Buffer
err := slogtest.TestHandler(New(&buf, nil), func() []map[string]any {
return parseLogEntries(buf.String())
})
if err != nil {
t.Error(err)
}
}
func Test(t *testing.T) {
var buf bytes.Buffer
l := slog.New(New(&buf, nil))
l.Info("hello", "a", 1, "b", true, "c", 3.14, slog.Group("g", "h", 1, "i", 2), "d", "NO")
got := buf.String()
wantre := `time: [-0-9T:.]+Z?
level: INFO
source: ".*/indent_handler_test.go:\d+"
msg: "hello"
a: 1
b: true
c: 3.14
g:
h: 1
i: 2
d: "NO"
`
re := regexp.MustCompile(wantre)
if !re.MatchString(got) {
t.Errorf("\ngot:\n%q\nwant:\n%q", got, wantre)
}
buf.Reset()
l.Debug("test")
if got := buf.Len(); got != 0 {
t.Errorf("got buf.Len() = %d, want 0", got)
}
}
func parseLogEntries(s string) []map[string]any {
var ms []map[string]any
scan := bufio.NewScanner(strings.NewReader(s))
for scan.Scan() {
m := parseGroup(scan)
ms = append(ms, m)
}
if scan.Err() != nil {
panic(scan.Err())
}
return ms
}
func parseGroup(scan *bufio.Scanner) map[string]any {
m := map[string]any{}
groupIndent := -1
for {
line := scan.Text()
if line == "---" { // end of entry
break
}
k, v, found := strings.Cut(line, ":")
if !found {
panic(fmt.Sprintf("no ':' in line %q", line))
}
indent := strings.IndexFunc(k, func(r rune) bool {
return !unicode.IsSpace(r)
})
if indent < 0 {
panic("blank line")
}
if groupIndent < 0 {
// First line in group; remember the indent.
groupIndent = indent
} else if indent < groupIndent {
// End of group
break
} else if indent > groupIndent {
panic(fmt.Sprintf("indent increased on line %q", line))
}
key := strings.TrimSpace(k)
if v == "" {
// Just a key: start of a group.
if !scan.Scan() {
panic("empty group")
}
m[key] = parseGroup(scan)
} else {
v = strings.TrimSpace(v)
if len(v) > 0 && v[0] == '"' {
var err error
v, err = strconv.Unquote(v)
if err != nil {
panic(err)
}
}
m[key] = v
if !scan.Scan() {
break
}
}
}
return m
}
func TestParseLogEntries(t *testing.T) {
in := `
a: 1
b: 2
c: 3
g:
h: 4
i: 5
d: 6
---
e: 7
---
`
want := []map[string]any{
{
"a": "1",
"b": "2",
"c": "3",
"g": map[string]any{
"h": "4",
"i": "5",
},
"d": "6",
},
{
"e": "7",
},
}
got := parseLogEntries(in[1:])
if !reflect.DeepEqual(got, want) {
t.Errorf("\ngot:\n%v\nwant:\n%v", got, want)
}
}