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:
parent
91e7d0f484
commit
ddcf39b7ee
2 changed files with 546 additions and 0 deletions
284
pkg/logging/logger.go
Normal file
284
pkg/logging/logger.go
Normal 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
262
pkg/logging/logger_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue