Files
radiantwave-legacy/internal/logger/logger.go
T

232 lines
6.3 KiB
Go

package logger
import (
"fmt"
"log"
"os"
"time"
"gorm.io/gorm"
"rhemawavetech.com/rhemawave/internal/db"
)
// ─── Level ───────────────────────────────────────────────────────────────────
type Level int
const (
LevelDebug Level = iota
LevelInfo
LevelWarning
LevelError
LevelFatal
)
func (l Level) String() string {
return [...]string{"DEBUG", "INFO", "WARNING", "ERROR", "FATAL"}[l]
}
// ─── Internal entry type ──────────────────────────────────────────────────────
type entry struct {
level Level
message string
timestamp time.Time
fatal bool
}
// ─── Package state ────────────────────────────────────────────────────────────
var (
SetLevel Level = LevelInfo
console = log.New(os.Stderr, "", 0)
ch chan entry // unbuffered send is a bug; always buffered
done chan struct{}
db_ *gorm.DB
file *os.File
)
func init() {
ch = make(chan entry, 4096)
done = make(chan struct{})
go drain()
}
// ─── Configuration ────────────────────────────────────────────────────────────
// AddFile attaches or replaces the file backend. Safe to call at any time.
func AddFile(path string) error {
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("logger: open file: %w", err)
}
// Send a sentinel that carries the new file handle.
// We do this through the channel so the drain goroutine owns
// all file state — no mutex needed.
fileSwapCh <- f
return nil
}
// AddDatabase attaches or replaces the database backend. Safe to call at any time.
func AddDatabase(d *gorm.DB) {
dbSwapCh <- d
}
// Close drains remaining entries and shuts down the logger.
// Call this with defer in main().
func Close() {
close(done)
<-drainDone // wait for drain goroutine to finish flushing
if file != nil {
file.Close()
file = nil
}
}
// ─── Swap channels (owned exclusively by drain goroutine) ────────────────────
var (
fileSwapCh = make(chan *os.File, 1)
dbSwapCh = make(chan *gorm.DB, 1)
drainDone = make(chan struct{})
)
// ─── Drain goroutine ──────────────────────────────────────────────────────────
//
// This is the ONLY goroutine that touches file, db_, or does DB writes.
// Everything else just sends to ch.
func drain() {
defer close(drainDone)
batch := make([]entry, 0, 64)
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
flush := func() {
if len(batch) == 0 {
return
}
writeBatchToDB(batch)
batch = batch[:0]
}
for {
select {
// New log entry
case e := <-ch:
writeToFile(e)
if db_ != nil {
batch = append(batch, e)
if len(batch) >= 64 {
flush()
}
}
// Periodic DB flush so low-volume logs don't sit in the batch forever
case <-ticker.C:
flush()
// Hot-swap the file backend
case f := <-fileSwapCh:
if file != nil {
file.Close()
}
file = f
// Hot-swap the DB backend
case d := <-dbSwapCh:
flush() // flush any pending entries to the old db first
db_ = d
console.Println("logger: database backend attached")
// Shutdown: drain what's left, then exit
case <-done:
// Pull any entries still sitting in the channel
for len(ch) > 0 {
e := <-ch
writeToFile(e)
batch = append(batch, e)
}
flush()
return
}
}
}
// ─── Writers (called only from drain goroutine) ───────────────────────────────
func writeToFile(e entry) {
line := formatLine(e)
if file != nil {
fmt.Fprintln(file, line)
}
}
func writeBatchToDB(batch []entry) {
if db_ == nil || len(batch) == 0 {
return
}
records := make([]db.Log, len(batch))
for i, e := range batch {
records[i] = db.Log{
Timestamp: e.timestamp.UnixMilli(),
Level: e.level.String(),
Message: e.message,
}
}
// Single INSERT ... VALUES (...), (...), (...) instead of N individual saves.
if result := db_.CreateInBatches(records, 64); result.Error != nil {
console.Printf("logger: db batch write failed: %v\n", result.Error)
}
}
func formatLine(e entry) string {
return fmt.Sprintf("%s [%s] %s",
e.timestamp.Format("2006-01-02 15:04:05.000"),
e.level,
e.message,
)
}
// ─── Public API ───────────────────────────────────────────────────────────────
func logMsg(level Level, fatal bool, format string, args ...any) {
if level == LevelDebug && SetLevel != LevelDebug {
return
}
e := entry{
level: level,
message: fmt.Sprintf(format, args...),
timestamp: time.Now(), // captured here, in the caller's goroutine
fatal: fatal,
}
// Console is synchronous — log.Logger is internally mutex-protected,
// so this is safe from any goroutine and gives you immediate output.
console.Println(formatLine(e))
// Non-blocking send: if the buffer is full, we drop rather than deadlock.
// 4096 entries of headroom means this should never happen in practice.
select {
case ch <- e:
default:
console.Println("logger: channel full, entry dropped")
}
if fatal {
// Give the drain goroutine a moment to flush before we exit.
// Close() is cleaner but Fatal has to exit immediately.
time.Sleep(150 * time.Millisecond)
os.Exit(1)
}
}
func Debug(format string, args ...any) { logMsg(LevelDebug, false, format, args...) }
func Info(format string, args ...any) { logMsg(LevelInfo, false, format, args...) }
func Warning(format string, args ...any) { logMsg(LevelWarning, false, format, args...) }
func Error(format string, args ...any) { logMsg(LevelError, false, format, args...) }
func Fatal(format string, args ...any) { logMsg(LevelFatal, true, format, args...) }