Files
agents_and_robots/shell/logger/writer.go
T
egutierrez 71079962ca feat: add structured JSONL logging package with rotation and query
Nuevo paquete shell/logger/ que implementa logging estructurado JSONL
para agentes. Incluye DailyRotatingWriter con rotación diaria y por
tamaño (50MB default), limpieza automática de archivos viejos (7 días),
compresión gzip de logs rotados, y funciones de consulta (ReadLogs,
SearchLogs, ListAgents, ListDates) para que agentes LLM puedan leer
logs de otros agentes. Basado en log/slog de stdlib, sin dependencias
externas. 18 tests unitarios cubren rotación, concurrencia y consultas.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:26:56 +00:00

169 lines
3.9 KiB
Go

package logger
import (
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"time"
)
// DailyRotatingWriter is an io.Writer that rotates log files daily and by
// size. Files are named <baseDir>/<agentID>/YYYY-MM-DD.jsonl with optional
// numeric suffixes for size-based splits within the same day.
type DailyRotatingWriter struct {
baseDir string
agentID string
maxSize int64 // bytes
compress bool
nowFunc func() time.Time // for testing; defaults to time.Now().UTC
dir string // resolved agent log directory
mu sync.Mutex
current *os.File
written int64
currentDay string
suffix int
}
// NewDailyRotatingWriter creates a writer that stores logs under
// baseDir/agentID/. It creates the directory if needed and opens the first
// log file for today.
func NewDailyRotatingWriter(baseDir, agentID string, maxSizeMB int64, compress bool) (*DailyRotatingWriter, error) {
dir := filepath.Join(baseDir, agentID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create log dir %s: %w", dir, err)
}
w := &DailyRotatingWriter{
baseDir: baseDir,
agentID: agentID,
maxSize: maxSizeMB * 1024 * 1024,
compress: compress,
nowFunc: func() time.Time { return time.Now().UTC() },
dir: dir,
}
if err := w.openFile(); err != nil {
return nil, err
}
return w, nil
}
// Write implements io.Writer with daily and size-based rotation.
func (w *DailyRotatingWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
today := w.nowFunc().Format("2006-01-02")
// Day changed → rotate to new day file.
if today != w.currentDay {
if err := w.rotate(today, 0); err != nil {
return 0, err
}
}
// Size exceeded → split within same day.
if w.written+int64(len(p)) > w.maxSize && w.written > 0 {
w.suffix++
if err := w.rotate(today, w.suffix); err != nil {
return 0, err
}
}
n, err := w.current.Write(p)
w.written += int64(n)
return n, err
}
// Close closes the current log file.
func (w *DailyRotatingWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.current != nil {
return w.current.Close()
}
return nil
}
// rotate closes the current file (optionally compressing it) and opens a new one.
func (w *DailyRotatingWriter) rotate(day string, suffix int) error {
prev := w.current
prevPath := ""
if prev != nil {
prevPath = prev.Name()
prev.Close()
}
// Compress the previous file in the background if enabled and it's from a
// different day (we don't compress intra-day splits until day rotates).
if w.compress && prevPath != "" && day != w.currentDay {
go compressFile(prevPath)
}
w.currentDay = day
w.suffix = suffix
w.written = 0
return w.openFile()
}
// openFile opens (or creates) the log file for the current day/suffix.
func (w *DailyRotatingWriter) openFile() error {
w.currentDay = w.nowFunc().Format("2006-01-02")
name := w.filename(w.currentDay, w.suffix)
f, err := os.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return fmt.Errorf("open log file %s: %w", name, err)
}
// Track how much has already been written (append mode).
info, err := f.Stat()
if err != nil {
f.Close()
return err
}
w.current = f
w.written = info.Size()
return nil
}
// filename returns the full path for a given day and suffix.
func (w *DailyRotatingWriter) filename(day string, suffix int) string {
if suffix == 0 {
return filepath.Join(w.dir, day+".jsonl")
}
return filepath.Join(w.dir, fmt.Sprintf("%s.%d.jsonl", day, suffix))
}
// compressFile gzips src to src.gz and removes the original.
func compressFile(src string) {
in, err := os.Open(src)
if err != nil {
return
}
defer in.Close()
out, err := os.Create(src + ".gz")
if err != nil {
return
}
gz := gzip.NewWriter(out)
if _, err := io.Copy(gz, in); err != nil {
gz.Close()
out.Close()
os.Remove(src + ".gz")
return
}
gz.Close()
out.Close()
in.Close()
os.Remove(src)
}