Files
summercms/modules/diary/diary.go
Jakub Zych 8d4d7ce7a7 feat(bonfire): global flags, Artisan list and exit codes
- Global options -v/-vv/-vvv, -q, --ansi/--no-ansi, -n and --env on every
  command; PeekGlobals reads them before config loads, OnGlobals hooks
  re-apply them after parsing (diary.ApplyGlobals in the generated main)
- Bare binary, --help and list print the Artisan list with the version
  header from BuildVersion and namespace blocks
- Execute runs the root through fang (no version, man page or completions)
  and returns 0, 1 or 2; Fail renders errors before the root exists
- NewRoot rejects duplicate command names (ErrDuplicateCommand) and flags
  that reuse a global option (ErrReservedFlag)
- Generated main and summer use PeekGlobals, compass.Open with --env,
  diary.FromGlobals and bonfire.Execute; docs checker knows the globals
2026-10-21 12:13:23 +02:00

271 lines
7.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
conf Config
opts Options
}
// 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,
conf: conf,
opts: opts,
}
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
next.opts.Verbosity, next.opts.Quiet = v, quiet
for _, c := range st.channels.consoles {
c.apply(quiet)
}
st.channels.source.Store(v >= 3)
current.Store(&next)
}
// FromGlobals returns the Options for the console global options: their
// verbosity, quiet switch and color mode. The generated main passes it to
// Bootstrap and Setup.
func FromGlobals(g bonfire.Globals) Options {
return Options{Verbosity: g.Verbosity, Quiet: g.Quiet, Color: g.Color}
}
// ApplyGlobals applies the parsed global options to the installed setup:
// the verbosity and quiet switch (SetVerbosity) and, when it differs from
// the one the channels were built with, the console color mode, which
// rebuilds the channels from the same configuration. The generated main
// registers it with bonfire.OnGlobals.
func ApplyGlobals(g bonfire.Globals) {
st := current.Load()
if st != nil && st.opts.Color != g.Color {
opts := st.opts
opts.Color, opts.Verbosity, opts.Quiet = g.Color, g.Verbosity, g.Quiet
if ch, err := buildChannels(st.conf, opts); err == nil {
install(st.conf, ch, opts)
return
}
}
SetVerbosity(g.Verbosity, g.Quiet)
}
// 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)
}
}