- 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
359 lines
10 KiB
Go
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
|
|
}
|