forked from wrenn/wrenn
v0.1.0 (#17)
This commit is contained in:
135
pkg/logging/logging.go
Normal file
135
pkg/logging/logging.go
Normal file
@ -0,0 +1,135 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Setup configures the global slog logger with dual output (stderr + rotating
|
||||
// log file). logsDir is the directory where log files are written. binaryName
|
||||
// is used as the log filename (e.g. "control-plane" → "control-plane.log").
|
||||
//
|
||||
// If logsDir is empty or the directory cannot be created, Setup falls back to
|
||||
// stderr-only logging and returns a no-op cleanup function.
|
||||
//
|
||||
// The returned cleanup function closes the log file and must be deferred.
|
||||
// Setup also installs a SIGHUP handler that reopens the log file, allowing
|
||||
// external log rotation tools (e.g. logrotate) to rotate files in place.
|
||||
func Setup(logsDir, binaryName string) func() {
|
||||
level := parseLevel(os.Getenv("LOG_LEVEL"))
|
||||
|
||||
if logsDir == "" {
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: level,
|
||||
})))
|
||||
return func() {}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(logsDir, 0750); err != nil {
|
||||
// Fall back to stderr-only; log the error so operators notice.
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: level,
|
||||
})))
|
||||
slog.Warn("file logging unavailable: failed to create log directory", "dir", logsDir, "error", err)
|
||||
return func() {}
|
||||
}
|
||||
|
||||
logPath := filepath.Join(logsDir, binaryName+".log")
|
||||
rf, err := newReopenableFile(logPath)
|
||||
if err != nil {
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: level,
|
||||
})))
|
||||
slog.Warn("file logging unavailable: failed to open log file", "path", logPath, "error", err)
|
||||
return func() {}
|
||||
}
|
||||
|
||||
mw := io.MultiWriter(os.Stderr, rf)
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(mw, &slog.HandlerOptions{
|
||||
Level: level,
|
||||
})))
|
||||
|
||||
// SIGHUP reopens the log file so logrotate can rotate in place.
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGHUP)
|
||||
go func() {
|
||||
for range sigCh {
|
||||
if err := rf.Reopen(); err != nil {
|
||||
slog.Error("failed to reopen log file on SIGHUP", "path", logPath, "error", err)
|
||||
} else {
|
||||
slog.Info("log file reopened", "path", logPath)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
signal.Stop(sigCh)
|
||||
close(sigCh)
|
||||
rf.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func parseLevel(s string) slog.Level {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
// reopenableFile is an io.Writer backed by an *os.File that can be atomically
|
||||
// reopened (for log rotation via SIGHUP). All operations are goroutine-safe.
|
||||
type reopenableFile struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
f *os.File
|
||||
}
|
||||
|
||||
func newReopenableFile(path string) (*reopenableFile, error) {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0640)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &reopenableFile{path: path, f: f}, nil
|
||||
}
|
||||
|
||||
func (r *reopenableFile) Write(p []byte) (int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.f.Write(p)
|
||||
}
|
||||
|
||||
// Reopen closes the current file and opens a new one at the same path.
|
||||
// This is the mechanism that makes logrotate's copytruncate-free rotation work:
|
||||
// logrotate renames the old file, then sends SIGHUP, and the process opens a
|
||||
// fresh file at the original path.
|
||||
func (r *reopenableFile) Reopen() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
// Open the new file before closing the old one so a failed open doesn't
|
||||
// leave the writer in a broken state with a closed fd.
|
||||
f, err := os.OpenFile(r.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0640)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.f.Close()
|
||||
r.f = f
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *reopenableFile) Close() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.f.Close()
|
||||
}
|
||||
Reference in New Issue
Block a user