Files
summercms/modules/diary/diary.go
Jakub Zych 5b09b32512 feat(diary): channels, components and config/log.yaml
- stderr and null drivers; per-component levels from log.components
- SUMMER_LOG__ overrides parse as strings and overlay the defaults key by key
- Validation names the key: driver, format, level, days, stack children and cycles, daily path outside the root
- summer new writes config/log.yaml (stack of console and daily, river warn, http info)
- docs/services/logging.md: channels, config/log.yaml, component loggers, daily files, redaction
- Log keys in the configuration docs; ExampleFor backs the docs snippet
2026-10-21 11:48:25 +02:00

241 lines
6.5 KiB
Go

// 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
components map[string]slog.Level
verbosity int
quiet bool
channels *channels
}
// floor is the minimum level for records of component name: its
// log.components level, else log.level, lowered by verbosity. Records without
// a component use log.level.
func (s *state) floor(name string) slog.Level {
f := s.level
if l, ok := s.components[name]; ok && name != "" {
f = l
}
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,
components: conf.Components,
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)
}
}