Two modes for interactive CLI use — driven by debugFile presence: - Normal (debugFile=""): human text on screen, INFO and above. - Debug (debugFile set): same screen + full JSON trace to file. Auto-detects TTY; falls back to 12-factor JSON when piped/redirected. IsInteractive() exposes TTY detection for call sites that need it. Terminal format: INFO has no prefix (program's normal voice); WARN prints "warning: …"; ERROR prints "error: …"; DEBUG "debug: …". Breaking: NewDevelopment removed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
184 lines
4.2 KiB
Go
184 lines
4.2 KiB
Go
package logger
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// isTerminal reports whether f is connected to a character device (terminal).
|
|
func isTerminal(f *os.File) bool {
|
|
info, err := f.Stat()
|
|
return err == nil && info.Mode()&os.ModeCharDevice != 0
|
|
}
|
|
|
|
// humanHandler writes human-readable log lines to w with no timestamp.
|
|
//
|
|
// Format:
|
|
//
|
|
// INFO: message key=val … (no prefix — the program's normal voice)
|
|
// WARN: warning: message key=val …
|
|
// ERROR: error: message key=val …
|
|
// DEBUG: debug: message key=val …
|
|
type humanHandler struct {
|
|
w io.Writer
|
|
level slog.Level
|
|
mu sync.Mutex
|
|
attrs []slog.Attr
|
|
prefix string // dot-separated group prefix for attribute keys
|
|
}
|
|
|
|
func newHumanHandler(w io.Writer, level slog.Level) *humanHandler {
|
|
return &humanHandler{w: w, level: level}
|
|
}
|
|
|
|
func (h *humanHandler) Enabled(_ context.Context, level slog.Level) bool {
|
|
return level >= h.level
|
|
}
|
|
|
|
func (h *humanHandler) Handle(_ context.Context, r slog.Record) error {
|
|
var b strings.Builder
|
|
b.WriteString(humanLevel(r.Level))
|
|
b.WriteString(r.Message)
|
|
|
|
for _, a := range h.attrs {
|
|
appendAttr(&b, h.prefix, a)
|
|
}
|
|
r.Attrs(func(a slog.Attr) bool {
|
|
appendAttr(&b, h.prefix, a)
|
|
return true
|
|
})
|
|
b.WriteByte('\n')
|
|
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
_, err := io.WriteString(h.w, b.String())
|
|
return err
|
|
}
|
|
|
|
func (h *humanHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
|
merged := make([]slog.Attr, len(h.attrs)+len(attrs))
|
|
copy(merged, h.attrs)
|
|
copy(merged[len(h.attrs):], attrs)
|
|
return &humanHandler{w: h.w, level: h.level, attrs: merged, prefix: h.prefix}
|
|
}
|
|
|
|
func (h *humanHandler) WithGroup(name string) slog.Handler {
|
|
if name == "" {
|
|
return h
|
|
}
|
|
prefix := h.prefix
|
|
if prefix != "" {
|
|
prefix += "."
|
|
}
|
|
return &humanHandler{w: h.w, level: h.level, attrs: h.attrs, prefix: prefix + name}
|
|
}
|
|
|
|
// multiHandler fans a single log record out to multiple handlers.
|
|
// Each sub-handler's own Enabled filter is respected independently,
|
|
// so different handlers can capture different level ranges.
|
|
type multiHandler []slog.Handler
|
|
|
|
func (m multiHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
|
for _, h := range m {
|
|
if h.Enabled(ctx, level) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m multiHandler) Handle(ctx context.Context, r slog.Record) error {
|
|
var firstErr error
|
|
for _, h := range m {
|
|
if h.Enabled(ctx, r.Level) {
|
|
if err := h.Handle(ctx, r); err != nil && firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|
|
|
|
func (m multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
|
handlers := make(multiHandler, len(m))
|
|
for i, h := range m {
|
|
handlers[i] = h.WithAttrs(attrs)
|
|
}
|
|
return handlers
|
|
}
|
|
|
|
func (m multiHandler) WithGroup(name string) slog.Handler {
|
|
handlers := make(multiHandler, len(m))
|
|
for i, h := range m {
|
|
handlers[i] = h.WithGroup(name)
|
|
}
|
|
return handlers
|
|
}
|
|
|
|
// ── formatting helpers ────────────────────────────────────────────────────────
|
|
|
|
func humanLevel(level slog.Level) string {
|
|
switch {
|
|
case level >= slog.LevelError:
|
|
return "error: "
|
|
case level >= slog.LevelWarn:
|
|
return "warning: "
|
|
case level >= slog.LevelInfo:
|
|
return "" // INFO needs no label — it is the program's normal voice
|
|
default:
|
|
return "debug: "
|
|
}
|
|
}
|
|
|
|
func appendAttr(b *strings.Builder, prefix string, a slog.Attr) {
|
|
a.Value = a.Value.Resolve()
|
|
if a.Value.Kind() == slog.KindGroup {
|
|
// inline unnamed groups; named groups become a key prefix
|
|
sub := prefix
|
|
if a.Key != "" {
|
|
if sub != "" {
|
|
sub += "."
|
|
}
|
|
sub += a.Key
|
|
}
|
|
for _, ga := range a.Value.Group() {
|
|
appendAttr(b, sub, ga)
|
|
}
|
|
return
|
|
}
|
|
if a.Key == "" {
|
|
return
|
|
}
|
|
key := a.Key
|
|
if prefix != "" {
|
|
key = prefix + "." + key
|
|
}
|
|
b.WriteByte(' ')
|
|
b.WriteString(key)
|
|
b.WriteByte('=')
|
|
appendValue(b, a.Value)
|
|
}
|
|
|
|
func appendValue(b *strings.Builder, v slog.Value) {
|
|
switch v.Kind() {
|
|
case slog.KindString:
|
|
s := v.String()
|
|
if strings.ContainsAny(s, " \t\n\"=") {
|
|
b.WriteString(strconv.Quote(s))
|
|
} else {
|
|
b.WriteString(s)
|
|
}
|
|
case slog.KindTime:
|
|
b.WriteString(v.Time().Format(time.RFC3339))
|
|
default:
|
|
fmt.Fprint(b, v.Any())
|
|
}
|
|
}
|