From 2f2c5ba2493978a1e135f811980ffa5ce14a5f45 Mon Sep 17 00:00:00 2001 From: Radoslav Dimitrov Date: Tue, 17 Feb 2026 22:52:40 +0200 Subject: [PATCH] Add NewHandler to logging package Signed-off-by: Radoslav Dimitrov --- logging/doc.go | 8 +++ logging/logging.go | 30 +++++++--- logging/logging_test.go | 130 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 8 deletions(-) diff --git a/logging/doc.go b/logging/doc.go index 65aee1b..fcf0690 100644 --- a/logging/doc.go +++ b/logging/doc.go @@ -49,6 +49,14 @@ Inject a buffer to capture log output in tests: logger.Info("test message") // inspect buf.String() +# Handler Access + +Use [NewHandler] when you need to wrap the handler with middleware: + + base := logging.NewHandler(logging.WithLevel(slog.LevelDebug)) + wrapped := &myMiddleware{Handler: base} + logger := slog.New(wrapped) + # Stability This package is Alpha stability. The API may change without notice. diff --git a/logging/logging.go b/logging/logging.go index ec0907c..fce8654 100644 --- a/logging/logging.go +++ b/logging/logging.go @@ -30,7 +30,7 @@ type config struct { output io.Writer } -// Option configures the logger created by [New]. +// Option configures [New] and [NewHandler]. type Option func(*config) // WithFormat sets the output format (JSON or Text). @@ -64,15 +64,17 @@ func WithOutput(w io.Writer) Option { } } -// New creates a pre-configured [*log/slog.Logger] with consistent defaults -// used across the ToolHive ecosystem. +// NewHandler creates a pre-configured [log/slog.Handler] with consistent +// defaults used across the ToolHive ecosystem. Use this when you need to wrap +// the handler with middleware (e.g., trace injection) before creating the +// final logger. // // Defaults: // - Format: JSON ([FormatJSON]) // - Level: INFO ([log/slog.LevelInfo]) // - Output: [os.Stderr] // - Timestamps: [time.RFC3339] -func New(opts ...Option) *slog.Logger { +func NewHandler(opts ...Option) slog.Handler { cfg := &config{ format: FormatJSON, level: slog.LevelInfo, @@ -88,15 +90,27 @@ func New(opts ...Option) *slog.Logger { ReplaceAttr: replaceAttr, } - var handler slog.Handler switch cfg.format { case FormatText: - handler = slog.NewTextHandler(cfg.output, handlerOpts) + return slog.NewTextHandler(cfg.output, handlerOpts) case FormatJSON: - handler = slog.NewJSONHandler(cfg.output, handlerOpts) + return slog.NewJSONHandler(cfg.output, handlerOpts) } - return slog.New(handler) + // Unreachable for known Format values; default to JSON for safety. + return slog.NewJSONHandler(cfg.output, handlerOpts) +} + +// New creates a pre-configured [*log/slog.Logger] with consistent defaults +// used across the ToolHive ecosystem. +// +// Defaults: +// - Format: JSON ([FormatJSON]) +// - Level: INFO ([log/slog.LevelInfo]) +// - Output: [os.Stderr] +// - Timestamps: [time.RFC3339] +func New(opts ...Option) *slog.Logger { + return slog.New(NewHandler(opts...)) } // replaceAttr formats the time attribute to RFC3339. diff --git a/logging/logging_test.go b/logging/logging_test.go index d35e2a3..3d7c88a 100644 --- a/logging/logging_test.go +++ b/logging/logging_test.go @@ -223,6 +223,136 @@ func TestNew_MultipleOptions(t *testing.T) { assert.Contains(t, output, "msg=\"debug message\"") } +func TestNewHandler(t *testing.T) { + t.Parallel() + + t.Run("returns a non-nil handler with no options", func(t *testing.T) { + t.Parallel() + handler := NewHandler() + assert.NotNil(t, handler) + }) + + t.Run("default format is JSON with RFC3339 timestamps", func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + handler := NewHandler(WithOutput(&buf)) + logger := slog.New(handler) + + logger.Info("test message", "key", "value") + + var entry map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &entry)) + + assert.Equal(t, "INFO", entry["level"]) + assert.Equal(t, "test message", entry["msg"]) + assert.Equal(t, "value", entry["key"]) + + ts, ok := entry["time"].(string) + require.True(t, ok, "time field should be a string") + _, err := time.Parse(time.RFC3339, ts) + assert.NoError(t, err, "timestamp should be valid RFC3339") + }) +} + +func TestNewHandler_WithFormat(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + format Format + check func(t *testing.T, output string) + }{ + { + name: "JSON format produces valid JSON", + format: FormatJSON, + check: func(t *testing.T, output string) { + t.Helper() + var entry map[string]any + require.NoError(t, json.Unmarshal([]byte(output), &entry)) + assert.Equal(t, "INFO", entry["level"]) + assert.Equal(t, "hello", entry["msg"]) + }, + }, + { + name: "text format produces key=value output", + format: FormatText, + check: func(t *testing.T, output string) { + t.Helper() + assert.Contains(t, output, "level=INFO") + assert.Contains(t, output, "msg=hello") + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + handler := NewHandler(WithFormat(tc.format), WithOutput(&buf)) + logger := slog.New(handler) + + logger.Info("hello") + + tc.check(t, buf.String()) + }) + } +} + +func TestNewHandler_WithLevel(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + level slog.Level + logLevel slog.Level + shouldWrite bool + }{ + {"debug logger writes debug", slog.LevelDebug, slog.LevelDebug, true}, + {"info logger filters debug", slog.LevelInfo, slog.LevelDebug, false}, + {"info logger writes info", slog.LevelInfo, slog.LevelInfo, true}, + {"warn logger filters info", slog.LevelWarn, slog.LevelInfo, false}, + {"warn logger writes warn", slog.LevelWarn, slog.LevelWarn, true}, + {"error logger filters warn", slog.LevelError, slog.LevelWarn, false}, + {"error logger writes error", slog.LevelError, slog.LevelError, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + handler := NewHandler(WithLevel(tc.level), WithOutput(&buf)) + logger := slog.New(handler) + + logger.Log(context.TODO(), tc.logLevel, "test") + + if tc.shouldWrite { + assert.NotEmpty(t, buf.String()) + } else { + assert.Empty(t, buf.String()) + } + }) + } +} + +func TestNewHandler_ProducesSameOutputAsNew(t *testing.T) { + t.Parallel() + + var buf1, buf2 bytes.Buffer + loggerFromNew := New(WithOutput(&buf1)) + loggerFromHandler := slog.New(NewHandler(WithOutput(&buf2))) + + loggerFromNew.Info("same message", "key", "value") + loggerFromHandler.Info("same message", "key", "value") + + var entry1, entry2 map[string]any + require.NoError(t, json.Unmarshal(buf1.Bytes(), &entry1)) + require.NoError(t, json.Unmarshal(buf2.Bytes(), &entry2)) + + assert.Equal(t, entry1["level"], entry2["level"]) + assert.Equal(t, entry1["msg"], entry2["msg"]) + assert.Equal(t, entry1["key"], entry2["key"]) +} + func TestReplaceAttr(t *testing.T) { t.Parallel()