Files
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

150 lines
3.4 KiB
Go

package logger
import (
"bufio"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// ReadLogs returns all log entries for agentID between from and to (inclusive).
func ReadLogs(baseDir, agentID string, from, to time.Time) ([]json.RawMessage, error) {
var result []json.RawMessage
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
entries, err := ReadDayLogs(baseDir, agentID, d)
if err != nil {
continue // skip missing days
}
result = append(result, entries...)
}
return result, nil
}
// ReadDayLogs returns all log entries for a specific day.
func ReadDayLogs(baseDir, agentID string, date time.Time) ([]json.RawMessage, error) {
dir := filepath.Join(baseDir, agentID)
prefix := date.Format("2006-01-02")
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("read dir %s: %w", dir, err)
}
var result []json.RawMessage
for _, e := range entries {
name := e.Name()
if !strings.HasPrefix(name, prefix) || !isLogFile(name) {
continue
}
lines, err := readLogFile(filepath.Join(dir, name))
if err != nil {
continue
}
result = append(result, lines...)
}
return result, nil
}
// SearchLogs returns log entries where field equals value, within the date range.
func SearchLogs(baseDir, agentID string, field, value string, from, to time.Time) ([]json.RawMessage, error) {
all, err := ReadLogs(baseDir, agentID, from, to)
if err != nil {
return nil, err
}
var matched []json.RawMessage
for _, raw := range all {
var m map[string]any
if json.Unmarshal(raw, &m) != nil {
continue
}
if v, ok := m[field]; ok && fmt.Sprint(v) == value {
matched = append(matched, raw)
}
}
return matched, nil
}
// ListAgents returns the agent IDs that have log directories under baseDir.
func ListAgents(baseDir string) ([]string, error) {
entries, err := os.ReadDir(baseDir)
if err != nil {
return nil, fmt.Errorf("read base dir %s: %w", baseDir, err)
}
var ids []string
for _, e := range entries {
if e.IsDir() {
ids = append(ids, e.Name())
}
}
sort.Strings(ids)
return ids, nil
}
// ListDates returns the dates for which logs exist for the given agent.
func ListDates(baseDir, agentID string) ([]time.Time, error) {
dir := filepath.Join(baseDir, agentID)
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("read dir %s: %w", dir, err)
}
seen := make(map[string]bool)
var dates []time.Time
for _, e := range entries {
if e.IsDir() || !isLogFile(e.Name()) {
continue
}
d := parseDateFromFilename(e.Name())
if d.IsZero() {
continue
}
key := d.Format("2006-01-02")
if !seen[key] {
seen[key] = true
dates = append(dates, d)
}
}
sort.Slice(dates, func(i, j int) bool { return dates[i].Before(dates[j]) })
return dates, nil
}
// readLogFile reads all JSONL lines from a file (.jsonl or .jsonl.gz).
func readLogFile(path string) ([]json.RawMessage, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var r io.Reader = f
if strings.HasSuffix(path, ".gz") {
gz, err := gzip.NewReader(f)
if err != nil {
return nil, err
}
defer gz.Close()
r = gz
}
var lines []json.RawMessage
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
cp := make([]byte, len(line))
copy(cp, line)
lines = append(lines, json.RawMessage(cp))
}
return lines, scanner.Err()
}