// 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//. 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 }