Log messages are lower-case event names with their details as key/value
attributes ("error" for errors), in place of formatted strings.
Loggable package take a WithLogger option as part of their "New*" call,
defaulting to slog.Default(). Each tags its lines with its `component`.
Some debug loggers print directly rather than using slog when in
terminal logging mode.
153 lines
4.1 KiB
Go
153 lines
4.1 KiB
Go
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 coloured text, replacing the
|
|
// default slog logger. When stdout is an interactive terminal, loggers
|
|
// derived from it report so to WritesToTerminal.
|
|
func LogToTerminal() {
|
|
opts := &tint.Options{
|
|
Level: &logLevel,
|
|
TimeFormat: "*",
|
|
}
|
|
handler := tint.NewTextHandler(terminal, opts)
|
|
if isTerminal(terminal) {
|
|
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 h writes JSON, as the file logger does.
|
|
func IsJSONHandler(h slog.Handler) bool {
|
|
_, ok := h.(*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
|
|
}
|