Files
summercms/modules/diary/channels.go
Jakub Zych e903cc32be 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/
2026-10-21 11:41:48 +02:00

158 lines
4.2 KiB
Go

package diary
import (
"context"
"fmt"
"io"
"log/slog"
"path/filepath"
"sync/atomic"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/sunscreen"
"github.com/charmbracelet/colorprofile"
)
// noFloor is the level of a channel without its own level: every record the
// component floors let through reaches it.
const noFloor = slog.Level(-1 << 30)
// consoleLevel is a console channel's runtime level: base is the configured
// level, which quiet raises to warn.
type consoleLevel struct {
lv *slog.LevelVar
base slog.Level
}
func (c consoleLevel) apply(quiet bool) {
l := c.base
if quiet && l < slog.LevelWarn {
l = slog.LevelWarn
}
c.lv.Set(l)
}
// channels is the built handler tree of one configuration.
type channels struct {
root slog.Handler
consoles []consoleLevel
closers []io.Closer
source *atomic.Bool
}
type channelBuilder struct {
cfg Config
opts Options
out *channels
visiting map[string]bool
warnTo slog.Handler
}
// buildChannels builds the channel named by cfg.Default. Every leaf is
// wrapped by sunscreen.Wrap and gated by its own level; a stack fans out
// through slog.NewMultiHandler.
func buildChannels(cfg Config, opts Options) (*channels, error) {
b := &channelBuilder{
cfg: cfg,
opts: opts,
out: &channels{source: new(atomic.Bool)},
visiting: map[string]bool{},
}
b.out.source.Store(opts.Verbosity >= 3)
root, err := b.build(cfg.Default)
if err != nil {
for _, c := range b.out.closers {
_ = c.Close()
}
return nil, err
}
b.out.root = root
for _, c := range b.out.consoles {
c.apply(opts.Quiet)
}
return b.out, nil
}
func (b *channelBuilder) build(name string) (slog.Handler, error) {
ch, ok := b.cfg.Channels[name]
if !ok {
return nil, fmt.Errorf("diary: channel %q is not defined in %s", name, ChannelsKey)
}
if b.visiting[name] {
return nil, fmt.Errorf("diary: %s.%s.channels includes itself", ChannelsKey, name)
}
b.visiting[name] = true
defer delete(b.visiting, name)
lv := new(slog.LevelVar)
base := noFloor
if ch.HasLevel {
base = ch.Level
}
lv.Set(base)
hopts := &slog.HandlerOptions{Level: lv}
switch ch.Driver {
case DriverConsole:
w := b.opts.Stderr
var leaf slog.Handler
switch ch.Format {
case FormatJSON:
leaf = slog.NewJSONHandler(w, hopts)
case FormatLogfmt:
leaf = slog.NewTextHandler(w, hopts)
default:
profile := bonfire.DetectProfile(w, b.opts.Color, b.opts.Environ)
if profile > colorprofile.NoTTY {
leaf = newConsoleHandler(w, ConsoleOptions{Level: lv, Profile: profile}, b.out.source)
} else {
leaf = slog.NewTextHandler(w, hopts)
}
}
b.out.consoles = append(b.out.consoles, consoleLevel{lv: lv, base: base})
wrapped := sunscreen.Wrap(leaf)
if b.warnTo == nil {
b.warnTo = wrapped
}
return wrapped, nil
case DriverDaily:
path := ch.Path
if !filepath.IsAbs(path) {
path = filepath.Join(b.opts.Root, filepath.Clean(path))
}
dw := newDailyWriter(path, ch.Days, b.opts.Now, b.warn)
b.out.closers = append(b.out.closers, dw)
var leaf slog.Handler
if ch.Format == FormatLogfmt {
leaf = slog.NewTextHandler(dw, hopts)
} else {
leaf = slog.NewJSONHandler(dw, hopts)
}
return sunscreen.Wrap(leaf), nil
case DriverStack:
children := make([]slog.Handler, 0, len(ch.Channels))
for _, child := range ch.Channels {
h, err := b.build(child)
if err != nil {
return nil, err
}
children = append(children, h)
}
return slog.NewMultiHandler(children...), nil
}
return nil, fmt.Errorf("diary: %s.%s.driver %q is not supported", ChannelsKey, name, ch.Driver)
}
// warn reports a daily file that cannot be opened: one WARN record from
// component diary on the first console channel, or on a plain stderr
// handler when the configuration has no console channel.
func (b *channelBuilder) warn(path string, err error) {
h := b.warnTo
if h == nil {
h = sunscreen.Wrap(slog.NewTextHandler(b.opts.Stderr, nil))
}
r := slog.NewRecord(b.opts.Now(), slog.LevelWarn, "daily log file disabled", 0)
r.AddAttrs(slog.String("path", path), slog.String("error", err.Error()))
_ = h.WithAttrs([]slog.Attr{slog.String(ComponentKey, "diary")}).Handle(context.Background(), r)
}