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:
2026-03-06 17:26:56 +00:00
parent b596a967cc
commit 71079962ca
8 changed files with 917 additions and 0 deletions
+84
View File
@@ -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
}
+110
View File
@@ -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")
}
}
+101
View File
@@ -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
}
+77
View File
@@ -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)
}
}
+149
View File
@@ -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()
}
+130
View File
@@ -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))
}
}
+168
View File
@@ -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)
}
+98
View File
@@ -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")
}
}