From 30feb471a351228e3be67d71355f4812f2a70c73 Mon Sep 17 00:00:00 2001 From: Antonio Pagano Date: Sat, 21 Mar 2026 08:53:20 -0500 Subject: [PATCH] Add default logger instance and package-level API This change introduces a default logger that can be used without creating a logger instance, similar to the standard library's log package. New API: - logger.Default() - Returns the default FieldLogger instance - logger.SetDefault(l FieldLogger) - Sets a custom default logger - Package-level functions: logger.Debug, logger.Info, logger.Warn, logger.Error, logger.Fatal, logger.Panic, logger.WithField, logger.WithFields, and all *f variants (Debugf, Infof, etc.) The fieldLogger implementation has been moved to its own file (field.go) for better code organization. Added comprehensive tests covering: - Default logger functionality - Package-level wrapper functions - Field logging with WithField/WithFields - Level filtering behavior - The logrus subpackage - The textFormatter implementation --- default.go | 178 ++++++------- default_test.go | 531 +++++++++++++++++++++++++++++++++++++++ field.go | 108 ++++++++ level.go | 23 ++ logger.go | 9 + logrus/formatter_test.go | 174 +++++++++++++ logrus/logrus.go | 33 ++- logrus/logrus_test.go | 284 +++++++++++++++++++++ 8 files changed, 1233 insertions(+), 107 deletions(-) create mode 100644 default_test.go create mode 100644 field.go create mode 100644 logrus/formatter_test.go create mode 100644 logrus/logrus_test.go diff --git a/default.go b/default.go index 3bdc2d0..5be2470 100644 --- a/default.go +++ b/default.go @@ -1,160 +1,126 @@ package logger import ( - "context" - "fmt" - "io" "log/slog" "os" + "sync" ) -// fieldLogger is a Logger implementation backed by log/slog -type fieldLogger struct { - logger *slog.Logger - attrs []slog.Attr -} - -// SetOutput sets the output writer for the logger. -func (s *fieldLogger) SetOutput(w io.Writer) { - opts := &slog.HandlerOptions{} - s.logger = slog.New(slog.NewTextHandler(w, opts)) -} - -func (s *fieldLogger) WithField(key string, value any) FieldLogger { - return s.WithFields(map[string]any{key: value}) -} - -func (s *fieldLogger) WithFields(m map[string]any) FieldLogger { - attrs := make([]slog.Attr, 0, len(m)+len(s.attrs)) - attrs = append(attrs, s.attrs...) - for k, v := range m { - attrs = append(attrs, slog.Any(k, v)) - } - return &fieldLogger{ - logger: s.logger, - attrs: attrs, - } -} +var ( + defaultLogger FieldLogger + defaultLoggerOnce sync.Once +) -func (s *fieldLogger) log(level slog.Level, msg string, args ...any) { - if len(args) > 0 { - msg = fmt.Sprintf(msg, args...) +// NewLogger based on the specified log level, defaults to "debug". +// See `New` for more details. +func NewLogger(level string) FieldLogger { + lvl, err := ParseLevel(level) + if err != nil { + lvl = DebugLevel } - s.logger.Log(context.Background(), level, msg, s.attrsToAny()...) + return New(lvl) } -func (s *fieldLogger) attrsToAny() []any { - result := make([]any, len(s.attrs)) - for i, a := range s.attrs { - result[i] = a - } - return result +// Default returns the default FieldLogger instance. +// The default logger is initialized with InfoLevel on first call. +func Default() FieldLogger { + defaultLoggerOnce.Do(func() { + if defaultLogger == nil { + defaultLogger = New(InfoLevel) + } + }) + return defaultLogger } -func (s *fieldLogger) Debugf(msg string, args ...any) { - s.log(slog.LevelDebug, msg, args...) +// SetDefault sets the default logger instance. +// Subsequent calls to Default() and package-level logging functions will use this logger. +func SetDefault(l FieldLogger) { + defaultLogger = l } -func (s *fieldLogger) Infof(msg string, args ...any) { - s.log(slog.LevelInfo, msg, args...) +// Debugf logs a formatted debug message using the default logger. +func Debugf(msg string, args ...any) { + Default().Debugf(msg, args...) } -func (s *fieldLogger) Printf(msg string, args ...any) { - s.Infof(msg, args...) +// Infof logs a formatted info message using the default logger. +func Infof(msg string, args ...any) { + Default().Infof(msg, args...) } -func (s *fieldLogger) Warnf(msg string, args ...any) { - s.log(slog.LevelWarn, msg, args...) +// Printf logs a formatted message using the default logger. +func Printf(msg string, args ...any) { + Default().Printf(msg, args...) } -func (s *fieldLogger) Errorf(msg string, args ...any) { - s.log(slog.LevelError, msg, args...) +// Warnf logs a formatted warning message using the default logger. +func Warnf(msg string, args ...any) { + Default().Warnf(msg, args...) } -func (s *fieldLogger) Fatalf(msg string, args ...any) { - s.log(slog.Level(12), msg, args...) - os.Exit(1) +// Errorf logs a formatted error message using the default logger. +func Errorf(msg string, args ...any) { + Default().Errorf(msg, args...) } -func (s *fieldLogger) Debug(args ...any) { - s.log(slog.LevelDebug, fmt.Sprint(args...)) +// Fatalf logs a formatted fatal message using the default logger and exits the program. +func Fatalf(msg string, args ...any) { + Default().Fatalf(msg, args...) } -func (s *fieldLogger) Info(args ...any) { - s.log(slog.LevelInfo, fmt.Sprint(args...)) +// Debug logs a debug message using the default logger. +func Debug(args ...any) { + Default().Debug(args...) } -func (s *fieldLogger) Warn(args ...any) { - s.log(slog.LevelWarn, fmt.Sprint(args...)) +// Info logs an info message using the default logger. +func Info(args ...any) { + Default().Info(args...) } -func (s *fieldLogger) Error(args ...any) { - s.log(slog.LevelError, fmt.Sprint(args...)) +// Warn logs a warning message using the default logger. +func Warn(args ...any) { + Default().Warn(args...) } -func (s *fieldLogger) Fatal(args ...any) { - s.log(slog.Level(12), fmt.Sprint(args...)) - os.Exit(1) +// Error logs an error message using the default logger. +func Error(args ...any) { + Default().Error(args...) } -func (s *fieldLogger) Panic(args ...any) { - msg := fmt.Sprint(args...) - s.log(slog.Level(12), msg) - panic(msg) +// Fatal logs a fatal message using the default logger and exits the program. +func Fatal(args ...any) { + Default().Fatal(args...) } -// ParseLevel parses a string level into a Level. -func ParseLevel(level string) (Level, error) { - switch level { - case "panic": - return PanicLevel, nil - case "fatal": - return FatalLevel, nil - case "error": - return ErrorLevel, nil - case "warn", "warning": - return WarnLevel, nil - case "info": - return InfoLevel, nil - case "debug": - return DebugLevel, nil - } - - var l Level - return l, fmt.Errorf("not a valid Level: %q", level) +// Panic logs a panic message using the default logger and panics. +func Panic(args ...any) { + Default().Panic(args...) } -// NewLogger based on the specified log level, defaults to "debug". -// See `New` for more details. -func NewLogger(level string) FieldLogger { - lvl, err := ParseLevel(level) - if err != nil { - lvl = DebugLevel - } - return New(lvl) +// WithField adds a field to the default logger and returns a new FieldLogger. +func WithField(key string, value any) FieldLogger { + return Default().WithField(key, value) } -// New based on the specified log level, defaults to "debug". -// This logger will log to the STDOUT in a human readable, -// but parseable form. -// -// Example: time="2016-12-01T21:02:07-05:00" level=info duration=225.283µs human_size="106 B" method=GET path="/" render=199.79µs request_id=2265736089 size=106 status=200 -func New(lvl Level) FieldLogger { - return newSlog(lvl) +// WithFields adds multiple fields to the default logger and returns a new FieldLogger. +func WithFields(fields map[string]any) FieldLogger { + return Default().WithFields(fields) } -func newSlog(lvl Level) FieldLogger { +func newDefault(lvl Level) FieldLogger { opts := &slog.HandlerOptions{ - Level: toSlogLevel(lvl), + Level: toDefaultLevel(lvl), } handler := slog.NewTextHandler(os.Stdout, opts) return &fieldLogger{ logger: slog.New(handler), + level: lvl, } } -// toSlogLevel converts our Level to slog.Level. -func toSlogLevel(l Level) slog.Level { +// toDefaultLevel converts our Level to slog.Level. +func toDefaultLevel(l Level) slog.Level { switch l { case PanicLevel, FatalLevel: return slog.Level(12) // custom level above error diff --git a/default_test.go b/default_test.go new file mode 100644 index 0000000..79496d1 --- /dev/null +++ b/default_test.go @@ -0,0 +1,531 @@ +package logger + +import ( + "bytes" + "strings" + "testing" +) + +func TestDefault(t *testing.T) { + t.Run("returns non-nil logger", func(t *testing.T) { + log := Default() + if log == nil { + t.Fatal("Default() should not return nil") + } + }) + + t.Run("returns same instance on multiple calls", func(t *testing.T) { + // Reset for this test + d := Default() + d2 := Default() + + if d != d2 { + t.Fatal("Default() should return the same instance on multiple calls") + } + }) +} + +func TestSetDefault(t *testing.T) { + t.Run("changes the default logger", func(t *testing.T) { + // Save the original default + original := Default() + + // Create a custom logger with a buffer + buf := &bytes.Buffer{} + custom := New(InfoLevel) + custom.(Outable).SetOutput(buf) + + // Set the custom logger as default + SetDefault(custom) + + // Verify the default is now our custom logger + if Default() != custom { + t.Fatal("SetDefault() did not change the default logger") + } + + // Test that package-level functions use the custom logger + Info("test message") + if !strings.Contains(buf.String(), "test message") { + t.Fatalf("Expected custom logger to log message, got: %s", buf.String()) + } + + // Restore original + SetDefault(original) + }) +} + +func TestPackageLevelFunctions(t *testing.T) { + t.Run("Info logs message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(InfoLevel) + log.(Outable).SetOutput(buf) + SetDefault(log) + + Info("info message") + if !strings.Contains(buf.String(), "info message") { + t.Fatalf("Expected Info to log message, got: %s", buf.String()) + } + }) + + t.Run("Infof logs formatted message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(InfoLevel) + log.(Outable).SetOutput(buf) + SetDefault(log) + + Infof("formatted %s message %d", "test", 42) + if !strings.Contains(buf.String(), "formatted test message 42") { + t.Fatalf("Expected Infof to log formatted message, got: %s", buf.String()) + } + }) + + t.Run("Debug does not log when level is Info", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(InfoLevel) + log.(Outable).SetOutput(buf) + SetDefault(log) + + Debug("debug message") + if strings.Contains(buf.String(), "debug message") { + t.Fatal("Debug should not log when level is Info") + } + }) + + t.Run("Warn logs message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(WarnLevel) + log.(Outable).SetOutput(buf) + SetDefault(log) + + Warn("warn message") + if !strings.Contains(buf.String(), "warn message") { + t.Fatalf("Expected Warn to log message, got: %s", buf.String()) + } + }) + + t.Run("Warnf logs formatted message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(WarnLevel) + log.(Outable).SetOutput(buf) + SetDefault(log) + + Warnf("warn %s", "message") + if !strings.Contains(buf.String(), "warn message") { + t.Fatalf("Expected Warnf to log formatted message, got: %s", buf.String()) + } + }) + + t.Run("Error logs message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(ErrorLevel) + log.(Outable).SetOutput(buf) + SetDefault(log) + + Error("error message") + if !strings.Contains(buf.String(), "error message") { + t.Fatalf("Expected Error to log message, got: %s", buf.String()) + } + }) + + t.Run("Errorf logs formatted message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(ErrorLevel) + log.(Outable).SetOutput(buf) + SetDefault(log) + + Errorf("error %s", "message") + if !strings.Contains(buf.String(), "error message") { + t.Fatalf("Expected Errorf to log formatted message, got: %s", buf.String()) + } + }) + + t.Run("Printf logs message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(InfoLevel) + log.(Outable).SetOutput(buf) + SetDefault(log) + + Printf("print %s", "message") + if !strings.Contains(buf.String(), "print message") { + t.Fatalf("Expected Printf to log message, got: %s", buf.String()) + } + }) +} + +func TestWithField(t *testing.T) { + t.Run("adds field to logger", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(InfoLevel) + log.(Outable).SetOutput(buf) + SetDefault(log) + + WithField("key", "value").Info("message with field") + if !strings.Contains(buf.String(), "key=value") { + t.Fatalf("Expected field to be logged, got: %s", buf.String()) + } + }) + + t.Run("returns new FieldLogger", func(t *testing.T) { + original := Default() + newLog := WithField("key", "value") + + if original == newLog { + t.Fatal("WithField should return a new logger instance") + } + }) +} + +func TestWithFields(t *testing.T) { + t.Run("adds multiple fields to logger", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(InfoLevel) + log.(Outable).SetOutput(buf) + SetDefault(log) + + WithFields(map[string]any{ + "key1": "value1", + "key2": 42, + }).Info("message with fields") + + output := buf.String() + if !strings.Contains(output, "key1=value1") { + t.Fatalf("Expected key1 to be logged, got: %s", output) + } + if !strings.Contains(output, "key2=42") { + t.Fatalf("Expected key2 to be logged, got: %s", output) + } + }) + + t.Run("returns new FieldLogger", func(t *testing.T) { + original := Default() + newLog := WithFields(map[string]any{"key": "value"}) + + if original == newLog { + t.Fatal("WithFields should return a new logger instance") + } + }) +} + +func TestParseLevel(t *testing.T) { + tests := []struct { + name string + input string + expected Level + wantErr bool + }{ + {"debug", "debug", DebugLevel, false}, + {"info", "info", InfoLevel, false}, + {"warn", "warn", WarnLevel, false}, + {"warning", "warning", WarnLevel, false}, + {"error", "error", ErrorLevel, false}, + {"fatal", "fatal", FatalLevel, false}, + {"panic", "panic", PanicLevel, false}, + {"invalid", "invalid", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + level, err := ParseLevel(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseLevel(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + if !tt.wantErr && level != tt.expected { + t.Fatalf("ParseLevel(%q) = %v, expected %v", tt.input, level, tt.expected) + } + }) + } +} + +func TestNewLogger(t *testing.T) { + t.Run("creates logger with valid level", func(t *testing.T) { + log := NewLogger("info") + if log == nil { + t.Fatal("NewLogger should not return nil") + } + }) + + t.Run("defaults to debug for invalid level", func(t *testing.T) { + // This should not panic and should return a logger + log := NewLogger("invalid") + if log == nil { + t.Fatal("NewLogger should not return nil for invalid level") + } + }) +} + +func TestFieldLogger_SetOutput(t *testing.T) { + t.Run("changes output destination", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(InfoLevel) + log.(Outable).SetOutput(buf) + + log.Info("test message") + if !strings.Contains(buf.String(), "test message") { + t.Fatalf("Expected message in buffer, got: %s", buf.String()) + } + }) +} + +func TestFieldLogger_Debug(t *testing.T) { + t.Run("logs debug message at debug level", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(DebugLevel) + log.(Outable).SetOutput(buf) + + log.Debug("debug message") + if !strings.Contains(buf.String(), "debug message") { + t.Fatalf("Expected debug message, got: %s", buf.String()) + } + }) + + t.Run("Debugf logs formatted debug message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(DebugLevel) + log.(Outable).SetOutput(buf) + + log.Debugf("debug %s %d", "test", 42) + if !strings.Contains(buf.String(), "debug test 42") { + t.Fatalf("Expected formatted debug message, got: %s", buf.String()) + } + }) +} + +func TestFieldLogger_Info(t *testing.T) { + t.Run("logs info message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(InfoLevel) + log.(Outable).SetOutput(buf) + + log.Info("info message") + if !strings.Contains(buf.String(), "info message") { + t.Fatalf("Expected info message, got: %s", buf.String()) + } + }) + + t.Run("Infof logs formatted message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(InfoLevel) + log.(Outable).SetOutput(buf) + + log.Infof("info %s", "formatted") + if !strings.Contains(buf.String(), "info formatted") { + t.Fatalf("Expected formatted info message, got: %s", buf.String()) + } + }) + + t.Run("Printf logs as info", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(InfoLevel) + log.(Outable).SetOutput(buf) + + log.Printf("print %s", "message") + if !strings.Contains(buf.String(), "print message") { + t.Fatalf("Expected print message, got: %s", buf.String()) + } + }) +} + +func TestFieldLogger_Warn(t *testing.T) { + t.Run("logs warn message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(WarnLevel) + log.(Outable).SetOutput(buf) + + log.Warn("warn message") + if !strings.Contains(buf.String(), "warn message") { + t.Fatalf("Expected warn message, got: %s", buf.String()) + } + }) + + t.Run("Warnf logs formatted warn message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(WarnLevel) + log.(Outable).SetOutput(buf) + + log.Warnf("warn %s", "formatted") + if !strings.Contains(buf.String(), "warn formatted") { + t.Fatalf("Expected formatted warn message, got: %s", buf.String()) + } + }) +} + +func TestFieldLogger_Error(t *testing.T) { + t.Run("logs error message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(ErrorLevel) + log.(Outable).SetOutput(buf) + + log.Error("error message") + if !strings.Contains(buf.String(), "error message") { + t.Fatalf("Expected error message, got: %s", buf.String()) + } + }) + + t.Run("Errorf logs formatted error message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(ErrorLevel) + log.(Outable).SetOutput(buf) + + log.Errorf("error %s", "formatted") + if !strings.Contains(buf.String(), "error formatted") { + t.Fatalf("Expected formatted error message, got: %s", buf.String()) + } + }) +} + +func TestFieldLogger_WithField(t *testing.T) { + t.Run("adds single field", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(InfoLevel) + log.(Outable).SetOutput(buf) + + log.WithField("key", "value").Info("message") + if !strings.Contains(buf.String(), "key=value") { + t.Fatalf("Expected field in output, got: %s", buf.String()) + } + }) + + t.Run("chains multiple WithField calls", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(InfoLevel) + log.(Outable).SetOutput(buf) + + log.WithField("key1", "value1").WithField("key2", "value2").Info("message") + output := buf.String() + if !strings.Contains(output, "key1=value1") { + t.Fatalf("Expected key1 in output, got: %s", output) + } + if !strings.Contains(output, "key2=value2") { + t.Fatalf("Expected key2 in output, got: %s", output) + } + }) + + t.Run("original logger is unchanged", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(InfoLevel) + log.(Outable).SetOutput(buf) + + newLog := log.WithField("key", "value") + log.Info("original message") + + if strings.Contains(buf.String(), "key=value") { + t.Fatal("Original logger should not have the field") + } + + newBuf := &bytes.Buffer{} + newLog.(Outable).SetOutput(newBuf) + newLog.Info("new message") + if !strings.Contains(newBuf.String(), "key=value") { + t.Fatalf("New logger should have the field, got: %s", newBuf.String()) + } + }) +} + +func TestFieldLogger_WithFields(t *testing.T) { + t.Run("adds multiple fields", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(InfoLevel) + log.(Outable).SetOutput(buf) + + log.WithFields(map[string]any{ + "key1": "value1", + "key2": 42, + }).Info("message") + + output := buf.String() + if !strings.Contains(output, "key1=value1") { + t.Fatalf("Expected key1 in output, got: %s", output) + } + if !strings.Contains(output, "key2=42") { + t.Fatalf("Expected key2 in output, got: %s", output) + } + }) + + t.Run("combines with existing fields", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(InfoLevel) + log.(Outable).SetOutput(buf) + + log.WithField("key1", "value1").WithFields(map[string]any{ + "key2": "value2", + }).Info("message") + + output := buf.String() + if !strings.Contains(output, "key1=value1") { + t.Fatalf("Expected key1 in output, got: %s", output) + } + if !strings.Contains(output, "key2=value2") { + t.Fatalf("Expected key2 in output, got: %s", output) + } + }) +} + +func TestFieldLogger_LevelFiltering(t *testing.T) { + t.Run("debug does not log at info level", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(InfoLevel) + log.(Outable).SetOutput(buf) + + log.Debug("debug message") + if strings.Contains(buf.String(), "debug message") { + t.Fatal("Debug should not log at InfoLevel") + } + }) + + t.Run("info does not log at warn level", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(WarnLevel) + log.(Outable).SetOutput(buf) + + log.Info("info message") + if strings.Contains(buf.String(), "info message") { + t.Fatal("Info should not log at WarnLevel") + } + }) + + t.Run("warn does not log at error level", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(ErrorLevel) + log.(Outable).SetOutput(buf) + + log.Warn("warn message") + if strings.Contains(buf.String(), "warn message") { + t.Fatal("Warn should not log at ErrorLevel") + } + }) + + t.Run("higher levels log at lower levels", func(t *testing.T) { + buf := &bytes.Buffer{} + log := New(DebugLevel) + log.(Outable).SetOutput(buf) + + log.Error("error message") + if !strings.Contains(buf.String(), "error message") { + t.Fatalf("Error should log at DebugLevel, got: %s", buf.String()) + } + }) +} + +func TestLevel_String(t *testing.T) { + tests := []struct { + level Level + expected string + }{ + {DebugLevel, "debug"}, + {InfoLevel, "info"}, + {WarnLevel, "warning"}, + {ErrorLevel, "error"}, + {FatalLevel, "fatal"}, + {PanicLevel, "panic"}, + {Level(999), "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := tt.level.String() + if result != tt.expected { + t.Fatalf("Level(%d).String() = %q, expected %q", tt.level, result, tt.expected) + } + }) + } +} diff --git a/field.go b/field.go new file mode 100644 index 0000000..3d9df64 --- /dev/null +++ b/field.go @@ -0,0 +1,108 @@ +package logger + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" +) + +// fieldLogger is a Logger implementation backed by log/slog +type fieldLogger struct { + logger *slog.Logger + attrs []slog.Attr + level Level +} + +// SetOutput sets the output writer for the logger. +func (s *fieldLogger) SetOutput(w io.Writer) { + opts := &slog.HandlerOptions{ + Level: toDefaultLevel(s.level), + } + s.logger = slog.New(slog.NewTextHandler(w, opts)) +} + +func (s *fieldLogger) WithField(key string, value any) FieldLogger { + return s.WithFields(map[string]any{key: value}) +} + +func (s *fieldLogger) WithFields(m map[string]any) FieldLogger { + attrs := make([]slog.Attr, 0, len(m)+len(s.attrs)) + attrs = append(attrs, s.attrs...) + for k, v := range m { + attrs = append(attrs, slog.Any(k, v)) + } + return &fieldLogger{ + logger: s.logger, + attrs: attrs, + level: s.level, + } +} + +func (s *fieldLogger) log(level slog.Level, msg string, args ...any) { + if len(args) > 0 { + msg = fmt.Sprintf(msg, args...) + } + s.logger.Log(context.Background(), level, msg, s.attrsToAny()...) +} + +func (s *fieldLogger) attrsToAny() []any { + result := make([]any, len(s.attrs)) + for i, a := range s.attrs { + result[i] = a + } + return result +} + +func (s *fieldLogger) Debugf(msg string, args ...any) { + s.log(slog.LevelDebug, msg, args...) +} + +func (s *fieldLogger) Infof(msg string, args ...any) { + s.log(slog.LevelInfo, msg, args...) +} + +func (s *fieldLogger) Printf(msg string, args ...any) { + s.Infof(msg, args...) +} + +func (s *fieldLogger) Warnf(msg string, args ...any) { + s.log(slog.LevelWarn, msg, args...) +} + +func (s *fieldLogger) Errorf(msg string, args ...any) { + s.log(slog.LevelError, msg, args...) +} + +func (s *fieldLogger) Fatalf(msg string, args ...any) { + s.log(slog.Level(12), msg, args...) + os.Exit(1) +} + +func (s *fieldLogger) Debug(args ...any) { + s.log(slog.LevelDebug, fmt.Sprint(args...)) +} + +func (s *fieldLogger) Info(args ...any) { + s.log(slog.LevelInfo, fmt.Sprint(args...)) +} + +func (s *fieldLogger) Warn(args ...any) { + s.log(slog.LevelWarn, fmt.Sprint(args...)) +} + +func (s *fieldLogger) Error(args ...any) { + s.log(slog.LevelError, fmt.Sprint(args...)) +} + +func (s *fieldLogger) Fatal(args ...any) { + s.log(slog.Level(12), fmt.Sprint(args...)) + os.Exit(1) +} + +func (s *fieldLogger) Panic(args ...any) { + msg := fmt.Sprint(args...) + s.log(slog.Level(12), msg) + panic(msg) +} diff --git a/level.go b/level.go index 8f8dea4..3b7294e 100644 --- a/level.go +++ b/level.go @@ -1,5 +1,7 @@ package logger +import "fmt" + // Level represents the logging level type Level uint32 @@ -42,3 +44,24 @@ func (level Level) String() string { return "unknown" } + +// ParseLevel parses a string level into a Level. +func ParseLevel(level string) (Level, error) { + switch level { + case "panic": + return PanicLevel, nil + case "fatal": + return FatalLevel, nil + case "error": + return ErrorLevel, nil + case "warn", "warning": + return WarnLevel, nil + case "info": + return InfoLevel, nil + case "debug": + return DebugLevel, nil + } + + var l Level + return l, fmt.Errorf("not a valid Level: %q", level) +} diff --git a/logger.go b/logger.go index a86749c..54f88e4 100644 --- a/logger.go +++ b/logger.go @@ -47,3 +47,12 @@ type Logger interface { Fatal(...any) Panic(...any) } + +// New based on the specified log level, defaults to "debug". +// This logger will log to the STDOUT in a human readable, +// but parseable form. +// +// Example: time="2016-12-01T21:02:07-05:00" level=info duration=225.283µs human_size="106 B" method=GET path="/" render=199.79µs request_id=2265736089 size=106 status=200 +func New(lvl Level) FieldLogger { + return newDefault(lvl) +} diff --git a/logrus/formatter_test.go b/logrus/formatter_test.go new file mode 100644 index 0000000..d8dd308 --- /dev/null +++ b/logrus/formatter_test.go @@ -0,0 +1,174 @@ +package logrus + +import ( + "bytes" + "strings" + "testing" + + "github.com/sirupsen/logrus" +) + +func TestTextFormatter_Format(t *testing.T) { + t.Run("formats basic entry with level and message", func(t *testing.T) { + f := &textFormatter{} + entry := &logrus.Entry{ + Logger: logrus.New(), + Message: "test message", + Level: logrus.InfoLevel, + Data: logrus.Fields{}, + } + + result, err := f.Format(entry) + if err != nil { + t.Fatalf("Format returned error: %v", err) + } + + output := string(result) + if !strings.Contains(output, "test message") { + t.Fatalf("Expected message in output, got: %s", output) + } + if !strings.Contains(output, "info") && !strings.Contains(output, "INFO") { + t.Fatalf("Expected level in output, got: %s", output) + } + }) + + t.Run("includes fields in output", func(t *testing.T) { + f := &textFormatter{} + entry := &logrus.Entry{ + Logger: logrus.New(), + Message: "test message", + Level: logrus.InfoLevel, + Data: logrus.Fields{ + "key1": "value1", + "key2": "value2", + }, + } + + result, err := f.Format(entry) + if err != nil { + t.Fatalf("Format returned error: %v", err) + } + + output := string(result) + if !strings.Contains(output, "key1") { + t.Fatalf("Expected key1 in output, got: %s", output) + } + if !strings.Contains(output, "key2") { + t.Fatalf("Expected key2 in output, got: %s", output) + } + }) + + t.Run("applies colors when ForceColors is true", func(t *testing.T) { + f := &textFormatter{ForceColors: true} + entry := &logrus.Entry{ + Logger: logrus.New(), + Message: "colored message", + Level: logrus.ErrorLevel, + Data: logrus.Fields{}, + } + + result, err := f.Format(entry) + if err != nil { + t.Fatalf("Format returned error: %v", err) + } + + output := string(result) + if !strings.Contains(output, "\x1b[") { + t.Fatalf("Expected ANSI color codes in output, got: %s", output) + } + }) + + t.Run("quotes values containing spaces", func(t *testing.T) { + f := &textFormatter{} + entry := &logrus.Entry{ + Logger: logrus.New(), + Message: "test", + Level: logrus.InfoLevel, + Data: logrus.Fields{ + "key": "value with spaces", + }, + } + + result, err := f.Format(entry) + if err != nil { + t.Fatalf("Format returned error: %v", err) + } + + output := string(result) + if !strings.Contains(output, `"value with spaces"`) { + t.Fatalf("Expected quoted value in output, got: %s", output) + } + }) + + t.Run("does not quote simple values", func(t *testing.T) { + f := &textFormatter{} + entry := &logrus.Entry{ + Logger: logrus.New(), + Message: "test", + Level: logrus.InfoLevel, + Data: logrus.Fields{ + "key": "simple_value-123", + }, + } + + result, err := f.Format(entry) + if err != nil { + t.Fatalf("Format returned error: %v", err) + } + + output := string(result) + if strings.Contains(output, `"simple_value-123"`) { + t.Fatalf("Expected unquoted value in output, got: %s", output) + } + }) + + t.Run("uses entry buffer when available", func(t *testing.T) { + f := &textFormatter{} + buf := &bytes.Buffer{} + entry := &logrus.Entry{ + Logger: logrus.New(), + Message: "buffered", + Level: logrus.InfoLevel, + Data: logrus.Fields{}, + Buffer: buf, + } + + _, err := f.Format(entry) + if err != nil { + t.Fatalf("Format returned error: %v", err) + } + + if buf.Len() == 0 { + t.Fatal("Expected buffer to contain data") + } + }) +} + +func TestPrefixFieldClashes(t *testing.T) { + t.Run("renames reserved field 'time'", func(t *testing.T) { + data := logrus.Fields{"time": "2023-01-01"} + prefixFieldClashes(data) + + if _, ok := data["fields.time"]; !ok { + t.Fatal("Expected 'time' to be renamed to 'fields.time'") + } + }) + + t.Run("renames reserved field 'msg'", func(t *testing.T) { + data := logrus.Fields{"msg": "hello"} + prefixFieldClashes(data) + + if _, ok := data["fields.msg"]; !ok { + t.Fatal("Expected 'msg' to be renamed to 'fields.msg'") + } + }) + + t.Run("renames reserved field 'level'", func(t *testing.T) { + data := logrus.Fields{"level": "debug"} + prefixFieldClashes(data) + + if _, ok := data["fields.level"]; !ok { + t.Fatal("Expected 'level' to be renamed to 'fields.level'") + } + }) +} diff --git a/logrus/logrus.go b/logrus/logrus.go index 5e1d6b9..791b8ec 100644 --- a/logrus/logrus.go +++ b/logrus/logrus.go @@ -4,15 +4,46 @@ // slog-based implementation in the root logger package. Importing this package // adds logrus as a dependency to your project. // +// Features: +// - Full compatibility with the logger.FieldLogger interface +// - Structured logging with fields via WithField/WithFields +// - Multiple log levels (Debug, Info, Warn, Error, Fatal, Panic) +// - Formatted logging methods (Debugf, Infof, etc.) +// - Custom text formatter with color support for terminal output +// +// When to use logrus vs default: +// - Use logrus if you need hooks, formatters, or logrus-specific features +// - Use the default (slog-based) for zero external dependencies +// +// Level mapping: +// - logger.DebugLevel -> logrus.DebugLevel +// - logger.InfoLevel -> logrus.InfoLevel +// - logger.WarnLevel -> logrus.WarnLevel +// - logger.ErrorLevel -> logrus.ErrorLevel +// - logger.FatalLevel -> logrus.FatalLevel +// - logger.PanicLevel -> logrus.PanicLevel +// // Usage: // // import "github.com/gobuffalo/logger/v2/logrus" +// +// // Create a new logger // log := logrus.New(logrus.InfoLevel) // log.Info("Application started") +// +// // With structured fields // log.WithField("version", "1.0.0").Info("Version info") +// log.WithFields(map[string]any{ +// "request_id": "abc123", +// "user_id": 42, +// }).Info("Request processed") +// +// // From string level (defaults to debug on invalid) +// log := logrus.NewLogger("info") // // The fieldLogger type implements the logger.FieldLogger interface, making it -// interchangeable with the default logger implementation. +// fully interchangeable with the default logger implementation. You can pass +// logrus loggers anywhere that accepts logger.FieldLogger. package logrus import ( diff --git a/logrus/logrus_test.go b/logrus/logrus_test.go new file mode 100644 index 0000000..698bbdf --- /dev/null +++ b/logrus/logrus_test.go @@ -0,0 +1,284 @@ +package logrus + +import ( + "bytes" + "strings" + "testing" + + "github.com/gobuffalo/logger/v2" +) + +func newTestLogger(t *testing.T, lvl logger.Level, buf *bytes.Buffer) logger.FieldLogger { + t.Helper() + log := New(lvl) + log.(logger.Outable).SetOutput(buf) + return log +} + +func TestNew(t *testing.T) { + t.Run("creates logger with valid level", func(t *testing.T) { + log := New(logger.InfoLevel) + if log == nil { + t.Fatal("New should not return nil") + } + }) + + t.Run("implements FieldLogger interface", func(t *testing.T) { + log := New(logger.DebugLevel) + var _ logger.FieldLogger = log + }) + + t.Run("implements Outable interface", func(t *testing.T) { + log := New(logger.DebugLevel) + var _ logger.Outable = log.(fieldLogger) + }) +} + +func TestNewLogger(t *testing.T) { + t.Run("creates logger from string level", func(t *testing.T) { + log := NewLogger("info") + if log == nil { + t.Fatal("NewLogger should not return nil") + } + }) + + t.Run("defaults to debug for invalid level", func(t *testing.T) { + log := NewLogger("invalid") + if log == nil { + t.Fatal("NewLogger should not return nil for invalid level") + } + }) + + t.Run("parses all valid levels", func(t *testing.T) { + levels := []string{"debug", "info", "warn", "error", "fatal", "panic"} + for _, lvl := range levels { + log := NewLogger(lvl) + if log == nil { + t.Fatalf("NewLogger(%q) should not return nil", lvl) + } + } + }) +} + +func TestFieldLogger_SetOutput(t *testing.T) { + t.Run("changes output destination", func(t *testing.T) { + buf := &bytes.Buffer{} + log := newTestLogger(t, logger.InfoLevel, buf) + + log.Info("test message") + if !strings.Contains(buf.String(), "test message") { + t.Fatalf("Expected message in buffer, got: %s", buf.String()) + } + }) +} + +func TestFieldLogger_WithField(t *testing.T) { + t.Run("adds single field", func(t *testing.T) { + buf := &bytes.Buffer{} + log := newTestLogger(t, logger.InfoLevel, buf) + + log.WithField("key", "value").Info("message") + if !strings.Contains(buf.String(), "key") { + t.Fatalf("Expected field in output, got: %s", buf.String()) + } + }) + + t.Run("returns new FieldLogger", func(t *testing.T) { + log := New(logger.InfoLevel) + newLog := log.WithField("key", "value") + + if log == newLog { + t.Fatal("WithField should return a new logger instance") + } + }) + + t.Run("chains multiple WithField calls", func(t *testing.T) { + buf := &bytes.Buffer{} + log := newTestLogger(t, logger.InfoLevel, buf) + + log.WithField("key1", "value1").WithField("key2", "value2").Info("message") + output := buf.String() + if !strings.Contains(output, "key1") || !strings.Contains(output, "key2") { + t.Fatalf("Expected both fields in output, got: %s", output) + } + }) +} + +func TestFieldLogger_WithFields(t *testing.T) { + t.Run("adds multiple fields", func(t *testing.T) { + buf := &bytes.Buffer{} + log := newTestLogger(t, logger.InfoLevel, buf) + + log.WithFields(map[string]any{ + "key1": "value1", + "key2": 42, + }).Info("message") + + output := buf.String() + if !strings.Contains(output, "key1") { + t.Fatalf("Expected key1 in output, got: %s", output) + } + if !strings.Contains(output, "key2") { + t.Fatalf("Expected key2 in output, got: %s", output) + } + }) + + t.Run("returns new FieldLogger", func(t *testing.T) { + log := New(logger.InfoLevel) + newLog := log.WithFields(map[string]any{"key": "value"}) + + if log == newLog { + t.Fatal("WithFields should return a new logger instance") + } + }) +} + +func TestFieldLogger_Debug(t *testing.T) { + t.Run("logs debug message at debug level", func(t *testing.T) { + buf := &bytes.Buffer{} + log := newTestLogger(t, logger.DebugLevel, buf) + + log.Debug("debug message") + if !strings.Contains(buf.String(), "debug message") { + t.Fatalf("Expected debug message, got: %s", buf.String()) + } + }) + + t.Run("Debug does not log at info level", func(t *testing.T) { + buf := &bytes.Buffer{} + log := newTestLogger(t, logger.InfoLevel, buf) + + log.Debug("debug message") + if strings.Contains(buf.String(), "debug message") { + t.Fatal("Debug should not log at InfoLevel") + } + }) + + t.Run("Debugf logs formatted debug message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := newTestLogger(t, logger.DebugLevel, buf) + + log.Debugf("debug %s %d", "test", 42) + if !strings.Contains(buf.String(), "debug test 42") { + t.Fatalf("Expected formatted debug message, got: %s", buf.String()) + } + }) +} + +func TestFieldLogger_Info(t *testing.T) { + t.Run("logs info message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := newTestLogger(t, logger.InfoLevel, buf) + + log.Info("info message") + if !strings.Contains(buf.String(), "info message") { + t.Fatalf("Expected info message, got: %s", buf.String()) + } + }) + + t.Run("Infof logs formatted message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := newTestLogger(t, logger.InfoLevel, buf) + + log.Infof("info %s", "formatted") + if !strings.Contains(buf.String(), "info formatted") { + t.Fatalf("Expected formatted info message, got: %s", buf.String()) + } + }) + + t.Run("Printf logs as info", func(t *testing.T) { + buf := &bytes.Buffer{} + log := newTestLogger(t, logger.InfoLevel, buf) + + log.Printf("print %s", "message") + if !strings.Contains(buf.String(), "print message") { + t.Fatalf("Expected print message, got: %s", buf.String()) + } + }) +} + +func TestFieldLogger_Warn(t *testing.T) { + t.Run("logs warn message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := newTestLogger(t, logger.WarnLevel, buf) + + log.Warn("warn message") + if !strings.Contains(buf.String(), "warn message") { + t.Fatalf("Expected warn message, got: %s", buf.String()) + } + }) + + t.Run("Warnf logs formatted warn message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := newTestLogger(t, logger.WarnLevel, buf) + + log.Warnf("warn %s", "formatted") + if !strings.Contains(buf.String(), "warn formatted") { + t.Fatalf("Expected formatted warn message, got: %s", buf.String()) + } + }) +} + +func TestFieldLogger_Error(t *testing.T) { + t.Run("logs error message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := newTestLogger(t, logger.ErrorLevel, buf) + + log.Error("error message") + if !strings.Contains(buf.String(), "error message") { + t.Fatalf("Expected error message, got: %s", buf.String()) + } + }) + + t.Run("Errorf logs formatted error message", func(t *testing.T) { + buf := &bytes.Buffer{} + log := newTestLogger(t, logger.ErrorLevel, buf) + + log.Errorf("error %s", "formatted") + if !strings.Contains(buf.String(), "error formatted") { + t.Fatalf("Expected formatted error message, got: %s", buf.String()) + } + }) +} + +func TestFieldLogger_LevelFiltering(t *testing.T) { + t.Run("debug does not log at info level", func(t *testing.T) { + buf := &bytes.Buffer{} + log := newTestLogger(t, logger.InfoLevel, buf) + + log.Debug("debug message") + if strings.Contains(buf.String(), "debug message") { + t.Fatal("Debug should not log at InfoLevel") + } + }) + + t.Run("info does not log at warn level", func(t *testing.T) { + buf := &bytes.Buffer{} + log := newTestLogger(t, logger.WarnLevel, buf) + + log.Info("info message") + if strings.Contains(buf.String(), "info message") { + t.Fatal("Info should not log at WarnLevel") + } + }) + + t.Run("warn does not log at error level", func(t *testing.T) { + buf := &bytes.Buffer{} + log := newTestLogger(t, logger.ErrorLevel, buf) + + log.Warn("warn message") + if strings.Contains(buf.String(), "warn message") { + t.Fatal("Warn should not log at ErrorLevel") + } + }) + + t.Run("higher levels log at lower levels", func(t *testing.T) { + buf := &bytes.Buffer{} + log := newTestLogger(t, logger.DebugLevel, buf) + + log.Error("error message") + if !strings.Contains(buf.String(), "error message") { + t.Fatalf("Error should log at DebugLevel, got: %s", buf.String()) + } + }) +}