template/pkg/logger/handler.go
djmil 7bc91b0890 pkg/logger: replace NewDevelopment with NewCLI(level, debugFile)
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>
2026-06-03 16:48:34 +00:00

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())
}
}