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/
This commit is contained in:
296
modules/diary/config.go
Normal file
296
modules/diary/config.go
Normal file
@@ -0,0 +1,296 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user