Files
summercms/modules/diary/channels.go
Jakub Zych c38dc1fe48 feat(surf): serve startup summary, shutdown report and http request log
- serve prints a startup summary on a terminal (version, environment, URL,
  admin, database host and name, pending migrations, plugins, drivers) and
  logs it as a 'startup summary' record plus 'listening on <addr>'
- on SIGINT/SIGTERM serve drains, prints and logs a shutdown report and
  returns nil (exit 0), or the drain errors (exit 1)
- the request log is on by default under diary component http with
  request_id; the route pattern, never the path, is logged
- lagoon.PendingMigrations, conga.Worker.Stats, diary.HideOnTTY,
  bonfire.IsTerminal and bonfire.Details back the summary and report
2026-10-21 12:59:07 +02:00

172 lines
4.7 KiB
Go

package diary
import (
"context"
"fmt"
"io"
"log/slog"
"path/filepath"
"sync/atomic"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/sunscreen"
"github.com/charmbracelet/colorprofile"
)
// noFloor is the level of a channel without its own level: every record the
// component floors let through reaches it.
const noFloor = slog.Level(-1 << 30)
// consoleLevel is a console channel's runtime level: base is the configured
// level, which quiet raises to warn.
type consoleLevel struct {
lv *slog.LevelVar
base slog.Level
}
func (c consoleLevel) apply(quiet bool) {
l := c.base
if quiet && l < slog.LevelWarn {
l = slog.LevelWarn
}
c.lv.Set(l)
}
// channels is the built handler tree of one configuration.
type channels struct {
root slog.Handler
consoles []consoleLevel
closers []io.Closer
source *atomic.Bool
}
type channelBuilder struct {
cfg Config
opts Options
out *channels
visiting map[string]bool
warnTo slog.Handler
}
// buildChannels builds the channel named by cfg.Default. Every leaf
// (console, stderr, daily and null) is wrapped by sunscreen.Wrap and gated by
// its own level; a stack fans out through slog.NewMultiHandler. A relative
// daily path resolves against Options.Root.
func buildChannels(cfg Config, opts Options) (*channels, error) {
b := &channelBuilder{
cfg: cfg,
opts: opts,
out: &channels{source: new(atomic.Bool)},
visiting: map[string]bool{},
}
b.out.source.Store(opts.Verbosity >= 3)
root, err := b.build(cfg.Default)
if err != nil {
for _, c := range b.out.closers {
_ = c.Close()
}
return nil, err
}
b.out.root = root
for _, c := range b.out.consoles {
c.apply(opts.Quiet)
}
return b.out, nil
}
func (b *channelBuilder) build(name string) (slog.Handler, error) {
ch, ok := b.cfg.Channels[name]
if !ok {
return nil, fmt.Errorf("diary: channel %q is not defined in %s", name, ChannelsKey)
}
if b.visiting[name] {
return nil, fmt.Errorf("diary: %s.%s.channels includes itself", ChannelsKey, name)
}
b.visiting[name] = true
defer delete(b.visiting, name)
lv := new(slog.LevelVar)
base := noFloor
if ch.HasLevel {
base = ch.Level
}
lv.Set(base)
hopts := &slog.HandlerOptions{Level: lv}
switch ch.Driver {
case DriverConsole:
w := b.opts.Stderr
var leaf slog.Handler
switch ch.Format {
case FormatJSON:
leaf = slog.NewJSONHandler(w, hopts)
case FormatLogfmt:
leaf = slog.NewTextHandler(w, hopts)
default:
profile := bonfire.DetectProfile(w, b.opts.Color, b.opts.Environ)
if profile > colorprofile.NoTTY {
leaf = newConsoleHandler(w, ConsoleOptions{Level: lv, Profile: profile}, b.out.source)
} else {
leaf = slog.NewTextHandler(w, hopts)
}
}
if _, styled := leaf.(*consoleHandler); !styled && isTerminal(w) {
leaf = ttyFilter{leaf}
}
b.out.consoles = append(b.out.consoles, consoleLevel{lv: lv, base: base})
wrapped := sunscreen.Wrap(leaf)
if b.warnTo == nil {
b.warnTo = wrapped
}
return wrapped, nil
case DriverStderr:
var leaf slog.Handler
if ch.Format == FormatJSON {
leaf = slog.NewJSONHandler(b.opts.Stderr, hopts)
} else {
leaf = slog.NewTextHandler(b.opts.Stderr, hopts)
}
return sunscreen.Wrap(leaf), nil
case DriverNull:
return sunscreen.Wrap(slog.DiscardHandler), nil
case DriverDaily:
path := ch.Path
if !filepath.IsAbs(path) {
path = filepath.Join(b.opts.Root, filepath.Clean(path))
}
dw := newDailyWriter(path, ch.Days, b.opts.Now, b.warn)
b.out.closers = append(b.out.closers, dw)
var leaf slog.Handler
if ch.Format == FormatLogfmt {
leaf = slog.NewTextHandler(dw, hopts)
} else {
leaf = slog.NewJSONHandler(dw, hopts)
}
return sunscreen.Wrap(leaf), nil
case DriverStack:
children := make([]slog.Handler, 0, len(ch.Channels))
for _, child := range ch.Channels {
h, err := b.build(child)
if err != nil {
return nil, err
}
children = append(children, h)
}
return slog.NewMultiHandler(children...), nil
}
return nil, fmt.Errorf("diary: %s.%s.driver %q is not supported", ChannelsKey, name, ch.Driver)
}
// warn reports a daily file that cannot be opened: one WARN record from
// component diary on the first console channel, or on a plain stderr
// handler when the configuration has no console channel.
func (b *channelBuilder) warn(path string, err error) {
h := b.warnTo
if h == nil {
h = sunscreen.Wrap(slog.NewTextHandler(b.opts.Stderr, nil))
}
r := slog.NewRecord(b.opts.Now(), slog.LevelWarn, "daily log file disabled", 0)
r.AddAttrs(slog.String("path", path), slog.String("error", err.Error()))
_ = h.WithAttrs([]slog.Attr{slog.String(ComponentKey, "diary")}).Handle(context.Background(), r)
}