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:
Jakub Zych
2026-10-21 11:41:48 +02:00
parent e6039b445f
commit e903cc32be
18 changed files with 1676 additions and 23 deletions

257
modules/diary/console.go Normal file
View 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()
}