// Package log provides the application-wide structured logger: slog // writing to stderr, with a colorized TTY handler when stderr is a // terminal and JSON output otherwise. // // Everything this package emits is a diagnostic, so it all goes to // stderr. stdout belongs to the output the user asked for. package log //nolint:revive,nolintlint // stdlib log unused here; see #76 import ( "context" "fmt" "log/slog" "os" "path/filepath" "runtime" "strings" "golang.org/x/term" ) // Level represents the logging level. type Level int const ( // LevelFatal represents a fatal error level that will exit the program. LevelFatal Level = iota // LevelError represents an error level. LevelError // LevelWarn represents a warning level. LevelWarn // LevelNotice represents a notice level (mapped to Info in slog). LevelNotice // LevelInfo represents an informational level. LevelInfo // LevelDebug represents a debug level. LevelDebug ) // Config holds logger configuration. type Config struct { Verbose bool Debug bool Cron bool Quiet bool } //nolint:gochecknoglobals // package-level logger is the package's purpose var logger *slog.Logger // Initialize sets up the global logger based on the provided configuration. func Initialize(cfg Config) { // Determine log level based on configuration var level slog.Level switch { case cfg.Cron || cfg.Quiet: // In cron/quiet mode keep warnings and errors visible — the // whole point of --cron is to stay silent only on total // success, so that anything cron emails to root is genuinely // "something went wrong, look at it." A backup with stuck // permission errors or skipped files should NOT be silent. level = slog.LevelWarn case cfg.Debug || strings.Contains(os.Getenv("GODEBUG"), "vaultik"): level = slog.LevelDebug case cfg.Verbose: level = slog.LevelInfo default: level = slog.LevelWarn } // Create handler with appropriate level opts := &slog.HandlerOptions{ Level: level, } // Diagnostics go to stderr, never to stdout. stdout is reserved for // the output the user asked for: every --json subcommand writes its // document there, and WARN/ERROR are never suppressed, so a logger // on stdout puts log records inside that document and makes it // unparseable. A config file with group- or world-readable // permissions is enough to trigger it (see internal/config), so this // was not a theoretical collision. // // The format is chosen by the TTY-ness of the stream the records // actually land on. AGENTS.md policy 9 says "if stdout is not a // terminal, emit jsonl"; it says stdout because that is where logs // used to go, and the property it is really asking for is that // output nobody is watching be machine-readable. Testing stdout here // would colorize records on a redirected stderr whenever stdout // happened to be a terminal, and vice versa. if term.IsTerminal(int(os.Stderr.Fd())) { // Use colorized TTY handler logger = slog.New(NewTTYHandler(os.Stderr, opts)) } else { // Use JSON format for non-TTY output logger = slog.New(slog.NewJSONHandler(os.Stderr, opts)) } // Set as default logger slog.SetDefault(logger) } // callerSkipFrames is the number of stack frames between runtime.Caller // and the code that invoked the package-level logging function. const callerSkipFrames = 2 // getCaller returns the caller information as a string func getCaller() string { _, file, line, ok := runtime.Caller(callerSkipFrames) if !ok { return "unknown" } return fmt.Sprintf("%s:%d", filepath.Base(file), line) } // Fatal logs a fatal error message and exits the program with code 1. func Fatal(msg string, args ...any) { if logger != nil { // Add caller info to args args = append(args, "caller", getCaller()) logger.Error(msg, args...) } os.Exit(1) } // Fatalf logs a formatted fatal error message and exits the program with code 1. func Fatalf(format string, args ...any) { Fatal(fmt.Sprintf(format, args...)) } // Error logs an error message. func Error(msg string, args ...any) { if logger != nil { args = append(args, "caller", getCaller()) logger.Error(msg, args...) } } // Errorf logs a formatted error message. func Errorf(format string, args ...any) { Error(fmt.Sprintf(format, args...)) } // Warn logs a warning message. func Warn(msg string, args ...any) { if logger != nil { args = append(args, "caller", getCaller()) logger.Warn(msg, args...) } } // Warnf logs a formatted warning message. func Warnf(format string, args ...any) { Warn(fmt.Sprintf(format, args...)) } // Notice logs a notice message (mapped to Info level). func Notice(msg string, args ...any) { if logger != nil { args = append(args, "caller", getCaller()) logger.Info(msg, args...) } } // Noticef logs a formatted notice message. func Noticef(format string, args ...any) { Notice(fmt.Sprintf(format, args...)) } // Info logs an informational message. func Info(msg string, args ...any) { if logger != nil { args = append(args, "caller", getCaller()) logger.Info(msg, args...) } } // Infof logs a formatted informational message. func Infof(format string, args ...any) { Info(fmt.Sprintf(format, args...)) } // Debug logs a debug message. func Debug(msg string, args ...any) { if logger != nil { args = append(args, "caller", getCaller()) logger.Debug(msg, args...) } } // Debugf logs a formatted debug message. func Debugf(format string, args ...any) { Debug(fmt.Sprintf(format, args...)) } // With returns a logger with additional context attributes. func With(args ...any) *slog.Logger { if logger != nil { return logger.With(args...) } return slog.Default() } // WithContext returns a logger with the provided context. func WithContext(_ context.Context) *slog.Logger { return logger } // Logger returns the underlying slog.Logger instance. func Logger() *slog.Logger { return logger }