Files
wild-cloud/api/internal/logging/console.go
2026-05-24 20:54:13 +00:00

139 lines
2.8 KiB
Go

package logging
import (
"context"
"fmt"
"io"
"log/slog"
"slices"
"sync"
)
// ANSI color codes
const (
dim = "\033[2m"
red = "\033[31m"
yellow = "\033[33m"
cyan = "\033[36m"
reset = "\033[0m"
)
// ConsoleHandler formats log output for human readability on terminals.
// It produces compact, color-coded lines:
//
// 20:15:54 INF daemon started addr=:5055
// 20:15:54 ERR backup failed component=backup error="connection refused"
type ConsoleHandler struct {
w io.Writer
level slog.Leveler
attrs []slog.Attr
mu *sync.Mutex
}
// NewConsoleHandler creates a handler that writes human-friendly colored logs.
func NewConsoleHandler(w io.Writer, opts *slog.HandlerOptions) *ConsoleHandler {
level := slog.LevelInfo
if opts != nil && opts.Level != nil {
level = opts.Level.Level()
}
return &ConsoleHandler{
w: w,
level: level,
mu: &sync.Mutex{},
}
}
func (h *ConsoleHandler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.level.Level()
}
func (h *ConsoleHandler) Handle(_ context.Context, r slog.Record) error {
// Time
buf := []byte(dim + r.Time.Format("15:04:05") + reset + " ")
// Level badge
switch {
case r.Level >= slog.LevelError:
buf = append(buf, red+"ERR"+reset+" "...)
case r.Level >= slog.LevelWarn:
buf = append(buf, yellow+"WRN"+reset+" "...)
default:
buf = append(buf, cyan+"INF"+reset+" "...)
}
// Message
buf = append(buf, r.Message...)
// Pre-set attrs (from slog.With)
for _, a := range h.attrs {
buf = appendAttr(buf, a)
}
// Inline attrs
r.Attrs(func(a slog.Attr) bool {
buf = appendAttr(buf, a)
return true
})
buf = append(buf, '\n')
h.mu.Lock()
defer h.mu.Unlock()
_, err := h.w.Write(buf)
return err
}
func (h *ConsoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &ConsoleHandler{
w: h.w,
level: h.level,
attrs: append(slices.Clone(h.attrs), attrs...),
mu: h.mu,
}
}
func (h *ConsoleHandler) WithGroup(name string) slog.Handler {
// Groups are rare in this codebase; treat as a prefixed attr set
return &ConsoleHandler{
w: h.w,
level: h.level,
attrs: append(slices.Clone(h.attrs), slog.String("group", name)),
mu: h.mu,
}
}
func appendAttr(buf []byte, a slog.Attr) []byte {
if a.Equal(slog.Attr{}) {
return buf
}
v := a.Value.Resolve()
buf = append(buf, ' ')
buf = append(buf, dim...)
buf = append(buf, a.Key...)
buf = append(buf, '=')
buf = append(buf, reset...)
s := v.String()
if needsQuote(s) {
buf = append(buf, fmt.Sprintf("%q", s)...)
} else {
buf = append(buf, s...)
}
return buf
}
func needsQuote(s string) bool {
if s == "" {
return true
}
for _, c := range s {
if c <= ' ' || c == '"' || c == '\\' {
return true
}
}
return false
}
// Verify interface compliance at compile time.
var _ slog.Handler = (*ConsoleHandler)(nil)