feat: Add structured logging package

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 <noreply@anthropic.com>
This commit is contained in:
snider 2025-12-31 11:18:22 +00:00
parent 91e7d0f484
commit ddcf39b7ee
2 changed files with 546 additions and 0 deletions

284
pkg/logging/logger.go Normal file
View file

@ -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)
}
}

262
pkg/logging/logger_test.go Normal file
View file

@ -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")
}
}