feat(diary): component loggers through a redacting console and daily stack
- New diary module: For, Bootstrap, Setup, SetVerbosity, Reset, Close, ParseConfig - Proxy component handler safe in package vars; styled console handler in bonfire's palette - Daily writer: one file per day, 0640, lazy open, retention, disables itself on open failure - Every channel leaf wrapped by sunscreen.Wrap; stack via slog.NewMultiHandler - bonfire: ColorMode, Palette, DefaultPalette, DetectProfile (lipgloss v2, colorprofile) - Generated main runs diary.Bootstrap first and diary.Setup after compass.Load - Ignore /storage/ and /examples/hello/storage/
This commit is contained in:
152
modules/diary/daily.go
Normal file
152
modules/diary/daily.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package diary
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dateLayout = "2006-01-02"
|
||||
|
||||
// dailyWriter writes to one file per local date: path storage/logs/summer.log
|
||||
// gives storage/logs/summer-2026-10-21.log. Files open lazily on the first
|
||||
// write (no empty files), are created 0o640 in directories created 0o750,
|
||||
// and on every open the files of the same base older than the retention are
|
||||
// deleted. If a file cannot be opened the writer reports once through warn
|
||||
// and is disabled: later writes succeed without writing anything.
|
||||
type dailyWriter struct {
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
base string
|
||||
ext string
|
||||
days int
|
||||
now func() time.Time
|
||||
warn func(path string, err error)
|
||||
file *os.File
|
||||
date string
|
||||
disabled bool
|
||||
}
|
||||
|
||||
func newDailyWriter(path string, days int, now func() time.Time, warn func(string, error)) *dailyWriter {
|
||||
dir, name := filepath.Split(path)
|
||||
ext := filepath.Ext(name)
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
return &dailyWriter{
|
||||
dir: filepath.Clean(dir),
|
||||
base: strings.TrimSuffix(name, ext),
|
||||
ext: ext,
|
||||
days: days,
|
||||
now: now,
|
||||
warn: warn,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dailyWriter) fileName(date string) string {
|
||||
return filepath.Join(d.dir, d.base+"-"+date+d.ext)
|
||||
}
|
||||
|
||||
// Write appends p to the current day's file in one write call.
|
||||
func (d *dailyWriter) Write(p []byte) (int, error) {
|
||||
d.mu.Lock()
|
||||
if d.disabled {
|
||||
d.mu.Unlock()
|
||||
return len(p), nil
|
||||
}
|
||||
date := d.now().Local().Format(dateLayout)
|
||||
if d.file == nil || date != d.date {
|
||||
if err := d.open(date); err != nil {
|
||||
d.disabled = true
|
||||
warn := d.warn
|
||||
name := d.fileName(date)
|
||||
d.mu.Unlock()
|
||||
if warn != nil {
|
||||
warn(name, err)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
}
|
||||
n, err := d.file.Write(p)
|
||||
d.mu.Unlock()
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (d *dailyWriter) open(date string) error {
|
||||
if d.file != nil {
|
||||
_ = d.file.Close()
|
||||
d.file = nil
|
||||
}
|
||||
if err := os.MkdirAll(d.dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
name := d.fileName(date)
|
||||
_, statErr := os.Stat(name)
|
||||
f, err := os.OpenFile(name, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if errors.Is(statErr, fs.ErrNotExist) {
|
||||
// The process umask may strip bits; new log files are 0o640 exactly.
|
||||
_ = f.Chmod(0o640)
|
||||
}
|
||||
d.file, d.date = f, date
|
||||
d.prune(date)
|
||||
return nil
|
||||
}
|
||||
|
||||
// prune deletes files named base-YYYY-MM-DD+ext whose date falls outside the
|
||||
// retention window: with days N the files of today and the N-1 days before
|
||||
// it are kept. Other files in the directory are never touched.
|
||||
func (d *dailyWriter) prune(today string) {
|
||||
if d.days <= 0 {
|
||||
return
|
||||
}
|
||||
now, err := time.ParseInLocation(dateLayout, today, time.Local)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := now.AddDate(0, 0, -d.days)
|
||||
entries, err := os.ReadDir(d.dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
prefix := d.base + "-"
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
rest, ok := strings.CutPrefix(name, prefix)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
stamp, ok := strings.CutSuffix(rest, d.ext)
|
||||
if !ok || len(stamp) != len(dateLayout) {
|
||||
continue
|
||||
}
|
||||
day, err := time.ParseInLocation(dateLayout, stamp, time.Local)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !day.After(cutoff) {
|
||||
_ = os.Remove(filepath.Join(d.dir, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the open file. A later write opens it again.
|
||||
func (d *dailyWriter) Close() error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.file == nil {
|
||||
return nil
|
||||
}
|
||||
err := d.file.Close()
|
||||
d.file, d.date = nil, ""
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user