package logger import ( "fmt" "log" "os" "time" "gorm.io/gorm" "radiantwavetech.com/radiantwave/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...) }