Files
job-search/internal/logging/logging.go
T
bwilliams 043e307663 Logging: structured slog messages throughout, and WithLogger options so callers choose the logger
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.
2026-10-15 16:10:52 +01:00

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
}