package logging import ( "context" "errors" "fmt" "log/slog" "os" "path/filepath" "strings" "sync" ) // Config configures the application logger. type Config struct { Level string // "debug"|"info"|"warn"|"error" Dir string // e.g. "./data/logs" Format string // "text"|"json" — controls terminal output; files are always JSON LogToStd bool // true: enable terminal output } // ToolCallLogger records the lifecycle of a single tool call. type ToolCallLogger interface { WithResult(result any) WithError(err error) End() } // logDir is shared with StartToolCall so the per-tool-call logger knows where // to create files. It is set by New and read by StartToolCall. var ( logDir string logDirMu sync.RWMutex ) func parseLevel(s string) slog.Level { switch strings.ToLower(s) { case "debug": return slog.LevelDebug case "warn", "warning": return slog.LevelWarn case "error": return slog.LevelError default: return slog.LevelInfo } } // multiHandler fans out records to multiple underlying handlers. type multiHandler struct { handlers []slog.Handler } func (m *multiHandler) Enabled(ctx context.Context, l slog.Level) bool { for _, h := range m.handlers { if h.Enabled(ctx, l) { return true } } return false } func (m *multiHandler) Handle(ctx context.Context, r slog.Record) error { var errs []error for _, h := range m.handlers { if h.Enabled(ctx, r.Level) { if err := h.Handle(ctx, r); err != nil { errs = append(errs, err) } } } return errors.Join(errs...) } func (m *multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler { handlers := make([]slog.Handler, len(m.handlers)) for i, h := range m.handlers { handlers[i] = h.WithAttrs(attrs) } return &multiHandler{handlers: handlers} } func (m *multiHandler) WithGroup(name string) slog.Handler { handlers := make([]slog.Handler, len(m.handlers)) for i, h := range m.handlers { handlers[i] = h.WithGroup(name) } return &multiHandler{handlers: handlers} } // New creates the main application logger and a shutdown closure. func New(cfg Config) (*slog.Logger, func(), error) { if err := os.MkdirAll(cfg.Dir, 0o755); err != nil { return nil, nil, fmt.Errorf("logging: create log dir: %w", err) } toolsDir := filepath.Join(cfg.Dir, "tools") if err := os.MkdirAll(toolsDir, 0o700); err != nil { return nil, nil, fmt.Errorf("logging: create tools log dir: %w", err) } logDirMu.Lock() logDir = cfg.Dir logDirMu.Unlock() level := parseLevel(cfg.Level) opts := &slog.HandlerOptions{Level: level} mainPath := filepath.Join(cfg.Dir, "spark-mcp.log") f, err := os.OpenFile(mainPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { return nil, nil, fmt.Errorf("logging: open main log file: %w", err) } fileHandler := slog.NewJSONHandler(f, opts) var handler slog.Handler = fileHandler if cfg.LogToStd { var stdHandler slog.Handler switch strings.ToLower(cfg.Format) { case "json": stdHandler = slog.NewJSONHandler(os.Stdout, opts) default: stdHandler = slog.NewTextHandler(os.Stdout, opts) } handler = &multiHandler{handlers: []slog.Handler{fileHandler, stdHandler}} } shutdown := func() { _ = f.Close() } logger := slog.New(handler).With("component", "main") return logger, shutdown, nil }