-
Notifications
You must be signed in to change notification settings - Fork 18.4k
log/slog: add multiple handlers support for logger #74840
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
+251
−0
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
efdbcfd
log/slog: add multiple handlers support for logger
callthingsoff dd96aeb
2
callthingsoff 4d9a380
3
callthingsoff f058a65
4
callthingsoff 7b6c242
5
callthingsoff 9f365b6
6
callthingsoff 34a36ea
7
callthingsoff File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
6 changes: 6 additions & 0 deletions
api/next/65954.txt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
pkg log/slog, func NewMultiHandler(...Handler) *MultiHandler #65954 | ||
pkg log/slog, method (*MultiHandler) Enabled(context.Context, Level) bool #65954 | ||
pkg log/slog, method (*MultiHandler) Handle(context.Context, Record) error #65954 | ||
pkg log/slog, method (*MultiHandler) WithAttrs([]Attr) Handler #65954 | ||
pkg log/slog, method (*MultiHandler) WithGroup(string) Handler #65954 | ||
pkg log/slog, type MultiHandler struct #65954 |
6 changes: 6 additions & 0 deletions
doc/next/6-stdlib/99-minor/log/slog/65954.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
The [`NewMultiHandler`](/pkg/log/slog#NewMultiHandler) function creates a | ||
[`MultiHandler`](/pkg/log/slog#MultiHandler) that invokes all the given Handlers. | ||
Its `Enable` method reports whether any of the handlers' `Enabled` methods | ||
return true. | ||
Its `Handle`, `WithAttr` and `WithGroup` methods call the corresponding method | ||
on each of the enabled handlers. |
39 changes: 39 additions & 0 deletions
src/log/slog/example_multi_handler_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
// Copyright 2025 The Go Authors. All rights reserved. | ||
// Use of this source code is governed by a BSD-style | ||
// license that can be found in the LICENSE file. | ||
|
||
package slog_test | ||
|
||
import ( | ||
"bytes" | ||
"log/slog" | ||
"os" | ||
) | ||
|
||
func ExampleMultiHandler() { | ||
removeTime := func(groups []string, a slog.Attr) slog.Attr { | ||
if a.Key == slog.TimeKey && len(groups) == 0 { | ||
return slog.Attr{} | ||
} | ||
return a | ||
} | ||
|
||
var textBuf, jsonBuf bytes.Buffer | ||
textHandler := slog.NewTextHandler(&textBuf, &slog.HandlerOptions{ReplaceAttr: removeTime}) | ||
jsonHandler := slog.NewJSONHandler(&jsonBuf, &slog.HandlerOptions{ReplaceAttr: removeTime}) | ||
|
||
multiHandler := slog.NewMultiHandler(textHandler, jsonHandler) | ||
logger := slog.New(multiHandler) | ||
|
||
logger.Info("login", | ||
slog.String("name", "whoami"), | ||
slog.Int("id", 42), | ||
) | ||
|
||
os.Stdout.WriteString(textBuf.String()) | ||
os.Stdout.WriteString(jsonBuf.String()) | ||
|
||
// Output: | ||
// level=INFO msg=login name=whoami id=42 | ||
// {"level":"INFO","msg":"login","name":"whoami","id":42} | ||
} |
61 changes: 61 additions & 0 deletions
src/log/slog/multi_handler.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
// Copyright 2025 The Go Authors. All rights reserved. | ||
// Use of this source code is governed by a BSD-style | ||
// license that can be found in the LICENSE file. | ||
|
||
package slog | ||
|
||
import ( | ||
"context" | ||
"errors" | ||
) | ||
|
||
// NewMultiHandler creates a [MultiHandler] with the given Handlers. | ||
func NewMultiHandler(handlers ...Handler) *MultiHandler { | ||
h := make([]Handler, len(handlers)) | ||
copy(h, handlers) | ||
return &MultiHandler{multi: h} | ||
} | ||
|
||
// MultiHandler is a [Handler] that invokes all the given Handlers. | ||
// Its Enable method reports whether any of the handlers' Enabled methods return true. | ||
// Its Handle, WithAttr and WithGroup methods call the corresponding method on each of the enabled handlers. | ||
type MultiHandler struct { | ||
multi []Handler | ||
} | ||
|
||
func (h *MultiHandler) Enabled(ctx context.Context, l Level) bool { | ||
for i := range h.multi { | ||
if h.multi[i].Enabled(ctx, l) { | ||
return true | ||
} | ||
} | ||
return false | ||
} | ||
|
||
func (h *MultiHandler) Handle(ctx context.Context, r Record) error { | ||
var errs []error | ||
for i := range h.multi { | ||
if h.multi[i].Enabled(ctx, r.Level) { | ||
if err := h.multi[i].Handle(ctx, r.Clone()); err != nil { | ||
errs = append(errs, err) | ||
} | ||
} | ||
} | ||
return errors.Join(errs...) | ||
} | ||
|
||
func (h *MultiHandler) WithAttrs(attrs []Attr) Handler { | ||
handlers := make([]Handler, 0, len(h.multi)) | ||
for i := range h.multi { | ||
handlers = append(handlers, h.multi[i].WithAttrs(attrs)) | ||
} | ||
return &MultiHandler{multi: handlers} | ||
} | ||
|
||
func (h *MultiHandler) WithGroup(name string) Handler { | ||
handlers := make([]Handler, 0, len(h.multi)) | ||
for i := range h.multi { | ||
handlers = append(handlers, h.multi[i].WithGroup(name)) | ||
} | ||
return &MultiHandler{multi: handlers} | ||
} |
139 changes: 139 additions & 0 deletions
src/log/slog/multi_handler_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,139 @@ | ||
// Copyright 2025 The Go Authors. All rights reserved. | ||
// Use of this source code is governed by a BSD-style | ||
// license that can be found in the LICENSE file. | ||
|
||
package slog | ||
|
||
import ( | ||
"bytes" | ||
"context" | ||
"errors" | ||
"testing" | ||
"time" | ||
) | ||
|
||
// mockFailingHandler is a handler that always returns an error | ||
// from its Handle method. | ||
type mockFailingHandler struct { | ||
Handler | ||
err error | ||
} | ||
|
||
func (h *mockFailingHandler) Handle(ctx context.Context, r Record) error { | ||
_ = h.Handler.Handle(ctx, r) | ||
return h.err | ||
} | ||
|
||
func TestMultiHandler(t *testing.T) { | ||
t.Run("Handle sends log to all handlers", func(t *testing.T) { | ||
var buf1, buf2 bytes.Buffer | ||
h1 := NewTextHandler(&buf1, nil) | ||
h2 := NewJSONHandler(&buf2, nil) | ||
|
||
multi := NewMultiHandler(h1, h2) | ||
logger := New(multi) | ||
|
||
logger.Info("hello world", "user", "test") | ||
|
||
checkLogOutput(t, buf1.String(), "time="+textTimeRE+` level=INFO msg="hello world" user=test`) | ||
checkLogOutput(t, buf2.String(), `{"time":"`+jsonTimeRE+`","level":"INFO","msg":"hello world","user":"test"}`) | ||
}) | ||
|
||
t.Run("Enabled returns true if any handler is enabled", func(t *testing.T) { | ||
h1 := NewTextHandler(&bytes.Buffer{}, &HandlerOptions{Level: LevelError}) | ||
h2 := NewTextHandler(&bytes.Buffer{}, &HandlerOptions{Level: LevelInfo}) | ||
|
||
multi := NewMultiHandler(h1, h2) | ||
|
||
if !multi.Enabled(context.Background(), LevelInfo) { | ||
t.Error("Enabled should be true for INFO level, but got false") | ||
} | ||
if !multi.Enabled(context.Background(), LevelError) { | ||
t.Error("Enabled should be true for ERROR level, but got false") | ||
} | ||
}) | ||
|
||
t.Run("Enabled returns false if no handlers are enabled", func(t *testing.T) { | ||
h1 := NewTextHandler(&bytes.Buffer{}, &HandlerOptions{Level: LevelError}) | ||
h2 := NewTextHandler(&bytes.Buffer{}, &HandlerOptions{Level: LevelInfo}) | ||
|
||
multi := NewMultiHandler(h1, h2) | ||
|
||
if multi.Enabled(context.Background(), LevelDebug) { | ||
t.Error("Enabled should be false for DEBUG level, but got true") | ||
} | ||
}) | ||
|
||
t.Run("WithAttrs propagates attributes to all handlers", func(t *testing.T) { | ||
var buf1, buf2 bytes.Buffer | ||
h1 := NewTextHandler(&buf1, nil) | ||
h2 := NewJSONHandler(&buf2, nil) | ||
|
||
multi := NewMultiHandler(h1, h2).WithAttrs([]Attr{String("request_id", "123")}) | ||
logger := New(multi) | ||
|
||
logger.Info("request processed") | ||
|
||
checkLogOutput(t, buf1.String(), "time="+textTimeRE+` level=INFO msg="request processed" request_id=123`) | ||
checkLogOutput(t, buf2.String(), `{"time":"`+jsonTimeRE+`","level":"INFO","msg":"request processed","request_id":"123"}`) | ||
}) | ||
|
||
t.Run("WithGroup propagates group to all handlers", func(t *testing.T) { | ||
var buf1, buf2 bytes.Buffer | ||
h1 := NewTextHandler(&buf1, &HandlerOptions{AddSource: false}) | ||
h2 := NewJSONHandler(&buf2, &HandlerOptions{AddSource: false}) | ||
|
||
multi := NewMultiHandler(h1, h2).WithGroup("req") | ||
logger := New(multi) | ||
|
||
logger.Info("user login", "user_id", 42) | ||
|
||
checkLogOutput(t, buf1.String(), "time="+textTimeRE+` level=INFO msg="user login" req.user_id=42`) | ||
checkLogOutput(t, buf2.String(), `{"time":"`+jsonTimeRE+`","level":"INFO","msg":"user login","req":{"user_id":42}}`) | ||
}) | ||
|
||
t.Run("Handle propagates errors from handlers", func(t *testing.T) { | ||
errFail := errors.New("mock failing") | ||
|
||
var buf1, buf2 bytes.Buffer | ||
h1 := NewTextHandler(&buf1, nil) | ||
h2 := &mockFailingHandler{Handler: NewJSONHandler(&buf2, nil), err: errFail} | ||
|
||
multi := NewMultiHandler(h2, h1) | ||
|
||
err := multi.Handle(context.Background(), NewRecord(time.Now(), LevelInfo, "test message", 0)) | ||
if !errors.Is(err, errFail) { | ||
t.Errorf("Expected error: %v, but got: %v", errFail, err) | ||
} | ||
|
||
checkLogOutput(t, buf1.String(), "time="+textTimeRE+` level=INFO msg="test message"`) | ||
checkLogOutput(t, buf2.String(), `{"time":"`+jsonTimeRE+`","level":"INFO","msg":"test message"}`) | ||
}) | ||
|
||
t.Run("Handle with no handlers", func(t *testing.T) { | ||
multi := NewMultiHandler() | ||
logger := New(multi) | ||
|
||
logger.Info("nothing") | ||
|
||
err := multi.Handle(context.Background(), NewRecord(time.Now(), LevelInfo, "test", 0)) | ||
if err != nil { | ||
t.Errorf("Handle with no sub-handlers should return nil, but got: %v", err) | ||
} | ||
}) | ||
} | ||
|
||
// Test that NewMultiHandler copies the input slice and is insulated from future modification. | ||
func TestNewMultiHandlerCopy(t *testing.T) { | ||
var buf1 bytes.Buffer | ||
h1 := NewTextHandler(&buf1, nil) | ||
slice := []Handler{h1} | ||
multi := NewMultiHandler(slice...) | ||
slice[0] = nil | ||
|
||
err := multi.Handle(context.Background(), NewRecord(time.Now(), LevelInfo, "test message", 0)) | ||
if err != nil { | ||
t.Errorf("Expected nil error, but got: %v", err) | ||
} | ||
checkLogOutput(t, buf1.String(), "time="+textTimeRE+` level=INFO msg="test message"`) | ||
} |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.