// 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= 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) } }