diff --git a/api/next/65954.txt b/api/next/65954.txt new file mode 100644 index 00000000000000..c82d2c9542bb16 --- /dev/null +++ b/api/next/65954.txt @@ -0,0 +1 @@ +pkg log/slog, func MultiHandler(...Handler) Handler #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 index 00000000000000..3676658c281a29 --- /dev/null +++ b/doc/next/6-stdlib/99-minor/log/slog/65954.md @@ -0,0 +1,6 @@ +The [`MultiHandler`](/pkg/log/slog#MultiHandler) function returns 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. diff --git a/src/log/slog/multi_handler.go b/src/log/slog/multi_handler.go new file mode 100644 index 00000000000000..82a4dafad24ee9 --- /dev/null +++ b/src/log/slog/multi_handler.go @@ -0,0 +1,60 @@ +// 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" +) + +// MultiHandler returns 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. +func MultiHandler(handlers ...Handler) Handler { + h := make([]Handler, len(handlers)) + copy(h, handlers) + return &multiHandler{multi: h} +} + +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 index 00000000000000..7a4713dfa49dab --- /dev/null +++ b/src/log/slog/multi_handler_test.go @@ -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 := MultiHandler(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 := MultiHandler(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 := MultiHandler(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 := MultiHandler(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 := MultiHandler(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 := MultiHandler(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 := MultiHandler() + 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 MultiHandler copies the input slice and is insulated from future modification. +func TestMultiHandlerCopy(t *testing.T) { + var buf1 bytes.Buffer + h1 := NewTextHandler(&buf1, nil) + slice := []Handler{h1} + multi := MultiHandler(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"`) +}