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>
This commit is contained in:
parent
1054f3fd6b
commit
7bc91b0890
@ -19,7 +19,8 @@ type AppConfig struct {
|
|||||||
|
|
||||||
// LoggerConfig controls logging behavior.
|
// LoggerConfig controls logging behavior.
|
||||||
type LoggerConfig struct {
|
type LoggerConfig struct {
|
||||||
Level string // debug | info | warn | error
|
Level string // debug | info | warn | error
|
||||||
|
DebugFile string // non-empty enables debug mode: writes full JSON trace to this path
|
||||||
}
|
}
|
||||||
|
|
||||||
// Greeter config for internal/greeter/Service.
|
// Greeter config for internal/greeter/Service.
|
||||||
@ -41,6 +42,7 @@ func parseArgs() *Config {
|
|||||||
port := flag.Int("port", 8080, "listen port")
|
port := flag.Int("port", 8080, "listen port")
|
||||||
env := flag.String("env", "dev", "environment: dev | staging | prod")
|
env := flag.String("env", "dev", "environment: dev | staging | prod")
|
||||||
level := flag.String("log-level", "info", "log level: debug | info | warn | error")
|
level := flag.String("log-level", "info", "log level: debug | info | warn | error")
|
||||||
|
debugLog := flag.String("debug-log", "", "write full debug trace to file (enables debug mode)")
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@ -50,7 +52,8 @@ func parseArgs() *Config {
|
|||||||
Env: *env,
|
Env: *env,
|
||||||
},
|
},
|
||||||
Logger: LoggerConfig{
|
Logger: LoggerConfig{
|
||||||
Level: *level,
|
Level: *level,
|
||||||
|
DebugFile: *debugLog,
|
||||||
},
|
},
|
||||||
Greeter: GreeterConfig{
|
Greeter: GreeterConfig{
|
||||||
Name: *name,
|
Name: *name,
|
||||||
|
|||||||
@ -22,11 +22,12 @@ type app struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newApp(cfg *Config) *app {
|
func newApp(cfg *Config) *app {
|
||||||
var log *logger.Logger
|
// NewCLI auto-detects: human text on a terminal, JSON when piped.
|
||||||
|
// Pass -debug-log FILE to enable debug mode (writes full trace to file).
|
||||||
|
log := logger.NewCLI(cfg.Logger.Level, cfg.Logger.DebugFile).Expect("create logger")
|
||||||
|
log.Debug("config", "port", cfg.App.Port, "level", cfg.Logger.Level, "env", cfg.App.Env)
|
||||||
if cfg.App.Env == "dev" {
|
if cfg.App.Env == "dev" {
|
||||||
log = logger.NewDevelopment()
|
log.Warn("dev mode — not for production")
|
||||||
} else {
|
|
||||||
log = logger.New(cfg.Logger.Level).Expect("create logger") // might fail dramatically
|
|
||||||
}
|
}
|
||||||
return &app{
|
return &app{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
@ -54,11 +55,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) run() {
|
func (a *app) run() {
|
||||||
// High level business logic goes here
|
a.log.Debug("processing", "name", a.cfg.Greeter.Name)
|
||||||
a.showGreeting(a.cfg.Greeter.Name)
|
a.showGreeting(a.cfg.Greeter.Name)
|
||||||
|
|
||||||
// Human readable messages.
|
|
||||||
// Use logs for presenting technical data in machine friendly format.
|
|
||||||
fmt.Printf("TODO: implement listening on port %d\n", a.cfg.App.Port)
|
fmt.Printf("TODO: implement listening on port %d\n", a.cfg.App.Port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
183
pkg/logger/handler.go
Normal file
183
pkg/logger/handler.go
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
121
pkg/logger/handler_test.go
Normal file
121
pkg/logger/handler_test.go
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHumanLevel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
level slog.Level
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{slog.LevelDebug, "debug: "},
|
||||||
|
{slog.LevelInfo, ""},
|
||||||
|
{slog.LevelWarn, "warning: "},
|
||||||
|
{slog.LevelError, "error: "},
|
||||||
|
{slog.LevelError + 4, "error: "},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
if got := humanLevel(tc.level); got != tc.want {
|
||||||
|
t.Errorf("humanLevel(%v) = %q, want %q", tc.level, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHumanHandlerInfoNoPrefix(t *testing.T) {
|
||||||
|
var buf strings.Builder
|
||||||
|
h := newHumanHandler(&buf, slog.LevelDebug)
|
||||||
|
slog.New(h).Info("hello world")
|
||||||
|
|
||||||
|
if got := strings.TrimRight(buf.String(), "\n"); got != "hello world" {
|
||||||
|
t.Errorf("got %q, want %q", got, "hello world")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHumanHandlerPrefixes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
fn func(*slog.Logger, string, ...any)
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{(*slog.Logger).Warn, "warning: "},
|
||||||
|
{(*slog.Logger).Error, "error: "},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
var buf strings.Builder
|
||||||
|
log := slog.New(newHumanHandler(&buf, slog.LevelDebug))
|
||||||
|
tc.fn(log, "msg")
|
||||||
|
if !strings.HasPrefix(buf.String(), tc.want) {
|
||||||
|
t.Errorf("got %q, want prefix %q", buf.String(), tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHumanHandlerAttrs(t *testing.T) {
|
||||||
|
var buf strings.Builder
|
||||||
|
slog.New(newHumanHandler(&buf, slog.LevelDebug)).Info("started", "port", 8080)
|
||||||
|
|
||||||
|
want := "started port=8080\n"
|
||||||
|
if got := buf.String(); got != want {
|
||||||
|
t.Errorf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHumanHandlerStringQuoting(t *testing.T) {
|
||||||
|
var buf strings.Builder
|
||||||
|
slog.New(newHumanHandler(&buf, slog.LevelDebug)).Warn("check", "msg", "Hello, Gopher!")
|
||||||
|
|
||||||
|
if !strings.Contains(buf.String(), `msg="Hello, Gopher!"`) {
|
||||||
|
t.Errorf("expected quoted value in %q", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHumanHandlerWithAttrs(t *testing.T) {
|
||||||
|
var buf strings.Builder
|
||||||
|
h := newHumanHandler(&buf, slog.LevelDebug).WithAttrs([]slog.Attr{slog.String("rid", "abc")})
|
||||||
|
slog.New(h).Info("request")
|
||||||
|
|
||||||
|
if !strings.Contains(buf.String(), "rid=abc") {
|
||||||
|
t.Errorf("pre-set attr missing: %q", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHumanHandlerLevelFiltering(t *testing.T) {
|
||||||
|
var buf strings.Builder
|
||||||
|
log := slog.New(newHumanHandler(&buf, slog.LevelWarn))
|
||||||
|
log.Info("dropped")
|
||||||
|
log.Warn("kept")
|
||||||
|
|
||||||
|
if strings.Contains(buf.String(), "dropped") {
|
||||||
|
t.Error("INFO line should have been filtered at WARN level")
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), "kept") {
|
||||||
|
t.Error("WARN line should appear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiHandlerFanOut(t *testing.T) {
|
||||||
|
var screen, file strings.Builder
|
||||||
|
// screen: INFO+, file: DEBUG+
|
||||||
|
m := multiHandler{
|
||||||
|
newHumanHandler(&screen, slog.LevelInfo),
|
||||||
|
slog.NewJSONHandler(&file, &slog.HandlerOptions{Level: slog.LevelDebug}),
|
||||||
|
}
|
||||||
|
log := slog.New(m)
|
||||||
|
log.Debug("trace", "k", "v")
|
||||||
|
log.Info("event", "k", "v")
|
||||||
|
|
||||||
|
if strings.Contains(screen.String(), "trace") {
|
||||||
|
t.Error("DEBUG should not appear on screen")
|
||||||
|
}
|
||||||
|
if !strings.Contains(screen.String(), "event") {
|
||||||
|
t.Error("INFO should appear on screen")
|
||||||
|
}
|
||||||
|
if !strings.Contains(file.String(), "trace") {
|
||||||
|
t.Error("DEBUG should appear in file")
|
||||||
|
}
|
||||||
|
if !strings.Contains(file.String(), "event") {
|
||||||
|
t.Error("INFO should appear in file")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,16 +1,26 @@
|
|||||||
// Package logger wraps log/slog with a thin, ergonomic API.
|
// Package logger wraps log/slog with a thin, ergonomic API.
|
||||||
//
|
//
|
||||||
// Per the Twelve-Factor App (factor XI), the application writes structured log
|
// Design principle: call sites express what is logged, not how it reaches the
|
||||||
// events to stderr and never manages log files or routing itself. The execution
|
// reader. The constructor picks the right format for the execution environment;
|
||||||
// environment (shell, systemd, Docker) is responsible for capturing and storing
|
// application code never changes.
|
||||||
// the stream. Human-readable output belongs on stdout via fmt.Print*.
|
|
||||||
//
|
//
|
||||||
// Typical use:
|
// Per the Twelve-Factor App (factor XI), logs are written to stderr as an
|
||||||
|
// unbuffered stream. The execution environment (shell, systemd, Docker, k8s)
|
||||||
|
// routes and stores the stream.
|
||||||
//
|
//
|
||||||
// log := logger.New("info").Expect("create logger")
|
// Two constructors write to stderr:
|
||||||
|
//
|
||||||
|
// - New(level) — JSON, 12-factor compatible; use for headless services.
|
||||||
|
// - NewCLI(level, debugFile) — auto-detects: human text on a terminal, JSON when piped.
|
||||||
|
// Providing a non-empty debugFile enables debug mode: DEBUG writes to that
|
||||||
|
// file as JSON; the screen always shows INFO and above.
|
||||||
|
//
|
||||||
|
// Typical use in a CLI application:
|
||||||
|
//
|
||||||
|
// log := logger.NewCLI("info", "").Expect("create logger")
|
||||||
// log.Info("server started", "port", 8080)
|
// log.Info("server started", "port", 8080)
|
||||||
//
|
//
|
||||||
// // child logger for request-scoped fields that repeat across many lines:
|
// // child logger with request-scoped fields:
|
||||||
// req := log.WithField("request_id", rid)
|
// req := log.WithField("request_id", rid)
|
||||||
// req.Info("start")
|
// req.Info("start")
|
||||||
// req.Info("end")
|
// req.Info("end")
|
||||||
@ -38,6 +48,7 @@ type Logger struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New creates a JSON logger writing to stderr for the given level string.
|
// New creates a JSON logger writing to stderr for the given level string.
|
||||||
|
// Use for headless services; prefer NewCLI for programs invoked by a human.
|
||||||
// Valid levels: debug, info, warn, error.
|
// Valid levels: debug, info, warn, error.
|
||||||
func New(level string) result.Expect[*Logger] {
|
func New(level string) result.Expect[*Logger] {
|
||||||
lvl := parseLevel(level)
|
lvl := parseLevel(level)
|
||||||
@ -45,18 +56,60 @@ func New(level string) result.Expect[*Logger] {
|
|||||||
return result.Errw[*Logger](lvl.Err(), "parse log level")
|
return result.Errw[*Logger](lvl.Err(), "parse log level")
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lvl.Value()})
|
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lvl.Value()})
|
||||||
logger := &Logger{slog.New(handler)}
|
return result.Ok(&Logger{slog.New(h)})
|
||||||
|
|
||||||
return result.Ok(logger)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDevelopment creates a human-friendly text logger writing to stderr at debug level.
|
// NewCLI creates a logger for programs invoked by a human operator.
|
||||||
// Use this in local dev; prefer New() in any deployed environment.
|
//
|
||||||
func NewDevelopment() *Logger {
|
// When stderr is a terminal it operates in one of two modes:
|
||||||
h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})
|
// - Normal (debugFile == ""): human-readable text, INFO and above on screen.
|
||||||
|
// - Debug (debugFile != ""): same screen output, plus a full JSON trace of
|
||||||
|
// DEBUG and above written to debugFile for post-run investigation.
|
||||||
|
//
|
||||||
|
// When stderr is not a terminal (piped or redirected), NewCLI behaves like
|
||||||
|
// New: JSON at the given level to stderr; debugFile is ignored.
|
||||||
|
//
|
||||||
|
// Passing a level above "info" when running in a terminal is unusual —
|
||||||
|
// NewCLI will log a warning because interactive mode always shows INFO and above.
|
||||||
|
//
|
||||||
|
// Valid levels: debug, info, warn, error.
|
||||||
|
func NewCLI(level, debugFile string) result.Expect[*Logger] {
|
||||||
|
lvl := parseLevel(level)
|
||||||
|
if lvl.Err() != nil {
|
||||||
|
return result.Errw[*Logger](lvl.Err(), "parse log level")
|
||||||
|
}
|
||||||
|
|
||||||
return &Logger{slog.New(h)}
|
if !isTerminal(os.Stderr) {
|
||||||
|
h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: lvl.Value()})
|
||||||
|
return result.Ok(&Logger{slog.New(h)})
|
||||||
|
}
|
||||||
|
|
||||||
|
screen := newHumanHandler(os.Stderr, slog.LevelInfo)
|
||||||
|
|
||||||
|
var h slog.Handler = screen
|
||||||
|
if debugFile != "" {
|
||||||
|
f, err := os.Create(debugFile)
|
||||||
|
if err != nil {
|
||||||
|
return result.Errw[*Logger](err, "create debug log")
|
||||||
|
}
|
||||||
|
dump := slog.NewJSONHandler(f, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||||
|
h = multiHandler{screen, dump}
|
||||||
|
}
|
||||||
|
|
||||||
|
log := &Logger{slog.New(h)}
|
||||||
|
if lvl.Value() > slog.LevelInfo {
|
||||||
|
log.Warn("log level set above INFO — interactive mode always shows INFO and above",
|
||||||
|
"requested", level)
|
||||||
|
}
|
||||||
|
return result.Ok(log)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsInteractive reports whether stderr is attached to a terminal.
|
||||||
|
// Use this when the application itself needs to adjust behavior based on
|
||||||
|
// whether a human operator is watching (e.g. progress bars, prompts).
|
||||||
|
func IsInteractive() bool {
|
||||||
|
return isTerminal(os.Stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWriter creates a JSON logger writing to w. Intended for tests that need to
|
// NewWriter creates a JSON logger writing to w. Intended for tests that need to
|
||||||
@ -67,9 +120,8 @@ func NewWriter(w io.Writer, level string) result.Expect[*Logger] {
|
|||||||
return result.Errw[*Logger](lvl.Err(), "parse log level")
|
return result.Errw[*Logger](lvl.Err(), "parse log level")
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: lvl.Value()})
|
h := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: lvl.Value()})
|
||||||
|
return result.Ok(&Logger{slog.New(h)})
|
||||||
return result.Ok(&Logger{slog.New(handler)})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewNop returns a no-op logger. Useful in tests that don't care about logs.
|
// NewNop returns a no-op logger. Useful in tests that don't care about logs.
|
||||||
@ -90,7 +142,6 @@ func (l *Logger) WithFields(fields map[string]any) *Logger {
|
|||||||
for k, v := range fields {
|
for k, v := range fields {
|
||||||
args = append(args, k, v)
|
args = append(args, k, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Logger{l.Logger.With(args...)}
|
return &Logger{l.Logger.With(args...)}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,6 +152,5 @@ func parseLevel(level string) result.Expect[slog.Level] {
|
|||||||
if err := lvl.UnmarshalText([]byte(level)); err != nil {
|
if err := lvl.UnmarshalText([]byte(level)); err != nil {
|
||||||
return result.Errw[slog.Level](err, "unknown level (use debug|info|warn|error)")
|
return result.Errw[slog.Level](err, "unknown level (use debug|info|warn|error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.Ok(lvl)
|
return result.Ok(lvl)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,31 @@ import (
|
|||||||
"gitea.djmil.dev/go/template/pkg/testutil"
|
"gitea.djmil.dev/go/template/pkg/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestNewCLI(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
level string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{level: "debug"},
|
||||||
|
{level: "info"},
|
||||||
|
{level: "warn"},
|
||||||
|
{level: "error"},
|
||||||
|
{level: "invalid", wantErr: true},
|
||||||
|
{level: "", wantErr: true},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.level, func(t *testing.T) {
|
||||||
|
// In tests stderr is not a terminal — NewCLI uses JSON path.
|
||||||
|
r := logger.NewCLI(tc.level, "")
|
||||||
|
if tc.wantErr {
|
||||||
|
testutil.ResultErr(t, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
testutil.ResultOkNotNil(t, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNew(t *testing.T) {
|
func TestNew(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
level string
|
level string
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user