114ba24e05
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>
193 lines
5.3 KiB
Go
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)
|
|
}
|
|
}
|