- 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
271 lines
7.5 KiB
Go
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)
|
|
}
|
|
}
|