Skip to content
Merged
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
53 changes: 53 additions & 0 deletions fanout.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package log

import (
"context"
"errors"
"log/slog"
)

// fanoutHandler distributes log records to multiple slog.Handlers.
type fanoutHandler struct {
handlers []slog.Handler
}

Comment thread
turkosaurus marked this conversation as resolved.
// 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
}
}
return false
}

// 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 {
errs = append(errs, err)
}
}
}
return errors.Join(errs...)
}

// 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}
}

// 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}
}
50 changes: 46 additions & 4 deletions log.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package log

import (
"io"
"log/slog"
"os"

Expand All @@ -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
Expand All @@ -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...)
}
Comment thread
turkosaurus marked this conversation as resolved.

_, envNoColor := os.LookupEnv("NO_COLOR")
if opts.Level == nil {
opts.Level = slog.LevelInfo
Expand All @@ -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,
Expand All @@ -51,3 +58,38 @@ func New(opts slog.HandlerOptions) (*slog.Logger, error) {
)
return logger, nil
}

// NewWithHandlers creates a logger that fans out to the default tint
// 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)
}
_, 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,
Comment thread
turkosaurus marked this conversation as resolved.
"handlers", len(all),
)
return logger, nil
}
121 changes: 121 additions & 0 deletions log_test.go
Original file line number Diff line number Diff line change
@@ -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"
)

Expand Down Expand Up @@ -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")
Expand Down
Loading