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.
This commit is contained in:
+80
-58
@@ -1,94 +1,116 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"videnc-vibe/pkg/types"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const (
|
||||
LevelDebug = "debug"
|
||||
LevelInfo = "info"
|
||||
LevelError = "error"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
infoPath string
|
||||
errorPath string
|
||||
structPath string
|
||||
infoFile *os.File
|
||||
errorFile *os.File
|
||||
structFile *os.File
|
||||
db *sql.DB
|
||||
dbPath string
|
||||
retentionDays int
|
||||
}
|
||||
|
||||
func New(logDir string) (*Logger, error) {
|
||||
// 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)
|
||||
}
|
||||
|
||||
now := time.Now().Format("2006-01-02")
|
||||
infoPath := filepath.Join(logDir, fmt.Sprintf("info_%s.log", now))
|
||||
errorPath := filepath.Join(logDir, fmt.Sprintf("error_%s.log", now))
|
||||
structPath := filepath.Join(logDir, "structured.json")
|
||||
|
||||
infoFile, err := os.OpenFile(infoPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
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 info log: %w", err)
|
||||
return nil, fmt.Errorf("opening log db: %w", err)
|
||||
}
|
||||
|
||||
errorFile, err := os.OpenFile(errorPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening error log: %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)
|
||||
}
|
||||
|
||||
structFile, err := os.OpenFile(structPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening structured log: %w", err)
|
||||
}
|
||||
l := &Logger{db: db, dbPath: dbPath, retentionDays: retentionDays}
|
||||
l.purge()
|
||||
return l, nil
|
||||
}
|
||||
|
||||
return &Logger{
|
||||
infoPath: infoPath,
|
||||
errorPath: errorPath,
|
||||
structPath: structPath,
|
||||
infoFile: infoFile,
|
||||
errorFile: errorFile,
|
||||
structFile: structFile,
|
||||
}, 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) {
|
||||
timestamp := time.Now().Format("2006-01-02 15:04:05")
|
||||
entry := fmt.Sprintf("[%s] INFO: %s\n", timestamp, message)
|
||||
l.infoFile.WriteString(entry)
|
||||
l.writeStructured("info", message, "", "")
|
||||
fmt.Fprintf(os.Stdout, "[%s] INFO: %s\n", stamp(), message)
|
||||
l.write(LevelInfo, message, "", "")
|
||||
}
|
||||
|
||||
func (l *Logger) Error(message, err string) {
|
||||
timestamp := time.Now().Format("2006-01-02 15:04:05")
|
||||
entry := fmt.Sprintf("[%s] ERROR: %s - %s\n", timestamp, message, err)
|
||||
l.errorFile.WriteString(entry)
|
||||
l.writeStructured("error", message, err, "")
|
||||
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 string, message, err string) {
|
||||
timestamp := time.Now().Format("2006-01-02 15:04:05")
|
||||
entry := fmt.Sprintf("[%s] ERROR: %s - %s (file: %s)\n", timestamp, message, err, file)
|
||||
l.errorFile.WriteString(entry)
|
||||
l.writeStructured("error", message, err, file)
|
||||
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, "")
|
||||
}
|
||||
|
||||
func (l *Logger) writeStructured(level, message, err, file string) {
|
||||
entry := types.LogEntry{
|
||||
Timestamp: time.Now(),
|
||||
Level: level,
|
||||
Message: message,
|
||||
Error: err,
|
||||
File: file,
|
||||
}
|
||||
data, _ := json.Marshal(entry)
|
||||
l.structFile.WriteString(string(data) + "\n")
|
||||
// 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() {
|
||||
l.infoFile.Close()
|
||||
l.errorFile.Close()
|
||||
l.structFile.Close()
|
||||
if l.db == nil {
|
||||
return
|
||||
}
|
||||
l.purge()
|
||||
l.db.Close()
|
||||
l.db = nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user