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.
This commit is contained in:
1 parent
0b5fde9563
commit
043e307663
49 files changed
+598
-248
No files matched your search
@@ -2,6 +2,7 @@ package logging
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -19,6 +20,14 @@ var (
|
||||
// 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.
|
||||
@@ -31,7 +40,7 @@ func SetLevel(level slog.Level) {
|
||||
func EnableDebug() {
|
||||
if logLevel.Level() > slog.LevelDebug {
|
||||
logLevel.Set(slog.LevelDebug)
|
||||
slog.Debug("Debug logging enabled")
|
||||
slog.Debug("debug logging enabled")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,15 +80,59 @@ func Shutdown() error {
|
||||
}
|
||||
|
||||
// LogToTerminal sends all logging to stdout as coloured text, replacing the
|
||||
// default slog logger.
|
||||
// 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(os.Stdout, opts)
|
||||
logger := slog.New(handler)
|
||||
slog.SetDefault(logger)
|
||||
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.
|
||||
|
||||
@@ -18,9 +18,11 @@ func saveGlobals(t *testing.T) {
|
||||
t.Helper()
|
||||
level := logLevel.Level()
|
||||
def := slog.Default()
|
||||
out, detect := terminal, isTerminal
|
||||
t.Cleanup(func() {
|
||||
logLevel.Set(level)
|
||||
slog.SetDefault(def)
|
||||
terminal, isTerminal = out, detect
|
||||
Shutdown()
|
||||
})
|
||||
}
|
||||
@@ -155,3 +157,52 @@ func TestLogToTerminal(t *testing.T) {
|
||||
t.Fatal("terminal handler ignores the package level")
|
||||
}
|
||||
}
|
||||
|
||||
// On an interactive terminal the handler, and loggers derived from it, report
|
||||
// that they write to the terminal; when stdout is a file or pipe they don't.
|
||||
func TestWritesToTerminal(t *testing.T) {
|
||||
for _, interactive := range []bool{true, false} {
|
||||
saveGlobals(t)
|
||||
isTerminal = func(*os.File) bool {
|
||||
return interactive
|
||||
}
|
||||
|
||||
LogToTerminal()
|
||||
|
||||
derived := slog.Default().With("component", "test").WithGroup("g")
|
||||
for name, h := range map[string]slog.Handler{
|
||||
"default": slog.Default().Handler(),
|
||||
"derived": derived.Handler(),
|
||||
} {
|
||||
if got := WritesToTerminal(h); got != interactive {
|
||||
t.Errorf(
|
||||
"interactive %v, %s: WritesToTerminal = %v",
|
||||
interactive, name, got,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if WritesToTerminal(slog.DiscardHandler) {
|
||||
t.Error("another handler reported as the terminal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintToTerminal(t *testing.T) {
|
||||
saveGlobals(t)
|
||||
out, err := os.Create(filepath.Join(t.TempDir(), "terminal"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer out.Close()
|
||||
terminal = out
|
||||
|
||||
PrintToTerminal("line one\nline two")
|
||||
|
||||
got, err := os.ReadFile(out.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "line one\nline two\n" {
|
||||
t.Errorf("printed %q; want the text as it is, then a newline", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user