]> Cypherpunks repositories - gostls13.git/commitdiff
log/slog: add multiple handlers support for logger
authorJes Cok <xigua67damn@gmail.com>
Wed, 27 Aug 2025 14:27:31 +0000 (14:27 +0000)
committerGopher Robot <gobot@golang.org>
Thu, 4 Sep 2025 18:07:44 +0000 (11:07 -0700)
Fixes #65954

Change-Id: Ib01c6f47126ce290108b20c07479c82ef17c427c
GitHub-Last-Rev: 34a36ea4bf099b2ad30f35e639155853ff73ef46
GitHub-Pull-Request: golang/go#74840
Reviewed-on: https://go-review.googlesource.com/c/go/+/692237
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Michael Pratt <mpratt@google.com>
Auto-Submit: Michael Pratt <mpratt@google.com>

api/next/65954.txt [new file with mode: 0644]
doc/next/6-stdlib/99-minor/log/slog/65954.md [new file with mode: 0644]
src/log/slog/example_multi_handler_test.go [new file with mode: 0644]
src/log/slog/multi_handler.go [new file with mode: 0644]
src/log/slog/multi_handler_test.go [new file with mode: 0644]

diff --git a/api/next/65954.txt b/api/next/65954.txt
new file mode 100644 (file)
index 0000000..88d7c26
--- /dev/null
@@ -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
diff --git a/doc/next/6-stdlib/99-minor/log/slog/65954.md b/doc/next/6-stdlib/99-minor/log/slog/65954.md
new file mode 100644 (file)
index 0000000..631ed66
--- /dev/null
@@ -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.
diff --git a/src/log/slog/example_multi_handler_test.go b/src/log/slog/example_multi_handler_test.go
new file mode 100644 (file)
index 0000000..daba82c
--- /dev/null
@@ -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}
+}
diff --git a/src/log/slog/multi_handler.go b/src/log/slog/multi_handler.go
new file mode 100644 (file)
index 0000000..4cc802b
--- /dev/null
@@ -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}
+}
diff --git a/src/log/slog/multi_handler_test.go b/src/log/slog/multi_handler_test.go
new file mode 100644 (file)
index 0000000..86844a6
--- /dev/null
@@ -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"`)
+}