Skip to content

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
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/next/65954.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pkg log/slog, func MultiHandler(...Handler) Handler #65954
6 changes: 6 additions & 0 deletions doc/next/6-stdlib/99-minor/log/slog/65954.md
Original file line number Diff line number Diff line change
@@ -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.
60 changes: 60 additions & 0 deletions src/log/slog/multi_handler.go
Original file line number Diff line number Diff line change
@@ -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}
}
139 changes: 139 additions & 0 deletions src/log/slog/multi_handler_test.go
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 := 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"`)
}