package logger import ( "context" "fmt" "homestead/homestead_gateway/util/config" "log/slog" "os" "path/filepath" "time" slogmulti "github.com/samber/slog-multi" ) // New creates a logger identified by id. // Returns: *slog.Logger, closeFunc, error. // Call closeFunc() on shutdown to close open files. func New(id string, cfg config.LogConfig) (*slog.Logger, func() error, error) { if cfg.Directory == "" { cfg.Directory = "logs" } if cfg.Rotation <= 0 { cfg.Rotation = 7 } console := slog.NewTextHandler(&prefixWriter{inner: os.Stderr, prefix: []byte("[" + id + "] "), startLine: true}, &slog.HandlerOptions{AddSource: true}) router := newFileRouter(cfg.Directory, cfg.Rotation, id) root := slogmulti.Fanout(console, router) return slog.New(root), router.CloseFiles, nil } func (p *prefixWriter) Write(b []byte) (int, error) { p.mu.Lock() defer p.mu.Unlock() totalWritten := 0 if p.startLine { n, err := p.inner.Write(p.prefix) totalWritten += n if err != nil { return totalWritten, err } p.startLine = false } n, err := p.inner.Write(b) totalWritten += n if err != nil { return totalWritten, err } if len(b) > 0 && b[len(b)-1] == '\n' { p.startLine = true } return totalWritten, nil } func newFileRouter(baseDir string, rotationDays int, id string) *fileRouter { return &fileRouter{ handlers: make(map[string]slog.Handler), files: make(map[string]*os.File), baseDir: baseDir, rotationDays: rotationDays, id: id, dirTimeLayout: "2006-01-02", // e.g. 2025-11-30 } } //goland:noinspection GoUnusedParameter func (r *fileRouter) Enabled(ctx context.Context, lvl slog.Level) bool { // Conservatively true; actual handler will decide. return true } func (r *fileRouter) Handle(ctx context.Context, rec slog.Record) error { now := time.Now() dirName := now.Format(r.dirTimeLayout) dirPath := filepath.Join(r.baseDir, dirName) filePath := filepath.Join(dirPath, r.id+".log") h, err := r.getHandler(dirPath, filePath) if err != nil { return fmt.Errorf("file router get handler: %w", err) } return h.Handle(ctx, rec) } func (r *fileRouter) WithAttrs(attrs []slog.Attr) slog.Handler { return r } func (r *fileRouter) WithGroup(name string) slog.Handler { return r } // getHandler returns a text handler for the given file, creating dir/file and cleaning up old dirs if needed. func (r *fileRouter) getHandler(dirPath, filePath string) (slog.Handler, error) { r.mu.RLock() h, ok := r.handlers[filePath] r.mu.RUnlock() if ok { return h, nil } r.mu.Lock() defer r.mu.Unlock() if h, ok = r.handlers[filePath]; ok { return h, nil } _, statErr := os.Stat(dirPath) dirExisted := statErr == nil if err := os.MkdirAll(dirPath, 0o755); err != nil { return nil, fmt.Errorf("mkdir %s: %w", dirPath, err) } if !dirExisted { if err := r.cleanupOldDirs(); err != nil { // don't fail logging just because cleanup failed; report to stderr and continue. _, _ = fmt.Fprintf(os.Stderr, "logger: cleanupOldDirs error: %v\n", err) } } f, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return nil, fmt.Errorf("open log file %s: %w", filePath, err) } textHandler := slog.NewTextHandler(f, &slog.HandlerOptions{ AddSource: false, }) r.files[filePath] = f r.handlers[filePath] = textHandler return textHandler, nil } // cleanupOldDirs scans r.baseDir for directories matching the dirTimeLayout and deletes any whose // day-start time is older than rotationDays from now. func (r *fileRouter) cleanupOldDirs() error { entries, err := os.ReadDir(r.baseDir) if err != nil { if os.IsNotExist(err) { return nil } return fmt.Errorf("read baseDir: %w", err) } now := time.Now() cutoff := now.AddDate(0, 0, -r.rotationDays) for _, e := range entries { if !e.IsDir() { continue } name := e.Name() t, err := time.ParseInLocation(r.dirTimeLayout, name, time.Local) if err != nil { continue } if t.Before(cutoff) { path := filepath.Join(r.baseDir, name) if err := os.RemoveAll(path); err != nil { _, _ = fmt.Fprintf(os.Stderr, "logger: failed to remove old log dir %s: %v\n", path, err) } } } return nil } // CloseFiles closes open files func (r *fileRouter) CloseFiles() error { r.mu.Lock() defer r.mu.Unlock() var firstErr error for p, f := range r.files { if err := f.Close(); err != nil && firstErr == nil { firstErr = err } delete(r.files, p) delete(r.handlers, p) } return firstErr }