Files
summercms/modules/diary/config.go
Jakub Zych 5b09b32512 feat(diary): channels, components and config/log.yaml
- stderr and null drivers; per-component levels from log.components
- SUMMER_LOG__ overrides parse as strings and overlay the defaults key by key
- Validation names the key: driver, format, level, days, stack children and cycles, daily path outside the root
- summer new writes config/log.yaml (stack of console and daily, river warn, http info)
- docs/services/logging.md: channels, config/log.yaml, component loggers, daily files, redaction
- Log keys in the configuration docs; ExampleFor backs the docs snippet
2026-10-21 11:48:25 +02:00

359 lines
10 KiB
Go

package diary
import (
"fmt"
"log/slog"
"maps"
"path/filepath"
"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. A stderr channel (logfmt) and
// a null channel are defined too, so log.default can name them without
// defining them.
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},
"stderr": {Driver: DriverStderr, Format: FormatLogfmt},
"null": {Driver: DriverNull},
"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 raw, ok := cfg.Lookup(ComponentsKey); ok {
components, ok := raw.(map[string]any)
if !ok {
return Config{}, fmt.Errorf("diary: %s must be a map of component levels", ComponentsKey)
}
for _, name := range slices.Sorted(maps.Keys(components)) {
key := ComponentsKey + "." + name
if _, nested := components[name].(map[string]any); nested {
return Config{}, fmt.Errorf("diary: %s must be a level; component names cannot contain dots", key)
}
lvl, err := parseLevel(components[name])
if err != nil {
return Config{}, keyError(key, err)
}
out.Components[name] = lvl
}
}
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, DriverStderr, DriverDaily, DriverStack, DriverNull:
case "":
return fmt.Errorf("diary: %s.driver is required", key)
default:
return fmt.Errorf("diary: %s.driver %q is not one of console, stderr, daily, stack, null", 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 {
if ch.Path == "" {
return fmt.Errorf("diary: %s.path is required for the daily driver", key)
}
if !filepath.IsAbs(ch.Path) && !filepath.IsLocal(filepath.Clean(ch.Path)) {
return fmt.Errorf("diary: %s.path %q leaves the application root; use a path inside it or an absolute path", key, ch.Path)
}
}
if ch.Driver == DriverStack {
if len(ch.Channels) == 0 {
return fmt.Errorf("diary: %s.channels must list at least one channel", key)
}
for _, child := range ch.Channels {
if _, ok := c.Channels[child]; !ok {
return fmt.Errorf("diary: %s.channels names channel %q, which is not defined in %s", key, child, ChannelsKey)
}
}
}
}
for _, name := range slices.Sorted(maps.Keys(c.Channels)) {
if c.Channels[name].Driver != DriverStack {
continue
}
if err := c.checkCycle(name, nil); err != nil {
return err
}
}
return nil
}
// checkCycle reports a stack that contains itself through its children.
func (c Config) checkCycle(name string, path []string) error {
if slices.Contains(path, name) {
return fmt.Errorf("diary: %s.%s.channels forms a cycle (%s)", ChannelsKey, path[0], strings.Join(append(path, name), " -> "))
}
ch := c.Channels[name]
if ch.Driver != DriverStack {
return nil
}
for _, child := range ch.Channels {
if err := c.checkCycle(child, append(slices.Clip(path), name)); err != nil {
return err
}
}
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
}