From da35c1b6183ec4e967cd79fe8e608596423aaba5 Mon Sep 17 00:00:00 2001 From: turk Date: Sat, 28 Feb 2026 17:04:55 -0700 Subject: [PATCH 1/3] multiwriter --- log.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/log.go b/log.go index e60abf0..dd83cf2 100644 --- a/log.go +++ b/log.go @@ -1,6 +1,7 @@ package log import ( + "io" "log/slog" "os" @@ -9,7 +10,7 @@ import ( const ISO8601 = "2006-01-02T15:04:05.000Z" -// New creates a new slog.Logger with a tint handler that outputs to stderr. +// New creates a new slog.Logger with a tint handler that defaults to stderr. // If empty slog.HandlerOptions are provided, env var DEBUG is checked. // If DEBUG is unset: // - Level: slog.LevelInfo @@ -20,8 +21,14 @@ const ISO8601 = "2006-01-02T15:04:05.000Z" // - AddSource: true. // // NO_COLOR environment variable disables color output. -func New(opts slog.HandlerOptions) (*slog.Logger, error) { - w := os.Stderr +func New(opts slog.HandlerOptions, writers ...io.Writer) (*slog.Logger, error) { + var out io.Writer + if len(writers) == 0 { + out = os.Stderr + } else { + out = io.MultiWriter(writers...) + } + _, envNoColor := os.LookupEnv("NO_COLOR") if opts.Level == nil { opts.Level = slog.LevelInfo @@ -35,7 +42,7 @@ func New(opts slog.HandlerOptions) (*slog.Logger, error) { opts.AddSource = true } logger := slog.New( - tint.NewHandler(w, &tint.Options{ + tint.NewHandler(out, &tint.Options{ Level: opts.Level, AddSource: opts.AddSource, NoColor: envNoColor, From 015ac355ea8afcc5039a6176ad78056c4db231cc Mon Sep 17 00:00:00 2001 From: turk Date: Mon, 25 May 2026 14:50:23 -0600 Subject: [PATCH 2/3] add multiwriter support to allow otel --- fanout.go | 46 ++++++++++++++++++++++++++++++++++++++++++++++ log.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 fanout.go diff --git a/fanout.go b/fanout.go new file mode 100644 index 0000000..9848100 --- /dev/null +++ b/fanout.go @@ -0,0 +1,46 @@ +package log + +import ( + "context" + "log/slog" +) + +type FanoutHandler struct { + handlers []slog.Handler +} + +func (f *FanoutHandler) Enabled(ctx context.Context, l slog.Level) bool { + for _, h := range f.handlers { + if h.Enabled(ctx, l) { + return true + } + } + return false +} + +func (f *FanoutHandler) Handle(ctx context.Context, r slog.Record) error { + for _, h := range f.handlers { + if h.Enabled(ctx, r.Level) { + if err := h.Handle(ctx, r); err != nil { + return err + } + } + } + return nil +} + +func (f *FanoutHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + cloned := make([]slog.Handler, len(f.handlers)) + for i, h := range f.handlers { + cloned[i] = h.WithAttrs(attrs) + } + return &FanoutHandler{handlers: cloned} +} + +func (f *FanoutHandler) WithGroup(name string) slog.Handler { + cloned := make([]slog.Handler, len(f.handlers)) + for i, h := range f.handlers { + cloned[i] = h.WithGroup(name) + } + return &FanoutHandler{handlers: cloned} +} diff --git a/log.go b/log.go index dd83cf2..ed2b198 100644 --- a/log.go +++ b/log.go @@ -58,3 +58,40 @@ func New(opts slog.HandlerOptions, writers ...io.Writer) (*slog.Logger, error) { ) return logger, nil } + +// NewWithHandlers creates a logger that fans out to the default tint +// handler (stderr) plus any additional slog.Handlers provided. +// When no extra handlers are given, this is equivalent to New. +func NewWithHandlers( + opts slog.HandlerOptions, extra ...slog.Handler, +) (*slog.Logger, error) { + if len(extra) == 0 { + return New(opts) + } + _, envNoColor := os.LookupEnv("NO_COLOR") + if opts.Level == nil { + opts.Level = slog.LevelInfo + } + _, envDebug := os.LookupEnv("DEBUG") + if envDebug { + opts.Level = slog.LevelDebug + opts.AddSource = true + } + tintH := tint.NewHandler(os.Stderr, &tint.Options{ + Level: opts.Level, + AddSource: opts.AddSource, + NoColor: envNoColor, + TimeFormat: ISO8601, + }) + all := make([]slog.Handler, 0, len(extra)+1) + all = append(all, tintH) + all = append(all, extra...) + logger := slog.New(&FanoutHandler{handlers: all}) + logger.Debug( + "new logger (fanout)", + "source", opts.AddSource, + "level", opts.Level, + "handlers", len(all), + ) + return logger, nil +} From 385514e9ebe293588436f585bf56b6ef03c29ed7 Mon Sep 17 00:00:00 2001 From: turk Date: Mon, 25 May 2026 15:24:43 -0600 Subject: [PATCH 3/3] fixes --- fanout.go | 25 +++++++---- log.go | 10 ++--- log_test.go | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 15 deletions(-) diff --git a/fanout.go b/fanout.go index 9848100..9513c01 100644 --- a/fanout.go +++ b/fanout.go @@ -2,14 +2,17 @@ package log import ( "context" + "errors" "log/slog" ) -type FanoutHandler struct { +// fanoutHandler distributes log records to multiple slog.Handlers. +type fanoutHandler struct { handlers []slog.Handler } -func (f *FanoutHandler) Enabled(ctx context.Context, l slog.Level) bool { +// Enabled reports whether any underlying handler is enabled at the given level. +func (f *fanoutHandler) Enabled(ctx context.Context, l slog.Level) bool { for _, h := range f.handlers { if h.Enabled(ctx, l) { return true @@ -18,29 +21,33 @@ func (f *FanoutHandler) Enabled(ctx context.Context, l slog.Level) bool { return false } -func (f *FanoutHandler) Handle(ctx context.Context, r slog.Record) error { +// Handle sends the record to every enabled handler, collecting all errors. +func (f *fanoutHandler) Handle(ctx context.Context, r slog.Record) error { + var errs []error for _, h := range f.handlers { if h.Enabled(ctx, r.Level) { if err := h.Handle(ctx, r); err != nil { - return err + errs = append(errs, err) } } } - return nil + return errors.Join(errs...) } -func (f *FanoutHandler) WithAttrs(attrs []slog.Attr) slog.Handler { +// WithAttrs returns a new fanoutHandler with attrs appended to each handler. +func (f *fanoutHandler) WithAttrs(attrs []slog.Attr) slog.Handler { cloned := make([]slog.Handler, len(f.handlers)) for i, h := range f.handlers { cloned[i] = h.WithAttrs(attrs) } - return &FanoutHandler{handlers: cloned} + return &fanoutHandler{handlers: cloned} } -func (f *FanoutHandler) WithGroup(name string) slog.Handler { +// WithGroup returns a new fanoutHandler with the group applied to each handler. +func (f *fanoutHandler) WithGroup(name string) slog.Handler { cloned := make([]slog.Handler, len(f.handlers)) for i, h := range f.handlers { cloned[i] = h.WithGroup(name) } - return &FanoutHandler{handlers: cloned} + return &fanoutHandler{handlers: cloned} } diff --git a/log.go b/log.go index ed2b198..e3c7a52 100644 --- a/log.go +++ b/log.go @@ -60,11 +60,9 @@ func New(opts slog.HandlerOptions, writers ...io.Writer) (*slog.Logger, error) { } // NewWithHandlers creates a logger that fans out to the default tint -// handler (stderr) plus any additional slog.Handlers provided. -// When no extra handlers are given, this is equivalent to New. -func NewWithHandlers( - opts slog.HandlerOptions, extra ...slog.Handler, -) (*slog.Logger, error) { +// handler (stderr) plus any additional slog.Handlers, such as an +// OpenTelemetry log handler. Equivalent to New when no extras are given. +func NewWithHandlers(opts slog.HandlerOptions, extra ...slog.Handler) (*slog.Logger, error) { if len(extra) == 0 { return New(opts) } @@ -86,7 +84,7 @@ func NewWithHandlers( all := make([]slog.Handler, 0, len(extra)+1) all = append(all, tintH) all = append(all, extra...) - logger := slog.New(&FanoutHandler{handlers: all}) + logger := slog.New(&fanoutHandler{handlers: all}) logger.Debug( "new logger (fanout)", "source", opts.AddSource, diff --git a/log_test.go b/log_test.go index 52de82f..97dea44 100644 --- a/log_test.go +++ b/log_test.go @@ -1,10 +1,15 @@ package log import ( + "bytes" + "context" + "errors" "log/slog" "os" + "sync" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -59,6 +64,122 @@ func TestNew(t *testing.T) { ) } +func TestNewWriters(t *testing.T) { + t.Run("single writer", func(t *testing.T) { + var buf bytes.Buffer + opts := slog.HandlerOptions{Level: slog.LevelInfo} + log, err := New(opts, &buf) + require.NoError(t, err) + log.Info("hello") + assert.Contains(t, buf.String(), "hello") + }) + t.Run("multi writer", func(t *testing.T) { + var buf1, buf2 bytes.Buffer + opts := slog.HandlerOptions{Level: slog.LevelInfo} + log, err := New(opts, &buf1, &buf2) + require.NoError(t, err) + log.Info("hello") + assert.Contains(t, buf1.String(), "hello") + assert.Contains(t, buf2.String(), "hello") + }) +} + +func TestNewWithHandlers(t *testing.T) { + t.Run("extra handler receives records", func(t *testing.T) { + h := newCaptureHandler(slog.LevelInfo) + log, err := NewWithHandlers(slog.HandlerOptions{Level: slog.LevelInfo}, h) + require.NoError(t, err) + log.Info("test msg", "k", "v") + require.Len(t, h.state.records, 1) + assert.Equal(t, "test msg", h.state.records[0].Message) + }) + t.Run("level filtering", func(t *testing.T) { + h := newCaptureHandler(slog.LevelWarn) + log, err := NewWithHandlers(slog.HandlerOptions{Level: slog.LevelInfo}, h) + require.NoError(t, err) + log.Info("below warn") + assert.Empty(t, h.state.records) + log.Warn("at warn") + require.Len(t, h.state.records, 1) + }) + t.Run("WithAttrs propagation", func(t *testing.T) { + h := newCaptureHandler(slog.LevelInfo) + log, err := NewWithHandlers(slog.HandlerOptions{Level: slog.LevelInfo}, h) + require.NoError(t, err) + log = log.With("service", "test") + log.Info("msg") + require.Len(t, h.state.records, 1) + assert.True(t, h.state.hadAttrs, "WithAttrs should propagate") + }) + t.Run("WithGroup propagation", func(t *testing.T) { + h := newCaptureHandler(slog.LevelInfo) + log, err := NewWithHandlers(slog.HandlerOptions{Level: slog.LevelInfo}, h) + require.NoError(t, err) + log = log.WithGroup("grp") + log.Info("msg") + require.Len(t, h.state.records, 1) + assert.True(t, h.state.hadGroup, "WithGroup should propagate") + }) + t.Run("error collection", func(t *testing.T) { + errA := errors.New("handler A failed") + errB := errors.New("handler B failed") + hA := newCaptureHandler(slog.LevelInfo) + hA.err = errA + hB := newCaptureHandler(slog.LevelInfo) + hB.err = errB + f := &fanoutHandler{handlers: []slog.Handler{hA, hB}} + err := f.Handle(context.Background(), slog.Record{}) + require.Error(t, err) + assert.ErrorIs(t, err, errA) + assert.ErrorIs(t, err, errB) + }) +} + +type captureState struct { + mu sync.Mutex + records []slog.Record + hadAttrs bool + hadGroup bool +} + +type captureHandler struct { + state *captureState + level slog.Level + err error +} + +func newCaptureHandler(level slog.Level) *captureHandler { + return &captureHandler{ + state: &captureState{}, + level: level, + } +} + +func (c *captureHandler) Enabled(_ context.Context, l slog.Level) bool { + return l >= c.level +} + +func (c *captureHandler) Handle(_ context.Context, r slog.Record) error { + c.state.mu.Lock() + defer c.state.mu.Unlock() + c.state.records = append(c.state.records, r) + return c.err +} + +func (c *captureHandler) WithAttrs(_ []slog.Attr) slog.Handler { + c.state.mu.Lock() + c.state.hadAttrs = true + c.state.mu.Unlock() + return &captureHandler{state: c.state, level: c.level, err: c.err} +} + +func (c *captureHandler) WithGroup(_ string) slog.Handler { + c.state.mu.Lock() + c.state.hadGroup = true + c.state.mu.Unlock() + return &captureHandler{state: c.state, level: c.level, err: c.err} +} + func printAll(log *slog.Logger) { log = log.With("service", "cool_service") log.Debug("debug message test", "key", "value")