From ddcf39b7ee9e5fea9b8dad83943c8d5acf3291a6 Mon Sep 17 00:00:00 2001 From: snider Date: Wed, 31 Dec 2025 11:18:22 +0000 Subject: [PATCH] feat: Add structured logging package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create pkg/logging with: - Log levels: Debug, Info, Warn, Error - Structured fields support (key-value pairs) - Component-based logging (WithComponent) - Global logger convenience functions - ParseLevel for configuration - Full test coverage The package provides a migration path from log.Printf to structured logging without external dependencies. Example usage: logging.Info("miner started", logging.Fields{"name": minerName}) logger := logging.New(cfg).WithComponent("Manager") logger.Warn("connection lost", logging.Fields{"pool": pool}) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- pkg/logging/logger.go | 284 +++++++++++++++++++++++++++++++++++++ pkg/logging/logger_test.go | 262 ++++++++++++++++++++++++++++++++++ 2 files changed, 546 insertions(+) create mode 100644 pkg/logging/logger.go create mode 100644 pkg/logging/logger_test.go diff --git a/pkg/logging/logger.go b/pkg/logging/logger.go new file mode 100644 index 0000000..f400dc9 --- /dev/null +++ b/pkg/logging/logger.go @@ -0,0 +1,284 @@ +// Package logging provides structured logging with log levels and fields. +package logging + +import ( + "fmt" + "io" + "os" + "strings" + "sync" + "time" +) + +// Level represents the severity of a log message. +type Level int + +const ( + // LevelDebug is the most verbose log level. + LevelDebug Level = iota + // LevelInfo is for general informational messages. + LevelInfo + // LevelWarn is for warning messages. + LevelWarn + // LevelError is for error messages. + LevelError +) + +// String returns the string representation of the log level. +func (l Level) String() string { + switch l { + case LevelDebug: + return "DEBUG" + case LevelInfo: + return "INFO" + case LevelWarn: + return "WARN" + case LevelError: + return "ERROR" + default: + return "UNKNOWN" + } +} + +// Logger provides structured logging with configurable output and level. +type Logger struct { + mu sync.Mutex + output io.Writer + level Level + component string +} + +// Config holds configuration for creating a new Logger. +type Config struct { + Output io.Writer + Level Level + Component string +} + +// DefaultConfig returns the default logger configuration. +func DefaultConfig() Config { + return Config{ + Output: os.Stderr, + Level: LevelInfo, + Component: "", + } +} + +// New creates a new Logger with the given configuration. +func New(cfg Config) *Logger { + if cfg.Output == nil { + cfg.Output = os.Stderr + } + return &Logger{ + output: cfg.Output, + level: cfg.Level, + component: cfg.Component, + } +} + +// WithComponent returns a new Logger with the specified component name. +func (l *Logger) WithComponent(component string) *Logger { + return &Logger{ + output: l.output, + level: l.level, + component: component, + } +} + +// SetLevel sets the minimum log level. +func (l *Logger) SetLevel(level Level) { + l.mu.Lock() + defer l.mu.Unlock() + l.level = level +} + +// GetLevel returns the current log level. +func (l *Logger) GetLevel() Level { + l.mu.Lock() + defer l.mu.Unlock() + return l.level +} + +// Fields represents key-value pairs for structured logging. +type Fields map[string]interface{} + +// log writes a log message at the specified level. +func (l *Logger) log(level Level, msg string, fields Fields) { + l.mu.Lock() + defer l.mu.Unlock() + + if level < l.level { + return + } + + // Build the log line + var sb strings.Builder + timestamp := time.Now().Format("2006/01/02 15:04:05") + sb.WriteString(timestamp) + sb.WriteString(" [") + sb.WriteString(level.String()) + sb.WriteString("]") + + if l.component != "" { + sb.WriteString(" [") + sb.WriteString(l.component) + sb.WriteString("]") + } + + sb.WriteString(" ") + sb.WriteString(msg) + + // Add fields if present + if len(fields) > 0 { + sb.WriteString(" |") + for k, v := range fields { + sb.WriteString(" ") + sb.WriteString(k) + sb.WriteString("=") + sb.WriteString(fmt.Sprintf("%v", v)) + } + } + + sb.WriteString("\n") + fmt.Fprint(l.output, sb.String()) +} + +// Debug logs a debug message. +func (l *Logger) Debug(msg string, fields ...Fields) { + l.log(LevelDebug, msg, mergeFields(fields)) +} + +// Info logs an informational message. +func (l *Logger) Info(msg string, fields ...Fields) { + l.log(LevelInfo, msg, mergeFields(fields)) +} + +// Warn logs a warning message. +func (l *Logger) Warn(msg string, fields ...Fields) { + l.log(LevelWarn, msg, mergeFields(fields)) +} + +// Error logs an error message. +func (l *Logger) Error(msg string, fields ...Fields) { + l.log(LevelError, msg, mergeFields(fields)) +} + +// Debugf logs a formatted debug message. +func (l *Logger) Debugf(format string, args ...interface{}) { + l.log(LevelDebug, fmt.Sprintf(format, args...), nil) +} + +// Infof logs a formatted informational message. +func (l *Logger) Infof(format string, args ...interface{}) { + l.log(LevelInfo, fmt.Sprintf(format, args...), nil) +} + +// Warnf logs a formatted warning message. +func (l *Logger) Warnf(format string, args ...interface{}) { + l.log(LevelWarn, fmt.Sprintf(format, args...), nil) +} + +// Errorf logs a formatted error message. +func (l *Logger) Errorf(format string, args ...interface{}) { + l.log(LevelError, fmt.Sprintf(format, args...), nil) +} + +// mergeFields combines multiple Fields maps into one. +func mergeFields(fields []Fields) Fields { + if len(fields) == 0 { + return nil + } + result := make(Fields) + for _, f := range fields { + for k, v := range f { + result[k] = v + } + } + return result +} + +// --- Global logger for convenience --- + +var ( + globalLogger = New(DefaultConfig()) + globalMu sync.RWMutex +) + +// SetGlobal sets the global logger instance. +func SetGlobal(l *Logger) { + globalMu.Lock() + defer globalMu.Unlock() + globalLogger = l +} + +// GetGlobal returns the global logger instance. +func GetGlobal() *Logger { + globalMu.RLock() + defer globalMu.RUnlock() + return globalLogger +} + +// SetGlobalLevel sets the log level of the global logger. +func SetGlobalLevel(level Level) { + globalMu.RLock() + defer globalMu.RUnlock() + globalLogger.SetLevel(level) +} + +// Global convenience functions that use the global logger + +// Debug logs a debug message using the global logger. +func Debug(msg string, fields ...Fields) { + GetGlobal().Debug(msg, fields...) +} + +// Info logs an informational message using the global logger. +func Info(msg string, fields ...Fields) { + GetGlobal().Info(msg, fields...) +} + +// Warn logs a warning message using the global logger. +func Warn(msg string, fields ...Fields) { + GetGlobal().Warn(msg, fields...) +} + +// Error logs an error message using the global logger. +func Error(msg string, fields ...Fields) { + GetGlobal().Error(msg, fields...) +} + +// Debugf logs a formatted debug message using the global logger. +func Debugf(format string, args ...interface{}) { + GetGlobal().Debugf(format, args...) +} + +// Infof logs a formatted informational message using the global logger. +func Infof(format string, args ...interface{}) { + GetGlobal().Infof(format, args...) +} + +// Warnf logs a formatted warning message using the global logger. +func Warnf(format string, args ...interface{}) { + GetGlobal().Warnf(format, args...) +} + +// Errorf logs a formatted error message using the global logger. +func Errorf(format string, args ...interface{}) { + GetGlobal().Errorf(format, args...) +} + +// ParseLevel parses a string into a log level. +func ParseLevel(s string) (Level, error) { + switch strings.ToUpper(s) { + case "DEBUG": + return LevelDebug, nil + case "INFO": + return LevelInfo, nil + case "WARN", "WARNING": + return LevelWarn, nil + case "ERROR": + return LevelError, nil + default: + return LevelInfo, fmt.Errorf("unknown log level: %s", s) + } +} diff --git a/pkg/logging/logger_test.go b/pkg/logging/logger_test.go new file mode 100644 index 0000000..5fa5163 --- /dev/null +++ b/pkg/logging/logger_test.go @@ -0,0 +1,262 @@ +package logging + +import ( + "bytes" + "strings" + "testing" +) + +func TestLoggerLevels(t *testing.T) { + var buf bytes.Buffer + logger := New(Config{ + Output: &buf, + Level: LevelInfo, + }) + + // Debug should not appear at Info level + logger.Debug("debug message") + if buf.Len() > 0 { + t.Error("Debug message should not appear at Info level") + } + + // Info should appear + logger.Info("info message") + if !strings.Contains(buf.String(), "[INFO]") { + t.Error("Info message should appear") + } + if !strings.Contains(buf.String(), "info message") { + t.Error("Info message content should appear") + } + buf.Reset() + + // Warn should appear + logger.Warn("warn message") + if !strings.Contains(buf.String(), "[WARN]") { + t.Error("Warn message should appear") + } + buf.Reset() + + // Error should appear + logger.Error("error message") + if !strings.Contains(buf.String(), "[ERROR]") { + t.Error("Error message should appear") + } +} + +func TestLoggerDebugLevel(t *testing.T) { + var buf bytes.Buffer + logger := New(Config{ + Output: &buf, + Level: LevelDebug, + }) + + logger.Debug("debug message") + if !strings.Contains(buf.String(), "[DEBUG]") { + t.Error("Debug message should appear at Debug level") + } +} + +func TestLoggerWithFields(t *testing.T) { + var buf bytes.Buffer + logger := New(Config{ + Output: &buf, + Level: LevelInfo, + }) + + logger.Info("test message", Fields{"key": "value", "num": 42}) + output := buf.String() + + if !strings.Contains(output, "key=value") { + t.Error("Field key=value should appear") + } + if !strings.Contains(output, "num=42") { + t.Error("Field num=42 should appear") + } +} + +func TestLoggerWithComponent(t *testing.T) { + var buf bytes.Buffer + logger := New(Config{ + Output: &buf, + Level: LevelInfo, + Component: "TestComponent", + }) + + logger.Info("test message") + output := buf.String() + + if !strings.Contains(output, "[TestComponent]") { + t.Error("Component name should appear in log") + } +} + +func TestLoggerDerivedComponent(t *testing.T) { + var buf bytes.Buffer + parent := New(Config{ + Output: &buf, + Level: LevelInfo, + }) + + child := parent.WithComponent("ChildComponent") + child.Info("child message") + output := buf.String() + + if !strings.Contains(output, "[ChildComponent]") { + t.Error("Derived component name should appear") + } +} + +func TestLoggerFormatted(t *testing.T) { + var buf bytes.Buffer + logger := New(Config{ + Output: &buf, + Level: LevelInfo, + }) + + logger.Infof("formatted %s %d", "string", 123) + output := buf.String() + + if !strings.Contains(output, "formatted string 123") { + t.Errorf("Formatted message should appear, got: %s", output) + } +} + +func TestSetLevel(t *testing.T) { + var buf bytes.Buffer + logger := New(Config{ + Output: &buf, + Level: LevelError, + }) + + // Info should not appear at Error level + logger.Info("should not appear") + if buf.Len() > 0 { + t.Error("Info should not appear at Error level") + } + + // Change to Info level + logger.SetLevel(LevelInfo) + logger.Info("should appear now") + if !strings.Contains(buf.String(), "should appear now") { + t.Error("Info should appear after level change") + } + + // Verify GetLevel + if logger.GetLevel() != LevelInfo { + t.Error("GetLevel should return LevelInfo") + } +} + +func TestParseLevel(t *testing.T) { + tests := []struct { + input string + expected Level + wantErr bool + }{ + {"DEBUG", LevelDebug, false}, + {"debug", LevelDebug, false}, + {"INFO", LevelInfo, false}, + {"info", LevelInfo, false}, + {"WARN", LevelWarn, false}, + {"WARNING", LevelWarn, false}, + {"ERROR", LevelError, false}, + {"error", LevelError, false}, + {"invalid", LevelInfo, true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + level, err := ParseLevel(tt.input) + if tt.wantErr && err == nil { + t.Error("Expected error but got none") + } + if !tt.wantErr && err != nil { + t.Errorf("Unexpected error: %v", err) + } + if !tt.wantErr && level != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, level) + } + }) + } +} + +func TestGlobalLogger(t *testing.T) { + var buf bytes.Buffer + logger := New(Config{ + Output: &buf, + Level: LevelInfo, + }) + + SetGlobal(logger) + + Info("global test") + if !strings.Contains(buf.String(), "global test") { + t.Error("Global logger should write message") + } + + buf.Reset() + SetGlobalLevel(LevelError) + Info("should not appear") + if buf.Len() > 0 { + t.Error("Info should not appear at Error level") + } + + // Reset to default for other tests + SetGlobal(New(DefaultConfig())) +} + +func TestLevelString(t *testing.T) { + tests := []struct { + level Level + expected string + }{ + {LevelDebug, "DEBUG"}, + {LevelInfo, "INFO"}, + {LevelWarn, "WARN"}, + {LevelError, "ERROR"}, + {Level(99), "UNKNOWN"}, + } + + for _, tt := range tests { + if got := tt.level.String(); got != tt.expected { + t.Errorf("Level(%d).String() = %s, want %s", tt.level, got, tt.expected) + } + } +} + +func TestMergeFields(t *testing.T) { + // Empty fields + result := mergeFields(nil) + if result != nil { + t.Error("nil input should return nil") + } + + result = mergeFields([]Fields{}) + if result != nil { + t.Error("empty input should return nil") + } + + // Single fields + result = mergeFields([]Fields{{"key": "value"}}) + if result["key"] != "value" { + t.Error("Single field should be preserved") + } + + // Multiple fields + result = mergeFields([]Fields{ + {"key1": "value1"}, + {"key2": "value2"}, + }) + if result["key1"] != "value1" || result["key2"] != "value2" { + t.Error("Multiple fields should be merged") + } + + // Override + result = mergeFields([]Fields{ + {"key": "value1"}, + {"key": "value2"}, + }) + if result["key"] != "value2" { + t.Error("Later fields should override earlier ones") + } +}