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.). ChannelsKey = "log.channels" // ComponentsKey holds per-component levels (log.components.). 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 }