diff --git a/fanout.go b/fanout.go new file mode 100644 index 0000000..9513c01 --- /dev/null +++ b/fanout.go @@ -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 +} + +// 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} +} diff --git a/log.go b/log.go index e60abf0..e3c7a52 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, @@ -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, + "handlers", len(all), + ) + return logger, nil +} 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")