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:
233
modules/diary/diary.go
Normal file
233
modules/diary/diary.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// Package diary is the application logging pipeline, modelled on Laravel's
|
||||
// logging config (D-08): config/log.yaml names a default channel, a global
|
||||
// level and named channels (console, stderr, daily files, stacks, null), and
|
||||
// every channel redacts through sunscreen. Code logs through named component
|
||||
// loggers (D-09): For("journal") returns a logger whose records carry the
|
||||
// component and pass its level from log.components, and which is safe to
|
||||
// create in a package var before Setup runs.
|
||||
package diary
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
)
|
||||
|
||||
// ComponentKey is the attribute that carries a record's component.
|
||||
const ComponentKey = "component"
|
||||
|
||||
// Options configures Bootstrap and Setup.
|
||||
type Options struct {
|
||||
// Verbosity lowers every component floor: 1 to at most info, 2 or more
|
||||
// to debug; 3 or more also adds source locations on the console.
|
||||
Verbosity int
|
||||
// Quiet raises the console channels to warn; files keep their levels.
|
||||
Quiet bool
|
||||
// Color decides on styled console output (bonfire.ColorAuto by default).
|
||||
Color bonfire.ColorMode
|
||||
// Root is the application root that relative daily paths resolve
|
||||
// against; empty means the working directory.
|
||||
Root string
|
||||
// Stderr is where console channels write; nil means os.Stderr.
|
||||
Stderr io.Writer
|
||||
// Environ is used to detect color support; nil means os.Environ().
|
||||
Environ []string
|
||||
// Now is the clock of the daily writers; nil means time.Now.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (o Options) normalize() Options {
|
||||
if o.Stderr == nil {
|
||||
o.Stderr = os.Stderr
|
||||
}
|
||||
if o.Root == "" {
|
||||
o.Root = "."
|
||||
}
|
||||
if o.Now == nil {
|
||||
o.Now = time.Now
|
||||
}
|
||||
if o.Environ == nil {
|
||||
o.Environ = os.Environ()
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// state is one installed configuration. It is replaced as a whole; only the
|
||||
// console level vars and the source switch change in place.
|
||||
type state struct {
|
||||
gen uint64
|
||||
root slog.Handler
|
||||
level slog.Level
|
||||
verbosity int
|
||||
quiet bool
|
||||
channels *channels
|
||||
}
|
||||
|
||||
// floor is the minimum level for records of component name.
|
||||
func (s *state) floor(name string) slog.Level {
|
||||
f := s.level
|
||||
switch {
|
||||
case s.verbosity >= 2:
|
||||
f = min(f, slog.LevelDebug)
|
||||
case s.verbosity >= 1:
|
||||
f = min(f, slog.LevelInfo)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
type savedDefault struct {
|
||||
logger *slog.Logger
|
||||
writer io.Writer
|
||||
flags int
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
current atomic.Pointer[state]
|
||||
gens atomic.Uint64
|
||||
verbosity atomic.Int64
|
||||
saved *savedDefault
|
||||
)
|
||||
|
||||
// For returns the logger of a component, normally a plugin or module code
|
||||
// without dots (koanf splits config keys on dots). Its records carry the
|
||||
// attribute component=<name> and pass the component's level. Before Bootstrap
|
||||
// or Setup they go to the process default logger's handler.
|
||||
func For(component string) *slog.Logger {
|
||||
return slog.New(newComponentHandler(component))
|
||||
}
|
||||
|
||||
// Bootstrap installs a console channel on w, before configuration is read,
|
||||
// and makes diary the process default logger. The generated main calls it
|
||||
// first; Setup replaces it once config/log.yaml is loaded.
|
||||
func Bootstrap(w io.Writer, opts Options) {
|
||||
if w == nil {
|
||||
w = os.Stderr
|
||||
}
|
||||
opts.Stderr = w
|
||||
opts = opts.normalize()
|
||||
cfg := DefaultConfig()
|
||||
cfg.Default = "console"
|
||||
cfg.Channels = map[string]ChannelConfig{"console": {Driver: DriverConsole, Format: FormatAuto}}
|
||||
ch, err := buildChannels(cfg, opts)
|
||||
if err != nil {
|
||||
// A console-only configuration has nothing that can fail; keep the
|
||||
// previous state if it ever does.
|
||||
return
|
||||
}
|
||||
install(cfg, ch, opts)
|
||||
}
|
||||
|
||||
// Setup parses log.* from cfg (ParseConfig), builds the channels and makes
|
||||
// them the target of every diary logger and of the process default logger.
|
||||
// A configuration error is returned and leaves the previous setup in place;
|
||||
// a daily file that cannot be opened never fails Setup.
|
||||
func Setup(cfg *compass.Config, opts Options) error {
|
||||
conf, err := ParseConfig(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts = opts.normalize()
|
||||
ch, err := buildChannels(conf, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
install(conf, ch, opts)
|
||||
return nil
|
||||
}
|
||||
|
||||
func install(conf Config, ch *channels, opts Options) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if saved == nil {
|
||||
saved = &savedDefault{logger: slog.Default(), writer: log.Writer(), flags: log.Flags()}
|
||||
}
|
||||
verbosity.Store(int64(opts.Verbosity))
|
||||
st := &state{
|
||||
gen: gens.Add(1),
|
||||
root: ch.root,
|
||||
level: conf.Level,
|
||||
verbosity: opts.Verbosity,
|
||||
quiet: opts.Quiet,
|
||||
channels: ch,
|
||||
}
|
||||
old := current.Swap(st)
|
||||
slog.SetDefault(slog.New(newComponentHandler("")))
|
||||
if old != nil {
|
||||
closeChannels(old.channels)
|
||||
}
|
||||
}
|
||||
|
||||
// SetVerbosity changes the verbosity and quiet switches at runtime: 1 lets
|
||||
// info through for every component, 2 or more debug, 3 or more adds source
|
||||
// locations on styled console lines. Quiet raises only the console channels
|
||||
// to warn. Before Bootstrap or Setup it only changes what Verbosity returns;
|
||||
// Options.Verbosity and Options.Quiet set the values of a new setup.
|
||||
func SetVerbosity(v int, quiet bool) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
verbosity.Store(int64(v))
|
||||
st := current.Load()
|
||||
if st == nil {
|
||||
return
|
||||
}
|
||||
next := *st
|
||||
next.verbosity, next.quiet = v, quiet
|
||||
for _, c := range st.channels.consoles {
|
||||
c.apply(quiet)
|
||||
}
|
||||
st.channels.source.Store(v >= 3)
|
||||
current.Store(&next)
|
||||
}
|
||||
|
||||
// Verbosity returns the current verbosity (0 by default).
|
||||
func Verbosity() int {
|
||||
return int(verbosity.Load())
|
||||
}
|
||||
|
||||
// Close closes the open daily files. Later records open them again.
|
||||
func Close() error {
|
||||
st := current.Load()
|
||||
if st == nil {
|
||||
return nil
|
||||
}
|
||||
return closeChannels(st.channels)
|
||||
}
|
||||
|
||||
func closeChannels(ch *channels) error {
|
||||
if ch == nil {
|
||||
return nil
|
||||
}
|
||||
var errs []error
|
||||
for _, c := range ch.closers {
|
||||
errs = append(errs, c.Close())
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Reset drops the installed setup, closes its files and restores the process
|
||||
// default logger (and the standard log package's output) captured at the
|
||||
// first Bootstrap or Setup. Tests call it in t.Cleanup.
|
||||
func Reset() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
old := current.Swap(nil)
|
||||
verbosity.Store(0)
|
||||
if saved != nil {
|
||||
slog.SetDefault(saved.logger)
|
||||
log.SetOutput(saved.writer)
|
||||
log.SetFlags(saved.flags)
|
||||
saved = nil
|
||||
}
|
||||
if old != nil {
|
||||
closeChannels(old.channels)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user