package main import ( "fmt" "io" "log/slog" "os" "path/filepath" "sort" "strings" "sync" "time" ) type dailyWriter struct { dir string prefix string mu sync.Mutex day string part int file *os.File fileSize int64 retention int maxFileBytes int64 maxTotalBytes int64 lastCleanup time.Time bytesSinceGC int64 } func newDailyWriter(dir, prefix string, retention int, maxFileBytes, maxTotalBytes int64) (*dailyWriter, error) { dir, err := filepath.Abs(dir) if err != nil { return nil, err } if err = os.MkdirAll(dir, 0o755); err != nil { return nil, err } w := &dailyWriter{ dir: dir, prefix: prefix, retention: retention, maxFileBytes: maxFileBytes, maxTotalBytes: maxTotalBytes, } if err = w.openForDayLocked(time.Now()); err != nil { return nil, err } w.cleanupLocked(time.Now()) return w, nil } func (w *dailyWriter) Write(p []byte) (int, error) { w.mu.Lock() defer w.mu.Unlock() now := time.Now() if w.file == nil || w.day != now.Format("2006-01-02") { if err := w.openForDayLocked(now); err != nil { return 0, err } w.cleanupLocked(now) } if w.maxFileBytes > 0 && w.fileSize > 0 && w.fileSize+int64(len(p)) > w.maxFileBytes { if err := w.openNextPartLocked(now); err != nil { return 0, err } w.cleanupLocked(now) } n, err := w.file.Write(p) w.fileSize += int64(n) w.bytesSinceGC += int64(n) if err == nil && (w.bytesSinceGC >= 1<<20 || now.Sub(w.lastCleanup) >= time.Minute || (w.maxFileBytes > 0 && w.fileSize >= w.maxFileBytes)) { w.cleanupLocked(now) } return n, err } func (w *dailyWriter) Close() error { w.mu.Lock() defer w.mu.Unlock() if w.file == nil { return nil } err := w.file.Close() w.file = nil return err } func (w *dailyWriter) openForDayLocked(now time.Time) error { if w.file != nil { _ = w.file.Close() w.file = nil } w.day = now.Format("2006-01-02") w.part = 0 for { path := w.partPathLocked() info, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { return w.openPathLocked(path, 0) } return err } if w.maxFileBytes <= 0 || info.Size() < w.maxFileBytes { return w.openPathLocked(path, info.Size()) } w.part++ } } func (w *dailyWriter) openNextPartLocked(now time.Time) error { if w.file != nil { _ = w.file.Close() w.file = nil } if w.day != now.Format("2006-01-02") { return w.openForDayLocked(now) } w.part++ for { path := w.partPathLocked() info, err := os.Stat(path) if os.IsNotExist(err) { return w.openPathLocked(path, 0) } if err != nil { return err } if w.maxFileBytes <= 0 || info.Size() < w.maxFileBytes { return w.openPathLocked(path, info.Size()) } w.part++ } } func (w *dailyWriter) partPathLocked() string { if w.part == 0 { return filepath.Join(w.dir, fmt.Sprintf("%s-%s.log", w.prefix, w.day)) } return filepath.Join(w.dir, fmt.Sprintf("%s-%s-%03d.log", w.prefix, w.day, w.part)) } func (w *dailyWriter) openPathLocked(path string, size int64) error { file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return err } w.file = file w.fileSize = size return nil } type logFileInfo struct { path string size int64 modTime time.Time } func (w *dailyWriter) cleanupLocked(now time.Time) { w.lastCleanup = now w.bytesSinceGC = 0 entries, err := os.ReadDir(w.dir) if err != nil { return } cutoff := now.AddDate(0, 0, -w.retention) files := make([]logFileInfo, 0, len(entries)) var total int64 currentPath := "" if w.file != nil { currentPath, _ = filepath.Abs(w.file.Name()) } for _, entry := range entries { if entry.IsDir() || !isManagedLogName(entry.Name()) { continue } path := filepath.Join(w.dir, entry.Name()) info, statErr := entry.Info() if statErr != nil { continue } if w.retention > 0 && info.ModTime().Before(cutoff) && !samePath(path, currentPath) { if os.Remove(path) == nil { continue } } files = append(files, logFileInfo{path: path, size: info.Size(), modTime: info.ModTime()}) total += info.Size() } if w.maxTotalBytes <= 0 || total <= w.maxTotalBytes { return } sort.Slice(files, func(i, j int) bool { return files[i].modTime.Before(files[j].modTime) }) for _, file := range files { if total <= w.maxTotalBytes { break } if samePath(file.path, currentPath) { continue } if os.Remove(file.path) == nil { total -= file.size } } } func isManagedLogName(name string) bool { return strings.HasSuffix(name, ".log") && (strings.HasPrefix(name, "service-") || strings.HasPrefix(name, "crash-")) } func samePath(left, right string) bool { return right != "" && strings.EqualFold(filepath.Clean(left), filepath.Clean(right)) } type appLogger struct { *slog.Logger writer *dailyWriter } func newAppLogger(cfg Config, component string) (*appLogger, error) { maxFileBytes := int64(cfg.LogMaxFileMB) * 1024 * 1024 maxTotalBytes := int64(cfg.LogMaxTotalMB) * 1024 * 1024 w, err := newDailyWriter(cfg.LogDir, "service-"+component, cfg.LogRetentionDays, maxFileBytes, maxTotalBytes) if err != nil { return nil, err } handler := slog.NewJSONHandler(io.MultiWriter(os.Stdout, w), &slog.HandlerOptions{Level: slog.LevelInfo}) return &appLogger{Logger: slog.New(handler).With("component", component, "pid", os.Getpid()), writer: w}, nil } func (l *appLogger) Close() error { if l == nil || l.writer == nil { return nil } return l.writer.Close() } func discardLogger() *appLogger { return &appLogger{Logger: slog.New(slog.NewJSONHandler(io.Discard, nil))} }