// Package logger provides p4lf's own rotating log writer. // // Logs are written to a fixed path ($LOGS/p4lf.log). When the file exceeds // MaxLogSize, it is rotated: the current log is renamed to // p4lf..log and then gzipped. Log rotation is also triggered on // SIGHUP (handled in main). package logger import ( "compress/gzip" "fmt" "io" "os" "sync" "time" ) // Logger writes structured log lines to a rotating file. type Logger struct { mu sync.Mutex path string file *os.File maxSize int64 debug int size int64 } // New opens (or creates) the log file at path. func New(path string, maxSize int64, debug int) (*Logger, error) { f, sz, err := openLog(path) if err != nil { return nil, err } return &Logger{ path: path, file: f, maxSize: maxSize, debug: debug, size: sz, }, nil } func openLog(path string) (*os.File, int64, error) { f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { return nil, 0, fmt.Errorf("opening log %q: %w", path, err) } info, err := f.Stat() if err != nil { f.Close() return nil, 0, err } return f, info.Size(), nil } func (l *Logger) write(level, format string, args ...interface{}) { msg := fmt.Sprintf(format, args...) line := fmt.Sprintf("%s [%s] %s\n", time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), level, msg) l.mu.Lock() defer l.mu.Unlock() n, _ := fmt.Fprint(l.file, line) l.size += int64(n) if l.maxSize > 0 && l.size >= l.maxSize { l.rotate() } } // Infof logs at INFO level. func (l *Logger) Infof(format string, args ...interface{}) { l.write("INFO", format, args...) } // Warnf logs at WARN level. func (l *Logger) Warnf(format string, args ...interface{}) { l.write("WARN", format, args...) } // Errorf logs at ERROR level. func (l *Logger) Errorf(format string, args ...interface{}) { l.write("ERROR", format, args...) } // Debugf logs at DEBUG level (only when debug >= 1). func (l *Logger) Debugf(format string, args ...interface{}) { if l.debug >= 1 { l.write("DEBUG", format, args...) } } // Debug2f logs at DEBUG level (only when debug >= 2). func (l *Logger) Debug2f(format string, args ...interface{}) { if l.debug >= 2 { l.write("DEBUG2", format, args...) } } // Rotate forces an immediate log rotation. Called on SIGHUP or when MaxLogSize // is reached. Must NOT be called with l.mu held externally. func (l *Logger) Rotate() error { l.mu.Lock() defer l.mu.Unlock() return l.rotate() } // rotate performs the rotation. Must be called with l.mu held. func (l *Logger) rotate() error { if l.file == nil { return nil } l.file.Close() l.file = nil ts := time.Now().UTC().Format("20060102T150405Z") rotatedName := l.path[:len(l.path)-len(".log")] + "." + ts + ".log" if err := os.Rename(l.path, rotatedName); err != nil { // Best effort; reopen original path anyway. _ = l.reopen() return fmt.Errorf("rotating log: %w", err) } // Gzip the rotated log in the background; don't block the service. go func() { if err := gzipFile(rotatedName); err != nil { // Can't log to our logger here (might cause deadlock); use stderr. fmt.Fprintf(os.Stderr, "p4lf: gzip rotated log %q: %v\n", rotatedName, err) } }() return l.reopen() } func (l *Logger) reopen() error { f, sz, err := openLog(l.path) if err != nil { return err } l.file = f l.size = sz return nil } // Close flushes and closes the log file. func (l *Logger) Close() { l.mu.Lock() defer l.mu.Unlock() if l.file != nil { l.file.Close() l.file = nil } } // gzipFile compresses src to src+".gz" then removes the original. func gzipFile(src string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(src + ".gz") if err != nil { return err } gz := gzip.NewWriter(out) if _, err := io.Copy(gz, in); err != nil { gz.Close() out.Close() return err } if err := gz.Close(); err != nil { out.Close() return err } if err := out.Close(); err != nil { return err } return os.Remove(src) }