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