// 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.<timestamp>.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)
}
| # | Change | User | Description | Committed | |
|---|---|---|---|---|---|
| #1 | 32818 | C. Thomas Tyler |
Initial implementation of p4lf in Go. Adds: - Go module (github.com/rcowham/p4lf) with fsnotify dependency - internal/config: KEY=VALUE config parser with all settings - internal/tailer: file reader with inode-based rotation detection and state file checkpoint/resume (inode + byte offset, JSON, atomic write) - internal/chunker: gzip chunk writer with MaxLogChunks/MinLogSpace guards - internal/logger: rotating log writer with gzip of rotated files - cmd/p4lf/main.go: service main loop, SIGHUP/SIGTERM/SIGINT handling, config hot-reload on modtime change - Makefile: build/test/install/release targets for Linux amd64/arm64, macOS arm64/amd64; version injected via ldflags - p4lf.cfg.example: fully documented example config - p4lf.service: systemd unit file (User=perforce, Restart=on-failure) - ai/session_log_2026-06-25.md: design session log - ai/session_log_2026-06-25-2.md: implementation session log - .p4ignore: added bin/ and dist/ |