Skip to content

Commit e36c5ae

Browse files
callthingsoffgopherbot
authored andcommitted
log/slog: add multiple handlers support for logger
Fixes golang#65954 Change-Id: Ib01c6f47126ce290108b20c07479c82ef17c427c GitHub-Last-Rev: 34a36ea GitHub-Pull-Request: golang#74840 Reviewed-on: https://go-review.googlesource.com/c/go/+/692237 LUCI-TryBot-Result: Go LUCI <[email protected]> Reviewed-by: Jonathan Amsterdam <[email protected]> Reviewed-by: Michael Pratt <[email protected]> Auto-Submit: Michael Pratt <[email protected]>
1 parent 150fae7 commit e36c5ae

File tree

5 files changed

+251
-0
lines changed

5 files changed

+251
-0
lines changed

api/next/65954.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
pkg log/slog, func NewMultiHandler(...Handler) *MultiHandler #65954
2+
pkg log/slog, method (*MultiHandler) Enabled(context.Context, Level) bool #65954
3+
pkg log/slog, method (*MultiHandler) Handle(context.Context, Record) error #65954
4+
pkg log/slog, method (*MultiHandler) WithAttrs([]Attr) Handler #65954
5+
pkg log/slog, method (*MultiHandler) WithGroup(string) Handler #65954
6+
pkg log/slog, type MultiHandler struct #65954
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
The [`NewMultiHandler`](/pkg/log/slog#NewMultiHandler) function creates a
2+
[`MultiHandler`](/pkg/log/slog#MultiHandler) that invokes all the given Handlers.
3+
Its `Enable` method reports whether any of the handlers' `Enabled` methods
4+
return true.
5+
Its `Handle`, `WithAttr` and `WithGroup` methods call the corresponding method
6+
on each of the enabled handlers.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright 2025 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package slog_test
6+
7+
import (
8+
"bytes"
9+
"log/slog"
10+
"os"
11+
)
12+
13+
func ExampleMultiHandler() {
14+
removeTime := func(groups []string, a slog.Attr) slog.Attr {
15+
if a.Key == slog.TimeKey && len(groups) == 0 {
16+
return slog.Attr{}
17+
}
18+
return a
19+
}
20+
21+
var textBuf, jsonBuf bytes.Buffer
22+
textHandler := slog.NewTextHandler(&textBuf, &slog.HandlerOptions{ReplaceAttr: removeTime})
23+
jsonHandler := slog.NewJSONHandler(&jsonBuf, &slog.HandlerOptions{ReplaceAttr: removeTime})
24+
25+
multiHandler := slog.NewMultiHandler(textHandler, jsonHandler)
26+
logger := slog.New(multiHandler)
27+
28+
logger.Info("login",
29+
slog.String("name", "whoami"),
30+
slog.Int("id", 42),
31+
)
32+
33+
os.Stdout.WriteString(textBuf.String())
34+
os.Stdout.WriteString(jsonBuf.String())
35+
36+
// Output:
37+
// level=INFO msg=login name=whoami id=42
38+
// {"level":"INFO","msg":"login","name":"whoami","id":42}
39+
}

src/log/slog/multi_handler.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright 2025 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package slog
6+
7+
import (
8+
"context"
9+
"errors"
10+
)
11+
12+
// NewMultiHandler creates a [MultiHandler] with the given Handlers.
13+
func NewMultiHandler(handlers ...Handler) *MultiHandler {
14+
h := make([]Handler, len(handlers))
15+
copy(h, handlers)
16+
return &MultiHandler{multi: h}
17+
}
18+
19+
// MultiHandler is a [Handler] that invokes all the given Handlers.
20+
// Its Enable method reports whether any of the handlers' Enabled methods return true.
21+
// Its Handle, WithAttr and WithGroup methods call the corresponding method on each of the enabled handlers.
22+
type MultiHandler struct {
23+
multi []Handler
24+
}
25+
26+
func (h *MultiHandler) Enabled(ctx context.Context, l Level) bool {
27+
for i := range h.multi {
28+
if h.multi[i].Enabled(ctx, l) {
29+
return true
30+
}
31+
}
32+
return false
33+
}
34+
35+
func (h *MultiHandler) Handle(ctx context.Context, r Record) error {
36+
var errs []error
37+
for i := range h.multi {
38+
if h.multi[i].Enabled(ctx, r.Level) {
39+
if err := h.multi[i].Handle(ctx, r.Clone()); err != nil {
40+
errs = append(errs, err)
41+
}
42+
}
43+
}
44+
return errors.Join(errs...)
45+
}
46+
47+
func (h *MultiHandler) WithAttrs(attrs []Attr) Handler {
48+
handlers := make([]Handler, 0, len(h.multi))
49+
for i := range h.multi {
50+
handlers = append(handlers, h.multi[i].WithAttrs(attrs))
51+
}
52+
return &MultiHandler{multi: handlers}
53+
}
54+
55+
func (h *MultiHandler) WithGroup(name string) Handler {
56+
handlers := make([]Handler, 0, len(h.multi))
57+
for i := range h.multi {
58+
handlers = append(handlers, h.multi[i].WithGroup(name))
59+
}
60+
return &MultiHandler{multi: handlers}
61+
}

src/log/slog/multi_handler_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright 2025 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package slog
6+
7+
import (
8+
"bytes"
9+
"context"
10+
"errors"
11+
"testing"
12+
"time"
13+
)
14+
15+
// mockFailingHandler is a handler that always returns an error
16+
// from its Handle method.
17+
type mockFailingHandler struct {
18+
Handler
19+
err error
20+
}
21+
22+
func (h *mockFailingHandler) Handle(ctx context.Context, r Record) error {
23+
_ = h.Handler.Handle(ctx, r)
24+
return h.err
25+
}
26+
27+
func TestMultiHandler(t *testing.T) {
28+
t.Run("Handle sends log to all handlers", func(t *testing.T) {
29+
var buf1, buf2 bytes.Buffer
30+
h1 := NewTextHandler(&buf1, nil)
31+
h2 := NewJSONHandler(&buf2, nil)
32+
33+
multi := NewMultiHandler(h1, h2)
34+
logger := New(multi)
35+
36+
logger.Info("hello world", "user", "test")
37+
38+
checkLogOutput(t, buf1.String(), "time="+textTimeRE+` level=INFO msg="hello world" user=test`)
39+
checkLogOutput(t, buf2.String(), `{"time":"`+jsonTimeRE+`","level":"INFO","msg":"hello world","user":"test"}`)
40+
})
41+
42+
t.Run("Enabled returns true if any handler is enabled", func(t *testing.T) {
43+
h1 := NewTextHandler(&bytes.Buffer{}, &HandlerOptions{Level: LevelError})
44+
h2 := NewTextHandler(&bytes.Buffer{}, &HandlerOptions{Level: LevelInfo})
45+
46+
multi := NewMultiHandler(h1, h2)
47+
48+
if !multi.Enabled(context.Background(), LevelInfo) {
49+
t.Error("Enabled should be true for INFO level, but got false")
50+
}
51+
if !multi.Enabled(context.Background(), LevelError) {
52+
t.Error("Enabled should be true for ERROR level, but got false")
53+
}
54+
})
55+
56+
t.Run("Enabled returns false if no handlers are enabled", func(t *testing.T) {
57+
h1 := NewTextHandler(&bytes.Buffer{}, &HandlerOptions{Level: LevelError})
58+
h2 := NewTextHandler(&bytes.Buffer{}, &HandlerOptions{Level: LevelInfo})
59+
60+
multi := NewMultiHandler(h1, h2)
61+
62+
if multi.Enabled(context.Background(), LevelDebug) {
63+
t.Error("Enabled should be false for DEBUG level, but got true")
64+
}
65+
})
66+
67+
t.Run("WithAttrs propagates attributes to all handlers", func(t *testing.T) {
68+
var buf1, buf2 bytes.Buffer
69+
h1 := NewTextHandler(&buf1, nil)
70+
h2 := NewJSONHandler(&buf2, nil)
71+
72+
multi := NewMultiHandler(h1, h2).WithAttrs([]Attr{String("request_id", "123")})
73+
logger := New(multi)
74+
75+
logger.Info("request processed")
76+
77+
checkLogOutput(t, buf1.String(), "time="+textTimeRE+` level=INFO msg="request processed" request_id=123`)
78+
checkLogOutput(t, buf2.String(), `{"time":"`+jsonTimeRE+`","level":"INFO","msg":"request processed","request_id":"123"}`)
79+
})
80+
81+
t.Run("WithGroup propagates group to all handlers", func(t *testing.T) {
82+
var buf1, buf2 bytes.Buffer
83+
h1 := NewTextHandler(&buf1, &HandlerOptions{AddSource: false})
84+
h2 := NewJSONHandler(&buf2, &HandlerOptions{AddSource: false})
85+
86+
multi := NewMultiHandler(h1, h2).WithGroup("req")
87+
logger := New(multi)
88+
89+
logger.Info("user login", "user_id", 42)
90+
91+
checkLogOutput(t, buf1.String(), "time="+textTimeRE+` level=INFO msg="user login" req.user_id=42`)
92+
checkLogOutput(t, buf2.String(), `{"time":"`+jsonTimeRE+`","level":"INFO","msg":"user login","req":{"user_id":42}}`)
93+
})
94+
95+
t.Run("Handle propagates errors from handlers", func(t *testing.T) {
96+
errFail := errors.New("mock failing")
97+
98+
var buf1, buf2 bytes.Buffer
99+
h1 := NewTextHandler(&buf1, nil)
100+
h2 := &mockFailingHandler{Handler: NewJSONHandler(&buf2, nil), err: errFail}
101+
102+
multi := NewMultiHandler(h2, h1)
103+
104+
err := multi.Handle(context.Background(), NewRecord(time.Now(), LevelInfo, "test message", 0))
105+
if !errors.Is(err, errFail) {
106+
t.Errorf("Expected error: %v, but got: %v", errFail, err)
107+
}
108+
109+
checkLogOutput(t, buf1.String(), "time="+textTimeRE+` level=INFO msg="test message"`)
110+
checkLogOutput(t, buf2.String(), `{"time":"`+jsonTimeRE+`","level":"INFO","msg":"test message"}`)
111+
})
112+
113+
t.Run("Handle with no handlers", func(t *testing.T) {
114+
multi := NewMultiHandler()
115+
logger := New(multi)
116+
117+
logger.Info("nothing")
118+
119+
err := multi.Handle(context.Background(), NewRecord(time.Now(), LevelInfo, "test", 0))
120+
if err != nil {
121+
t.Errorf("Handle with no sub-handlers should return nil, but got: %v", err)
122+
}
123+
})
124+
}
125+
126+
// Test that NewMultiHandler copies the input slice and is insulated from future modification.
127+
func TestNewMultiHandlerCopy(t *testing.T) {
128+
var buf1 bytes.Buffer
129+
h1 := NewTextHandler(&buf1, nil)
130+
slice := []Handler{h1}
131+
multi := NewMultiHandler(slice...)
132+
slice[0] = nil
133+
134+
err := multi.Handle(context.Background(), NewRecord(time.Now(), LevelInfo, "test message", 0))
135+
if err != nil {
136+
t.Errorf("Expected nil error, but got: %v", err)
137+
}
138+
checkLogOutput(t, buf1.String(), "time="+textTimeRE+` level=INFO msg="test message"`)
139+
}

0 commit comments

Comments
 (0)