Files
av1dae/internal/logger/logger.go
T
Esa Kataja f39f5b1b16 add: SQLite-backed structured logging with retention
Replace JSON/file logging with a logs.db (WAL) store. Thread the logger
through the encoder and metadata client for debug instrumentation of every
ffmpeg/ffprobe/opusenc invocation and OMDb/TVmaze request. API keys are
redacted before request URLs are logged. Retention defaults to 7 days,
overridable via log_retention_days in config.
2026-06-21 17:08:23 +03:00

117 lines
2.8 KiB
Go

package logger
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"time"
_ "github.com/mattn/go-sqlite3"
)
const (
LevelDebug = "debug"
LevelInfo = "info"
LevelError = "error"
)
type Logger struct {
db *sql.DB
dbPath string
retentionDays int
}
// New opens (or creates) logs.db inside logDir, applies the schema, and runs
// an initial retention purge. retentionDays <= 0 falls back to 7.
func New(logDir string, retentionDays int) (*Logger, error) {
if retentionDays <= 0 {
retentionDays = 7
}
if err := os.MkdirAll(logDir, 0755); err != nil {
return nil, fmt.Errorf("creating log directory: %w", err)
}
dbPath := filepath.Join(logDir, "logs.db")
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
return nil, fmt.Errorf("opening log db: %w", err)
}
schema := `
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
level TEXT NOT NULL,
message TEXT NOT NULL,
file TEXT,
extra TEXT
);
CREATE INDEX IF NOT EXISTS idx_logs_ts_level ON logs(ts, level);
`
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("applying log schema: %w", err)
}
l := &Logger{db: db, dbPath: dbPath, retentionDays: retentionDays}
l.purge()
return l, nil
}
func (l *Logger) purge() {
cutoff := fmt.Sprintf("-%d days", l.retentionDays)
_, _ = l.db.Exec(`DELETE FROM logs WHERE ts < datetime('now', ?)`, cutoff)
}
func (l *Logger) write(level, message, file, extra string) {
_, _ = l.db.Exec(
`INSERT INTO logs(level, message, file, extra) VALUES(?, ?, ?, ?)`,
level, message, nullIfEmpty(file), nullIfEmpty(extra),
)
}
func nullIfEmpty(s string) interface{} {
if s == "" {
return nil
}
return s
}
func stamp() string {
return time.Now().Format("2006-01-02 15:04:05")
}
func (l *Logger) Info(message string) {
fmt.Fprintf(os.Stdout, "[%s] INFO: %s\n", stamp(), message)
l.write(LevelInfo, message, "", "")
}
func (l *Logger) Error(message, errStr string) {
msg := fmt.Sprintf("%s: %s", message, errStr)
fmt.Fprintf(os.Stderr, "[%s] ERROR: %s\n", stamp(), msg)
l.write(LevelError, msg, "", "")
}
func (l *Logger) ErrorFile(file, message, errStr string) {
msg := fmt.Sprintf("%s: %s", message, errStr)
fmt.Fprintf(os.Stderr, "[%s] ERROR: %s (file: %s)\n", stamp(), msg, file)
l.write(LevelError, msg, file, "")
}
// Debug records a debug entry to the database only. extra is an opaque string,
// typically JSON, used for ffprobe output, API response bodies, or command
// stdout/stderr captures. Pass "" if not applicable.
func (l *Logger) Debug(message, file, extra string) {
l.write(LevelDebug, message, file, extra)
}
func (l *Logger) Close() {
if l.db == nil {
return
}
l.purge()
l.db.Close()
l.db = nil
}