Files
summercms/modules/diary/config.go
Jakub Zych e903cc32be feat(diary): component loggers through a redacting console and daily stack
- New diary module: For, Bootstrap, Setup, SetVerbosity, Reset, Close, ParseConfig
- Proxy component handler safe in package vars; styled console handler in bonfire's palette
- Daily writer: one file per day, 0640, lazy open, retention, disables itself on open failure
- Every channel leaf wrapped by sunscreen.Wrap; stack via slog.NewMultiHandler
- bonfire: ColorMode, Palette, DefaultPalette, DetectProfile (lipgloss v2, colorprofile)
- Generated main runs diary.Bootstrap first and diary.Setup after compass.Load
- Ignore /storage/ and /examples/hello/storage/
2026-10-21 11:41:48 +02:00

297 lines
8.0 KiB
Go

package diary
import (
"fmt"
"log/slog"
"maps"
"slices"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/modules/compass"
)
// Config keys read by ParseConfig.
const (
// DefaultKey names the channel that receives every record (log.default).
DefaultKey = "log.default"
// LevelKey is the global minimum level (log.level).
LevelKey = "log.level"
// ChannelsKey holds the named channels (log.channels.<name>).
ChannelsKey = "log.channels"
// ComponentsKey holds per-component levels (log.components.<name>).
ComponentsKey = "log.components"
)
// Channel drivers.
const (
// DriverConsole writes to the console (stderr): styled lines on a
// terminal, logfmt or JSON otherwise.
DriverConsole = "console"
// DriverStderr writes logfmt or JSON to stderr, never styled.
DriverStderr = "stderr"
// DriverDaily writes one file per day with retention.
DriverDaily = "daily"
// DriverStack fans a record out to the channels it lists.
DriverStack = "stack"
// DriverNull discards records.
DriverNull = "null"
)
// Channel formats.
const (
// FormatAuto styles console lines on a terminal and writes logfmt
// otherwise. Only the console driver accepts it.
FormatAuto = "auto"
// FormatJSON writes one JSON object per record.
FormatJSON = "json"
// FormatLogfmt writes key=value lines.
FormatLogfmt = "logfmt"
)
// Config is the parsed log.* configuration.
type Config struct {
// Default is the channel every record goes to (log.default).
Default string
// Level is the global minimum level (log.level).
Level slog.Level
// Channels holds the named channels (log.channels).
Channels map[string]ChannelConfig
// Components holds per-component minimum levels (log.components).
Components map[string]slog.Level
}
// ChannelConfig is one entry of log.channels.
type ChannelConfig struct {
// Driver is one of DriverConsole, DriverStderr, DriverDaily,
// DriverStack or DriverNull.
Driver string
// Format is FormatAuto, FormatJSON or FormatLogfmt; empty means the
// driver's default (auto for console, logfmt for stderr, json for daily).
Format string
// Path is the daily file path; relative paths resolve against the
// application root.
Path string
// Level is the channel's own minimum level, applied when HasLevel is set.
Level slog.Level
// HasLevel reports whether the channel sets a level.
HasLevel bool
// Days is the daily retention in days; 0 keeps every file.
Days int
// Channels lists a stack's children.
Channels []string
}
// DefaultConfig returns the framework defaults: a stack of a console channel
// and a daily file at storage/logs/summer.log (JSON, level warn, kept 14
// days), global level info, and River at warn.
func DefaultConfig() Config {
return Config{
Default: "stack",
Level: slog.LevelInfo,
Channels: map[string]ChannelConfig{
"stack": {Driver: DriverStack, Channels: []string{"console", "daily"}},
"console": {Driver: DriverConsole, Format: FormatAuto},
"daily": {
Driver: DriverDaily,
Path: "storage/logs/summer.log",
Days: 14,
Format: FormatJSON,
Level: slog.LevelWarn,
HasLevel: true,
},
},
Components: map[string]slog.Level{"river": slog.LevelWarn},
}
}
func (c Config) clone() Config {
out := c
out.Channels = make(map[string]ChannelConfig, len(c.Channels))
for name, ch := range c.Channels {
ch.Channels = slices.Clone(ch.Channels)
out.Channels[name] = ch
}
out.Components = maps.Clone(c.Components)
if out.Components == nil {
out.Components = map[string]slog.Level{}
}
return out
}
// ParseConfig reads log.* from cfg over DefaultConfig, key by key, so a
// single override keeps every other default. Values may arrive as strings
// from SUMMER_LOG__ environment variables. A nil cfg gives the defaults.
func ParseConfig(cfg *compass.Config) (Config, error) {
out := DefaultConfig().clone()
if cfg == nil {
return out, nil
}
if raw, ok := cfg.Lookup(DefaultKey); ok {
out.Default = strings.TrimSpace(fmt.Sprint(raw))
}
if raw, ok := cfg.Lookup(LevelKey); ok {
lvl, err := parseLevel(raw)
if err != nil {
return Config{}, keyError(LevelKey, err)
}
out.Level = lvl
}
if raw, ok := cfg.Lookup(ChannelsKey); ok {
channels, ok := raw.(map[string]any)
if !ok {
return Config{}, fmt.Errorf("diary: %s must be a map of channels", ChannelsKey)
}
for _, name := range slices.Sorted(maps.Keys(channels)) {
ch, err := overlayChannel(name, out.Channels[name], channels[name])
if err != nil {
return Config{}, err
}
out.Channels[name] = ch
}
}
if err := out.validate(); err != nil {
return Config{}, err
}
return out, nil
}
func overlayChannel(name string, ch ChannelConfig, raw any) (ChannelConfig, error) {
key := ChannelsKey + "." + name
fields, ok := raw.(map[string]any)
if !ok {
return ch, fmt.Errorf("diary: %s must be a map", key)
}
for _, field := range slices.Sorted(maps.Keys(fields)) {
v := fields[field]
fkey := key + "." + field
switch field {
case "driver":
ch.Driver = strings.ToLower(strings.TrimSpace(fmt.Sprint(v)))
case "format":
ch.Format = strings.ToLower(strings.TrimSpace(fmt.Sprint(v)))
case "path":
ch.Path = strings.TrimSpace(fmt.Sprint(v))
case "level":
lvl, err := parseLevel(v)
if err != nil {
return ch, keyError(fkey, err)
}
ch.Level, ch.HasLevel = lvl, true
case "days":
n, err := parseDays(v)
if err != nil {
return ch, fmt.Errorf("diary: %s must be a whole number of at least 0", fkey)
}
ch.Days = n
case "channels":
ch.Channels = parseList(v)
default:
return ch, fmt.Errorf("diary: %s is not a channel setting (driver, level, format, path, days, channels)", fkey)
}
}
return ch, nil
}
func (c Config) validate() error {
if c.Default == "" {
return fmt.Errorf("diary: %s must name a channel", DefaultKey)
}
if _, ok := c.Channels[c.Default]; !ok {
return fmt.Errorf("diary: %s names channel %q, which is not defined in %s", DefaultKey, c.Default, ChannelsKey)
}
for _, name := range slices.Sorted(maps.Keys(c.Channels)) {
ch := c.Channels[name]
key := ChannelsKey + "." + name
switch ch.Driver {
case DriverConsole, DriverDaily, DriverStack:
case "":
return fmt.Errorf("diary: %s.driver is required", key)
default:
return fmt.Errorf("diary: %s.driver %q is not one of console, daily, stack", key, ch.Driver)
}
switch ch.Format {
case "", FormatJSON, FormatLogfmt:
case FormatAuto:
if ch.Driver != DriverConsole {
return fmt.Errorf("diary: %s.format auto is only valid for the console driver", key)
}
default:
return fmt.Errorf("diary: %s.format %q is not one of auto, json, logfmt", key, ch.Format)
}
if ch.Driver == DriverDaily && ch.Path == "" {
return fmt.Errorf("diary: %s.path is required for the daily driver", key)
}
}
return nil
}
func keyError(key string, err error) error {
return fmt.Errorf("diary: %s: %w", key, err)
}
func parseLevel(raw any) (slog.Level, error) {
s := strings.ToLower(strings.TrimSpace(fmt.Sprint(raw)))
switch s {
case "debug":
return slog.LevelDebug, nil
case "info":
return slog.LevelInfo, nil
case "warn", "warning":
return slog.LevelWarn, nil
case "error":
return slog.LevelError, nil
}
return 0, fmt.Errorf("level %q is not one of debug, info, warn, error", s)
}
func parseDays(raw any) (int, error) {
var n int
switch v := raw.(type) {
case int:
n = v
case int64:
n = int(v)
case uint64:
n = int(v)
case float64:
if v != float64(int(v)) {
return 0, fmt.Errorf("not a whole number")
}
n = int(v)
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0, err
}
n = parsed
default:
return 0, fmt.Errorf("unsupported value %T", raw)
}
if n < 0 {
return 0, fmt.Errorf("negative")
}
return n, nil
}
func parseList(raw any) []string {
var parts []string
switch v := raw.(type) {
case []any:
for _, e := range v {
parts = append(parts, fmt.Sprint(e))
}
case []string:
parts = v
default:
parts = strings.Split(fmt.Sprint(v), ",")
}
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}