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>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// runCleanup periodically removes log files older than maxAgeDays for the
|
||||
// given agent. It runs until ctx is cancelled.
|
||||
func runCleanup(ctx context.Context, baseDir, agentID string, maxAgeDays int, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run once immediately at startup.
|
||||
cleanOldLogs(baseDir, agentID, maxAgeDays)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
cleanOldLogs(baseDir, agentID, maxAgeDays)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanOldLogs removes .jsonl and .jsonl.gz files older than maxAgeDays.
|
||||
func cleanOldLogs(baseDir, agentID string, maxAgeDays int) {
|
||||
dir := filepath.Join(baseDir, agentID)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -maxAgeDays)
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !isLogFile(name) {
|
||||
continue
|
||||
}
|
||||
|
||||
date := parseDateFromFilename(name)
|
||||
if date.IsZero() {
|
||||
continue
|
||||
}
|
||||
if date.Before(cutoff) {
|
||||
os.Remove(filepath.Join(dir, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isLogFile returns true for .jsonl and .jsonl.gz files.
|
||||
func isLogFile(name string) bool {
|
||||
return strings.HasSuffix(name, ".jsonl") || strings.HasSuffix(name, ".jsonl.gz")
|
||||
}
|
||||
|
||||
// parseDateFromFilename extracts YYYY-MM-DD from filenames like:
|
||||
//
|
||||
// 2026-03-06.jsonl
|
||||
// 2026-03-06.1.jsonl
|
||||
// 2026-03-06.jsonl.gz
|
||||
func parseDateFromFilename(name string) time.Time {
|
||||
// Strip extensions.
|
||||
base := strings.TrimSuffix(name, ".gz")
|
||||
base = strings.TrimSuffix(base, ".jsonl")
|
||||
|
||||
// Remove numeric suffix (e.g., ".1" from "2026-03-06.1").
|
||||
if idx := strings.LastIndex(base, "."); idx >= 0 {
|
||||
candidate := base[:idx]
|
||||
if t, err := time.Parse("2006-01-02", candidate); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
t, _ := time.Parse("2006-01-02", base)
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCleanOldLogs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
agentDir := filepath.Join(dir, "bot1")
|
||||
os.MkdirAll(agentDir, 0o755)
|
||||
|
||||
// Create files: 10 days ago, 5 days ago, today.
|
||||
files := []string{
|
||||
"2026-02-24.jsonl",
|
||||
"2026-02-24.jsonl.gz",
|
||||
"2026-03-01.jsonl",
|
||||
"2026-03-06.jsonl",
|
||||
}
|
||||
for _, f := range files {
|
||||
os.WriteFile(filepath.Join(agentDir, f), []byte("{}"), 0o644)
|
||||
}
|
||||
|
||||
// Retain 7 days → should remove 2026-02-24 files.
|
||||
cleanOldLogs(dir, "bot1", 7)
|
||||
|
||||
remaining, _ := os.ReadDir(agentDir)
|
||||
names := make(map[string]bool)
|
||||
for _, e := range remaining {
|
||||
names[e.Name()] = true
|
||||
}
|
||||
|
||||
if names["2026-02-24.jsonl"] {
|
||||
t.Error("2026-02-24.jsonl should have been removed")
|
||||
}
|
||||
if names["2026-02-24.jsonl.gz"] {
|
||||
t.Error("2026-02-24.jsonl.gz should have been removed")
|
||||
}
|
||||
if !names["2026-03-01.jsonl"] {
|
||||
t.Error("2026-03-01.jsonl should still exist")
|
||||
}
|
||||
if !names["2026-03-06.jsonl"] {
|
||||
t.Error("2026-03-06.jsonl should still exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDateFromFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want string
|
||||
}{
|
||||
{"2026-03-06.jsonl", "2026-03-06"},
|
||||
{"2026-03-06.1.jsonl", "2026-03-06"},
|
||||
{"2026-03-06.jsonl.gz", "2026-03-06"},
|
||||
{"2026-03-06.2.jsonl.gz", "2026-03-06"},
|
||||
{"invalid.jsonl", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
d := parseDateFromFilename(tt.name)
|
||||
got := ""
|
||||
if !d.IsZero() {
|
||||
got = d.Format("2006-01-02")
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("parseDateFromFilename(%q) = %q, want %q", tt.name, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanOldLogs_EmptyDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Should not panic on non-existent agent dir.
|
||||
cleanOldLogs(dir, "nonexistent", 7)
|
||||
}
|
||||
|
||||
func TestIsLogFile(t *testing.T) {
|
||||
if !isLogFile("2026-03-06.jsonl") {
|
||||
t.Error("should match .jsonl")
|
||||
}
|
||||
if !isLogFile("2026-03-06.jsonl.gz") {
|
||||
t.Error("should match .jsonl.gz")
|
||||
}
|
||||
if isLogFile("readme.txt") {
|
||||
t.Error("should not match .txt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCleanup_Cancellation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(dir, "bot1"), 0o755)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
runCleanup(ctx, dir, "bot1", 7, 50*time.Millisecond)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("cleanup goroutine did not exit after cancel")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Package logger provides structured JSONL logging for agents with daily
|
||||
// file rotation, size-based splitting, automatic cleanup, and query helpers.
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Standard field names for structured logging across all agents.
|
||||
const (
|
||||
FieldAgentID = "agent_id"
|
||||
FieldTraceID = "trace_id"
|
||||
FieldAction = "action"
|
||||
FieldReason = "reason"
|
||||
FieldDurationMS = "duration_ms"
|
||||
FieldTokensUsed = "tokens_used"
|
||||
FieldResult = "result"
|
||||
FieldErrorType = "error_type"
|
||||
FieldComponent = "component"
|
||||
)
|
||||
|
||||
// traceKey is the context key for trace IDs.
|
||||
type traceKey struct{}
|
||||
|
||||
// WithTraceID returns a new context carrying the given trace ID.
|
||||
func WithTraceID(ctx context.Context, id string) context.Context {
|
||||
return context.WithValue(ctx, traceKey{}, id)
|
||||
}
|
||||
|
||||
// TraceIDFromCtx extracts the trace ID from ctx, or "" if absent.
|
||||
func TraceIDFromCtx(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(traceKey{}).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// LoggerConfig configures a per-agent logger.
|
||||
type LoggerConfig struct {
|
||||
BaseDir string // root log directory (default: "logs"); empty → stdout only
|
||||
AgentID string // agent identifier (required)
|
||||
MaxSizeMB int64 // max file size before rotation (default: 50)
|
||||
MaxAgeDays int // retention in days (default: 7)
|
||||
Compress bool // gzip rotated files (default: true)
|
||||
CleanupInterval time.Duration // cleanup ticker interval (default: 24h)
|
||||
Level slog.Level // minimum log level (default: INFO)
|
||||
}
|
||||
|
||||
func (c *LoggerConfig) defaults() {
|
||||
if c.BaseDir == "" {
|
||||
c.BaseDir = "logs"
|
||||
}
|
||||
if c.MaxSizeMB <= 0 {
|
||||
c.MaxSizeMB = 50
|
||||
}
|
||||
if c.MaxAgeDays <= 0 {
|
||||
c.MaxAgeDays = 7
|
||||
}
|
||||
if c.CleanupInterval <= 0 {
|
||||
c.CleanupInterval = 24 * time.Hour
|
||||
}
|
||||
}
|
||||
|
||||
// NewAgentLogger creates a structured JSON logger that writes to daily-rotated
|
||||
// JSONL files under BaseDir/<AgentID>/. It returns:
|
||||
// - a *slog.Logger pre-enriched with agent_id
|
||||
// - a cleanup func to call on shutdown (closes files, stops cleanup goroutine)
|
||||
// - an error if the log directory cannot be created
|
||||
//
|
||||
// If BaseDir is literally "stdout", the logger writes to os.Stdout with no
|
||||
// file rotation or cleanup.
|
||||
func NewAgentLogger(cfg LoggerConfig) (*slog.Logger, func(), error) {
|
||||
if cfg.BaseDir == "stdout" {
|
||||
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.Level})
|
||||
l := slog.New(h).With(FieldAgentID, cfg.AgentID)
|
||||
return l, func() {}, nil
|
||||
}
|
||||
|
||||
cfg.defaults()
|
||||
|
||||
w, err := NewDailyRotatingWriter(cfg.BaseDir, cfg.AgentID, cfg.MaxSizeMB, cfg.Compress)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
h := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: cfg.Level})
|
||||
l := slog.New(h).With(FieldAgentID, cfg.AgentID)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go runCleanup(ctx, cfg.BaseDir, cfg.AgentID, cfg.MaxAgeDays, cfg.CleanupInterval)
|
||||
|
||||
cleanup := func() {
|
||||
cancel()
|
||||
w.Close()
|
||||
}
|
||||
|
||||
return l, cleanup, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewAgentLogger_WritesJSONL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
l, cleanup, err := NewAgentLogger(LoggerConfig{
|
||||
BaseDir: dir,
|
||||
AgentID: "test-bot",
|
||||
Level: slog.LevelDebug,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
l.Info("hello world", FieldAction, "greet", FieldReason, "testing")
|
||||
|
||||
// Force flush by closing.
|
||||
cleanup()
|
||||
|
||||
files, _ := os.ReadDir(filepath.Join(dir, "test-bot"))
|
||||
if len(files) == 0 {
|
||||
t.Fatal("expected at least one log file")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, "test-bot", files[0].Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Fatalf("log line is not valid JSON: %s", data)
|
||||
}
|
||||
if m["msg"] != "hello world" {
|
||||
t.Errorf("msg = %v, want hello world", m["msg"])
|
||||
}
|
||||
if m[FieldAgentID] != "test-bot" {
|
||||
t.Errorf("agent_id = %v, want test-bot", m[FieldAgentID])
|
||||
}
|
||||
if m[FieldAction] != "greet" {
|
||||
t.Errorf("action = %v, want greet", m[FieldAction])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAgentLogger_Stdout(t *testing.T) {
|
||||
l, cleanup, err := NewAgentLogger(LoggerConfig{
|
||||
BaseDir: "stdout",
|
||||
AgentID: "dev-bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
// Just verify it doesn't panic.
|
||||
l.Info("stdout test")
|
||||
}
|
||||
|
||||
func TestTraceIDContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if got := TraceIDFromCtx(ctx); got != "" {
|
||||
t.Errorf("empty ctx should return empty trace, got %q", got)
|
||||
}
|
||||
|
||||
ctx = WithTraceID(ctx, "abc-123")
|
||||
if got := TraceIDFromCtx(ctx); got != "abc-123" {
|
||||
t.Errorf("trace = %q, want abc-123", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func setupQueryDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
bot1 := filepath.Join(dir, "bot1")
|
||||
bot2 := filepath.Join(dir, "bot2")
|
||||
os.MkdirAll(bot1, 0o755)
|
||||
os.MkdirAll(bot2, 0o755)
|
||||
|
||||
lines := []string{
|
||||
`{"time":"2026-03-05T10:00:00Z","level":"INFO","msg":"hello","action":"greet"}`,
|
||||
`{"time":"2026-03-05T11:00:00Z","level":"ERROR","msg":"oops","action":"fail"}`,
|
||||
}
|
||||
os.WriteFile(filepath.Join(bot1, "2026-03-05.jsonl"),
|
||||
[]byte(lines[0]+"\n"+lines[1]+"\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(bot1, "2026-03-06.jsonl"),
|
||||
[]byte(`{"time":"2026-03-06T09:00:00Z","level":"INFO","msg":"day2"}`+"\n"), 0o644)
|
||||
|
||||
os.WriteFile(filepath.Join(bot2, "2026-03-06.jsonl"),
|
||||
[]byte(`{"time":"2026-03-06T08:00:00Z","level":"DEBUG","msg":"bot2 log"}`+"\n"), 0o644)
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestListAgents(t *testing.T) {
|
||||
dir := setupQueryDir(t)
|
||||
agents, err := ListAgents(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(agents) != 2 {
|
||||
t.Fatalf("expected 2 agents, got %d", len(agents))
|
||||
}
|
||||
if agents[0] != "bot1" || agents[1] != "bot2" {
|
||||
t.Errorf("agents = %v, want [bot1 bot2]", agents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDates(t *testing.T) {
|
||||
dir := setupQueryDir(t)
|
||||
dates, err := ListDates(dir, "bot1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(dates) != 2 {
|
||||
t.Fatalf("expected 2 dates, got %d", len(dates))
|
||||
}
|
||||
if dates[0].Format("2006-01-02") != "2026-03-05" {
|
||||
t.Errorf("first date = %v, want 2026-03-05", dates[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDayLogs(t *testing.T) {
|
||||
dir := setupQueryDir(t)
|
||||
day := time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC)
|
||||
entries, err := ReadDayLogs(dir, "bot1", day)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLogs(t *testing.T) {
|
||||
dir := setupQueryDir(t)
|
||||
from := time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC)
|
||||
to := time.Date(2026, 3, 6, 0, 0, 0, 0, time.UTC)
|
||||
entries, err := ReadLogs(dir, "bot1", from, to)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("expected 3 entries across 2 days, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchLogs(t *testing.T) {
|
||||
dir := setupQueryDir(t)
|
||||
from := time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC)
|
||||
to := time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
results, err := SearchLogs(dir, "bot1", "action", "fail", from, to)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 match, got %d", len(results))
|
||||
}
|
||||
var m map[string]any
|
||||
json.Unmarshal(results[0], &m)
|
||||
if m["msg"] != "oops" {
|
||||
t.Errorf("msg = %v, want oops", m["msg"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchLogs_NoMatch(t *testing.T) {
|
||||
dir := setupQueryDir(t)
|
||||
from := time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC)
|
||||
to := time.Date(2026, 3, 6, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
results, err := SearchLogs(dir, "bot1", "action", "nonexistent", from, to)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 0 {
|
||||
t.Errorf("expected 0 matches, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAgents_EmptyDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
agents, err := ListAgents(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(agents) != 0 {
|
||||
t.Errorf("expected 0 agents, got %d", len(agents))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDailyRotatingWriter_DayRotation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w, err := NewDailyRotatingWriter(dir, "bot1", 50, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
day1 := time.Date(2026, 3, 5, 12, 0, 0, 0, time.UTC)
|
||||
day2 := time.Date(2026, 3, 6, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
w.nowFunc = func() time.Time { return day1 }
|
||||
// Force re-open with correct day.
|
||||
w.current.Close()
|
||||
w.currentDay = ""
|
||||
w.openFile()
|
||||
|
||||
w.Write([]byte(`{"msg":"day1"}`))
|
||||
|
||||
w.nowFunc = func() time.Time { return day2 }
|
||||
w.Write([]byte(`{"msg":"day2"}`))
|
||||
w.Close()
|
||||
|
||||
agentDir := filepath.Join(dir, "bot1")
|
||||
entries, _ := os.ReadDir(agentDir)
|
||||
|
||||
names := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
names[e.Name()] = true
|
||||
}
|
||||
|
||||
if !names["2026-03-05.jsonl"] && !names["2026-03-05.jsonl.gz"] {
|
||||
t.Error("expected 2026-03-05.jsonl or .gz")
|
||||
}
|
||||
if !names["2026-03-06.jsonl"] {
|
||||
t.Error("expected 2026-03-06.jsonl")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyRotatingWriter_SizeRotation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// 1 byte max to force rotation on every write.
|
||||
w, err := NewDailyRotatingWriter(dir, "bot2", 0, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Override maxSize to a tiny value (can't use 0 MB).
|
||||
w.maxSize = 10
|
||||
|
||||
now := time.Date(2026, 3, 6, 10, 0, 0, 0, time.UTC)
|
||||
w.nowFunc = func() time.Time { return now }
|
||||
w.current.Close()
|
||||
w.currentDay = ""
|
||||
w.openFile()
|
||||
|
||||
w.Write([]byte(`{"line":1}` + "\n"))
|
||||
w.Write([]byte(`{"line":2}` + "\n"))
|
||||
w.Write([]byte(`{"line":3}` + "\n"))
|
||||
w.Close()
|
||||
|
||||
entries, _ := os.ReadDir(filepath.Join(dir, "bot2"))
|
||||
if len(entries) < 2 {
|
||||
t.Errorf("expected multiple files from size rotation, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyRotatingWriter_Concurrent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w, err := NewDailyRotatingWriter(dir, "bot3", 50, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
w.Write([]byte(`{"concurrent":true}` + "\n"))
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
entries, _ := os.ReadDir(filepath.Join(dir, "bot3"))
|
||||
if len(entries) == 0 {
|
||||
t.Error("expected at least one log file")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user