package logging import ( "errors" "fmt" "log/slog" "os" "path/filepath" "github.com/lmittmann/tint" ) // ---------- Standard log handling (terminal or file based) ---------- // Logger state shared by the terminal and file loggers. var ( // logLevel is the minimum level logged, read by whichever handler is // installed, so changing it takes effect immediately. logLevel slog.LevelVar // fileLogger is the log file opened by LogToFile, or nil when logging // to the terminal. fileLogger *os.File // terminal is where LogToTerminal and PrintToTerminal write. terminal = os.Stdout // isTerminal reports whether file is an interactive terminal rather than // a file or pipe. Tests replace it. isTerminal = func(file *os.File) bool { info, err := file.Stat() return err == nil && info.Mode()&os.ModeCharDevice != 0 } ) // SetLevel sets the logging level without logging the change. func SetLevel(level slog.Level) { logLevel.Set(level) } // EnableDebug lowers the logging level to debug, if it isn't already, and // logs that it did. func EnableDebug() { if logLevel.Level() > slog.LevelDebug { logLevel.Set(slog.LevelDebug) slog.Debug("debug logging enabled") } } // LogToFile sends all logging to logfile as JSON, replacing the default slog // logger. The file is created if needed and appended to; any log file opened // by an earlier call is closed. func LogToFile(logfile string) error { closeErr := Shutdown() // Open the log file var err error fileLogger, err = os.OpenFile( filepath.Clean(logfile), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600, ) if err != nil { return errors.Join(err, closeErr) } opts := &slog.HandlerOptions{ Level: &logLevel, } handler := slog.NewJSONHandler(fileLogger, opts) logger := slog.New(handler) slog.SetDefault(logger) return closeErr } // Shutdown closes the log file opened by LogToFile, if any. func Shutdown() error { var err error = nil if fileLogger != nil { err = (*fileLogger).Close() fileLogger = nil } return err } // LogToTerminal sends all logging to stdout as text, replacing the default // slog logger. The text is coloured only on an interactive terminal, so a // pipe, a file or the systemd journal gets no escape codes. On an // interactive terminal, loggers derived from it report so to // WritesToTerminal. func LogToTerminal() { interactive := isTerminal(terminal) opts := &tint.Options{ Level: &logLevel, TimeFormat: "*", NoColor: !interactive, } handler := tint.NewTextHandler(terminal, opts) if interactive { handler = terminalHandler{ Handler: handler, } } slog.SetDefault(slog.New(handler)) } // WritesToTerminal reports whether handler writes to an interactive // terminal, where multi-line text (such as a trace) reads best printed as // it is with PrintToTerminal, rather than escaped into a field. func WritesToTerminal(handler slog.Handler) bool { _, ok := handler.(terminalHandler) return ok } // PrintToTerminal writes text to the terminal as it is, on lines of its own. // It is for detail that a handler would escape into one line; check // WritesToTerminal first. func PrintToTerminal(text string) { fmt.Fprintln(terminal, text) //nolint:errcheck // nothing to do if the terminal has gone } // ---------- terminalHandler ---------- // terminalHandler marks the handler LogToTerminal installs on an interactive // terminal. WithAttrs and WithGroup keep the mark, so a logger derived with // With still reports to WritesToTerminal. type terminalHandler struct { slog.Handler } // WithAttrs implements slog.Handler, keeping the terminal mark. func (h terminalHandler) WithAttrs(attrs []slog.Attr) slog.Handler { return terminalHandler{ Handler: h.Handler.WithAttrs(attrs), } } // WithGroup implements slog.Handler, keeping the terminal mark. func (h terminalHandler) WithGroup(name string) slog.Handler { return terminalHandler{ Handler: h.Handler.WithGroup(name), } } // IsJSONHandler reports whether handler writes JSON, as the file logger does. func IsJSONHandler(handler slog.Handler) bool { _, ok := handler.(*slog.JSONHandler) return ok } // IsFileLogger reports whether logging currently goes to a file. func IsFileLogger() bool { return fileLogger != nil } // IsDebugEnabled reports whether debug messages are logged. func IsDebugEnabled() bool { return logLevel.Level() <= slog.LevelDebug }