Files
Alphaeus Mote 114ba24e05 refactor(logging): add a centralized process-wide logger
Introduce logging.Init/Default plus package-level Info/Warn/Error/Debug so
code without an injected *Logger (main, the service wrapper) emits the same
[UTC] - [Component] - [Level] - Message format on the one shared writer and
rotation policy. Existing injected usage is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 08:49:14 -04:00

193 lines
5.3 KiB
Go

// Package logging provides centralized structured logging with file rotation
// Log format: [TimestampUTC] - [Component] - [Level] - Message
// Error format: [TimestampUTC] - [Component] - [Level] - [File:Line] - Message
package logging
import (
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/Grace-Solutions/OrchestrAD/internal/config"
"gopkg.in/natefinch/lumberjack.v2"
)
// Logger wraps slog with custom formatting
type Logger struct {
slog *slog.Logger
level slog.Level
writer io.Writer
}
// std is the process-wide centralized logger. Every entry point initializes it
// once via Init and shares the returned instance through dependency injection;
// code that cannot easily receive an injected logger (main, the service
// wrapper) uses the package-level Default/Info/Warn/Error/Debug helpers, which
// resolve to this same instance. This keeps all output on one writer, one
// rotation policy, and the single [UTC] - [Component] - [Level] - Message format.
var (
stdMu sync.RWMutex
std *Logger
)
// Init creates the centralized logger from cfg, stores it as the process-wide
// default, and returns it so callers can also inject it explicitly. The last
// Init wins; typically it is called exactly once per process at startup.
func Init(cfg config.LoggingConfig) *Logger {
l := New(cfg)
stdMu.Lock()
std = l
stdMu.Unlock()
return l
}
// Default returns the centralized logger. If Init has not run yet (e.g. an
// early top-level error before config is loaded), it lazily creates a
// console-only logger so logging never panics and the format stays consistent.
func Default() *Logger {
stdMu.RLock()
l := std
stdMu.RUnlock()
if l != nil {
return l
}
stdMu.Lock()
defer stdMu.Unlock()
if std == nil {
std = New(config.LoggingConfig{Level: "info", EnableConsole: true})
}
return std
}
// Package-level helpers delegate to the centralized logger so callers without
// an injected Logger still emit the standard format.
func Debug(component, msg string, args ...any) { Default().Debug(component, msg, args...) }
func Info(component, msg string, args ...any) { Default().Info(component, msg, args...) }
func Warn(component, msg string, args ...any) { Default().Warn(component, msg, args...) }
func Error(component, msg string, args ...any) { Default().Error(component, msg, args...) }
// New creates a new Logger with the given configuration
func New(cfg config.LoggingConfig) *Logger {
level := parseLevel(cfg.Level)
// Ensure log directory exists
if cfg.FilePath != "" {
if err := os.MkdirAll(filepath.Dir(cfg.FilePath), 0755); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not create log directory: %v\n", err)
}
}
var writers []io.Writer
// File writer with rotation
if cfg.FilePath != "" {
fileWriter := &lumberjack.Logger{
Filename: cfg.FilePath,
MaxSize: cfg.MaxSizeMB,
MaxBackups: cfg.MaxBackups,
MaxAge: cfg.MaxAgeDays,
Compress: cfg.Compress,
}
writers = append(writers, fileWriter)
}
// Console writer
if cfg.EnableConsole {
writers = append(writers, os.Stdout)
}
var writer io.Writer
if len(writers) > 1 {
writer = io.MultiWriter(writers...)
} else if len(writers) == 1 {
writer = writers[0]
} else {
writer = os.Stdout
}
handler := slog.NewTextHandler(writer, &slog.HandlerOptions{
Level: level,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
a.Value = slog.StringValue(time.Now().UTC().Format("2006-01-02T15:04:05.000Z"))
}
return a
},
})
return &Logger{
slog: slog.New(handler),
level: level,
writer: writer,
}
}
func parseLevel(level string) slog.Level {
switch strings.ToLower(level) {
case "debug":
return slog.LevelDebug
case "info":
return slog.LevelInfo
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
func (l *Logger) log(level slog.Level, component, msg string, includeSource bool) {
timestamp := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
levelStr := strings.ToUpper(level.String())
var logLine string
if includeSource {
_, file, line, ok := runtime.Caller(2)
if ok {
file = filepath.Base(file)
logLine = fmt.Sprintf("[%s] - [%s] - [%s] - [%s:%d] - %s", timestamp, component, levelStr, file, line, msg)
} else {
logLine = fmt.Sprintf("[%s] - [%s] - [%s] - %s", timestamp, component, levelStr, msg)
}
} else {
logLine = fmt.Sprintf("[%s] - [%s] - [%s] - %s", timestamp, component, levelStr, msg)
}
fmt.Fprintln(l.writer, logLine)
}
// Debug logs a debug message
func (l *Logger) Debug(component, msg string, args ...any) {
if l.level <= slog.LevelDebug {
l.log(slog.LevelDebug, component, fmt.Sprintf(msg, args...), false)
}
}
// Info logs an info message
func (l *Logger) Info(component, msg string, args ...any) {
if l.level <= slog.LevelInfo {
l.log(slog.LevelInfo, component, fmt.Sprintf(msg, args...), false)
}
}
// Warn logs a warning message
func (l *Logger) Warn(component, msg string, args ...any) {
if l.level <= slog.LevelWarn {
l.log(slog.LevelWarn, component, fmt.Sprintf(msg, args...), false)
}
}
// Error logs an error message with source location
func (l *Logger) Error(component, msg string, args ...any) {
if l.level <= slog.LevelError {
l.log(slog.LevelError, component, fmt.Sprintf(msg, args...), true)
}
}