feat(diary): component loggers through a redacting console and daily stack
- New diary module: For, Bootstrap, Setup, SetVerbosity, Reset, Close, ParseConfig - Proxy component handler safe in package vars; styled console handler in bonfire's palette - Daily writer: one file per day, 0640, lazy open, retention, disables itself on open failure - Every channel leaf wrapped by sunscreen.Wrap; stack via slog.NewMultiHandler - bonfire: ColorMode, Palette, DefaultPalette, DetectProfile (lipgloss v2, colorprofile) - Generated main runs diary.Bootstrap first and diary.Setup after compass.Load - Ignore /storage/ and /examples/hello/storage/
This commit is contained in:
257
modules/diary/console.go
Normal file
257
modules/diary/console.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package diary
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
)
|
||||
|
||||
// componentWidth is the column width of the component prefix.
|
||||
const componentWidth = 10
|
||||
|
||||
// ConsoleOptions configures NewConsoleHandler.
|
||||
type ConsoleOptions struct {
|
||||
// Level is the minimum level; nil means info.
|
||||
Level slog.Leveler
|
||||
// Profile is the color profile applied to the output; NoTTY writes no
|
||||
// escape codes at all.
|
||||
Profile colorprofile.Profile
|
||||
// AddSource appends the caller's file:line to each line.
|
||||
AddSource bool
|
||||
}
|
||||
|
||||
// NewConsoleHandler returns a slog.Handler that writes one styled line per
|
||||
// record to w in bonfire's palette: time, level, the component attribute as
|
||||
// a fixed-width prefix, the message, then key=value attributes with groups
|
||||
// as dotted key prefixes. Control characters other than tab are removed from
|
||||
// the message and attribute values, so logged data cannot inject terminal
|
||||
// escape sequences. Each line is written with one Write call, serialized
|
||||
// across the handler and its WithAttrs and WithGroup clones.
|
||||
func NewConsoleHandler(w io.Writer, opts ConsoleOptions) slog.Handler {
|
||||
source := new(atomic.Bool)
|
||||
source.Store(opts.AddSource)
|
||||
return newConsoleHandler(w, opts, source)
|
||||
}
|
||||
|
||||
func newConsoleHandler(w io.Writer, opts ConsoleOptions, source *atomic.Bool) *consoleHandler {
|
||||
level := opts.Level
|
||||
if level == nil {
|
||||
level = slog.LevelInfo
|
||||
}
|
||||
return &consoleHandler{
|
||||
out: &colorprofile.Writer{Forward: w, Profile: opts.Profile},
|
||||
mu: new(sync.Mutex),
|
||||
level: level,
|
||||
source: source,
|
||||
palette: bonfire.DefaultPalette(),
|
||||
}
|
||||
}
|
||||
|
||||
type consoleAttr struct {
|
||||
key string
|
||||
value string
|
||||
}
|
||||
|
||||
type consoleHandler struct {
|
||||
out io.Writer
|
||||
mu *sync.Mutex
|
||||
level slog.Leveler
|
||||
source *atomic.Bool
|
||||
palette bonfire.Palette
|
||||
component string
|
||||
attrs []consoleAttr
|
||||
groups []string
|
||||
}
|
||||
|
||||
func (h *consoleHandler) Enabled(_ context.Context, l slog.Level) bool {
|
||||
return l >= h.level.Level()
|
||||
}
|
||||
|
||||
func (h *consoleHandler) clone() *consoleHandler {
|
||||
c := *h
|
||||
c.attrs = h.attrs[:len(h.attrs):len(h.attrs)]
|
||||
c.groups = h.groups[:len(h.groups):len(h.groups)]
|
||||
return &c
|
||||
}
|
||||
|
||||
func (h *consoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
if len(attrs) == 0 {
|
||||
return h
|
||||
}
|
||||
c := h.clone()
|
||||
prefix := groupPrefix(c.groups)
|
||||
for _, a := range attrs {
|
||||
if len(c.groups) == 0 && a.Key == ComponentKey && a.Value.Kind() == slog.KindString {
|
||||
c.component = a.Value.String()
|
||||
continue
|
||||
}
|
||||
c.attrs = appendAttr(c.attrs, prefix, a)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (h *consoleHandler) WithGroup(name string) slog.Handler {
|
||||
if name == "" {
|
||||
return h
|
||||
}
|
||||
c := h.clone()
|
||||
c.groups = append(c.groups, name)
|
||||
return c
|
||||
}
|
||||
|
||||
func (h *consoleHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
component := h.component
|
||||
prefix := groupPrefix(h.groups)
|
||||
var recAttrs []consoleAttr
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
if len(h.groups) == 0 && a.Key == ComponentKey && a.Value.Kind() == slog.KindString {
|
||||
component = a.Value.String()
|
||||
return true
|
||||
}
|
||||
recAttrs = appendAttr(recAttrs, prefix, a)
|
||||
return true
|
||||
})
|
||||
|
||||
p := h.palette
|
||||
var b strings.Builder
|
||||
if !r.Time.IsZero() {
|
||||
b.WriteString(p.Muted.Render(r.Time.Format(time.TimeOnly)))
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
label, style := levelLabel(r.Level, p)
|
||||
b.WriteString(style.Render(label))
|
||||
b.WriteString(strings.Repeat(" ", max(0, 5-len(label))+1))
|
||||
name := sanitize(component)
|
||||
if name != "" {
|
||||
b.WriteString(p.Header.Render(name))
|
||||
}
|
||||
b.WriteString(strings.Repeat(" ", max(0, componentWidth-len(name))+1))
|
||||
b.WriteString(sanitize(r.Message))
|
||||
for _, list := range [][]consoleAttr{h.attrs, recAttrs} {
|
||||
for _, a := range list {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(p.Muted.Render(a.key + "="))
|
||||
b.WriteString(a.value)
|
||||
}
|
||||
}
|
||||
if h.source != nil && h.source.Load() && r.PC != 0 {
|
||||
frame, _ := runtime.CallersFrames([]uintptr{r.PC}).Next()
|
||||
if frame.File != "" {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(p.Muted.Render(filepath.Base(frame.File) + ":" + strconv.Itoa(frame.Line)))
|
||||
}
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
_, err := io.WriteString(h.out, b.String())
|
||||
return err
|
||||
}
|
||||
|
||||
func levelLabel(l slog.Level, p bonfire.Palette) (string, lipgloss.Style) {
|
||||
switch {
|
||||
case l < slog.LevelInfo:
|
||||
return l.String(), p.Muted
|
||||
case l < slog.LevelWarn:
|
||||
return l.String(), p.Current
|
||||
case l < slog.LevelError:
|
||||
return l.String(), p.Warning
|
||||
default:
|
||||
return l.String(), p.ErrorBold
|
||||
}
|
||||
}
|
||||
|
||||
func groupPrefix(groups []string) string {
|
||||
if len(groups) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(groups, ".") + "."
|
||||
}
|
||||
|
||||
// appendAttr flattens a into key=value pairs: empty attributes are skipped,
|
||||
// groups add their key as a dotted prefix, and groups with an empty key are
|
||||
// inlined (testing/slogtest semantics).
|
||||
func appendAttr(out []consoleAttr, prefix string, a slog.Attr) []consoleAttr {
|
||||
a.Value = a.Value.Resolve()
|
||||
if a.Equal(slog.Attr{}) {
|
||||
return out
|
||||
}
|
||||
if a.Value.Kind() == slog.KindGroup {
|
||||
group := a.Value.Group()
|
||||
if len(group) == 0 {
|
||||
return out
|
||||
}
|
||||
inner := prefix
|
||||
if a.Key != "" {
|
||||
inner = prefix + a.Key + "."
|
||||
}
|
||||
for _, g := range group {
|
||||
out = appendAttr(out, inner, g)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return append(out, consoleAttr{key: sanitize(prefix + a.Key), value: formatValue(a.Value)})
|
||||
}
|
||||
|
||||
func formatValue(v slog.Value) string {
|
||||
var s string
|
||||
switch v.Kind() {
|
||||
case slog.KindString:
|
||||
s = v.String()
|
||||
case slog.KindTime:
|
||||
s = v.Time().Format(time.RFC3339)
|
||||
case slog.KindAny:
|
||||
if err, ok := v.Any().(error); ok {
|
||||
s = err.Error()
|
||||
} else {
|
||||
s = fmt.Sprintf("%+v", v.Any())
|
||||
}
|
||||
default:
|
||||
s = v.String()
|
||||
}
|
||||
s = sanitize(s)
|
||||
if s == "" || strings.ContainsAny(s, " \t\"=") {
|
||||
return strconv.Quote(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// sanitize removes control characters other than tab; line breaks become
|
||||
// spaces so a multi-line value stays on one line.
|
||||
func sanitize(s string) string {
|
||||
clean := true
|
||||
for _, r := range s {
|
||||
if r != '\t' && unicode.IsControl(r) {
|
||||
clean = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if clean {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '\n' || r == '\r':
|
||||
b.WriteByte(' ')
|
||||
case r != '\t' && unicode.IsControl(r):
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
Reference in New Issue
Block a user