Files
summercms/modules/diary/diary.go
Jakub Zych 39677f1819 fix(diary): retire replaced daily writers and serialise ApplyGlobals
- install and Reset retire the channels they replace: a retired daily
  writer appends a late record and closes the file again, so no record is
  lost and no descriptor stays open; diary.Close keeps reopening the
  installed writers on the next record
- ApplyGlobals holds the setup lock for its whole run (lock-free
  installLocked and setVerbosityLocked), so concurrent calls rebuild the
  channels once (WR-06)
2026-10-22 02:27:39 +02:00

326 lines
9.2 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 (
"context"
"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()
installLocked(conf, ch, opts)
}
// installLocked makes ch the installed setup and retires the channels it
// replaces; the caller holds mu.
func installLocked(conf Config, ch *channels, opts Options) {
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 {
retireChannels(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()
setVerbosityLocked(v, quiet)
}
// setVerbosityLocked is SetVerbosity for a caller that holds mu.
func setVerbosityLocked(v int, quiet bool) {
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. It holds the setup lock for its
// whole run, so concurrent calls rebuild the channels once.
func ApplyGlobals(g bonfire.Globals) {
mu.Lock()
defer mu.Unlock()
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 {
installLocked(st.conf, ch, opts)
return
}
}
setVerbosityLocked(g.Verbosity, g.Quiet)
}
type hideOnTTYKey struct{}
// HideOnTTY marks ctx so that a console channel writing to a terminal skips
// the records logged with it (Logger.InfoContext and the like); daily files,
// stderr channels and a console that is not a terminal still write them. A
// command that has just printed the same facts as a styled block (serve's
// startup summary) logs them with it, so a person does not read them twice
// while journald and the log files keep them.
func HideOnTTY(ctx context.Context) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, hideOnTTYKey{}, true)
}
// hiddenOnTTY reports whether ctx carries the HideOnTTY mark.
func hiddenOnTTY(ctx context.Context) bool {
if ctx == nil {
return false
}
v, _ := ctx.Value(hideOnTTYKey{}).(bool)
return v
}
// 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)
}
// retireChannels closes the files of channels a new setup replaced, for
// good: a goroutine that still holds the old state appends its record and
// the file is closed again, so no descriptor stays open.
func retireChannels(ch *channels) {
if ch == nil {
return
}
for _, c := range ch.closers {
if r, ok := c.(interface{ retire() error }); ok {
_ = r.retire()
continue
}
_ = c.Close()
}
}
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 for good 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 {
retireChannels(old.channels)
}
}