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:
@@ -12,7 +12,7 @@ bonfire is the console layer of SummerCMS. Plugins and framework modules describ
|
||||
|
||||
## Role in the framework
|
||||
|
||||
A dependency-free base layer for console commands. `pact` uses it so plugins can contribute commands, and `lagoon`, `conga`, `cabana`, `lighthouse`, `flare` and `surf` build their own commands (migrate, workers, serve, admin tools) on it. Keeping it separate means every module describes commands the same way without depending on the CLI binary.
|
||||
A base layer for console commands with no framework dependencies. `pact` uses it so plugins can contribute commands, and `lagoon`, `conga`, `cabana`, `lighthouse`, `flare` and `surf` build their own commands (migrate, workers, serve, admin tools) on it. Keeping it separate means every module describes commands the same way without depending on the CLI binary.
|
||||
|
||||
Use cases:
|
||||
|
||||
@@ -107,6 +107,10 @@ if catalog.Has("blog:import") {
|
||||
| `bonfire.ErrUnknownCommand` | Returned by `bonfire.Call` when no command has the name. |
|
||||
| `bonfire.Catalog` | An immutable copy of a binary's command list; the generated app main publishes one on the app. |
|
||||
| `bonfire.NewCatalog` | Builds a `bonfire.Catalog` from a command slice. |
|
||||
| `bonfire.ColorMode` | How console output decides on color: `bonfire.ColorAuto` (terminal and environment), `bonfire.ColorAlways` or `bonfire.ColorNever`. |
|
||||
| `bonfire.Palette` | The console styles (`Success`, `Current`, `Warning`, `Error`, `ErrorBold`, `Muted`, `Header`), shared with the `diary` console log lines. |
|
||||
| `bonfire.DefaultPalette` | Returns the palette ported from SSU's status styles: green success, cyan current, yellow warning, red and bold red errors, gray muted text, bold headers. |
|
||||
| `bonfire.DetectProfile` | Returns the color profile for a writer under a `bonfire.ColorMode`, honouring `NO_COLOR`, `TERM=dumb` and `FORCE_COLOR`; render palette styles through a `colorprofile.Writer` with it. |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -123,7 +127,7 @@ Without these variables, color is enabled only when stdout is a terminal.
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: none.
|
||||
- Third-party: `github.com/spf13/cobra`, `golang.org/x/term`.
|
||||
- Third-party: `github.com/spf13/cobra`, `golang.org/x/term`, `charm.land/lipgloss/v2` and `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`).
|
||||
- Standard library: `bufio`, `context`, `errors`, `fmt`, `io`, `os`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`.
|
||||
|
||||
## Testing
|
||||
|
||||
100
modules/bonfire/styles.go
Normal file
100
modules/bonfire/styles.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
)
|
||||
|
||||
// ColorMode selects how console output decides on color: from the terminal
|
||||
// and the environment, always, or never.
|
||||
type ColorMode int
|
||||
|
||||
const (
|
||||
// ColorAuto colors output when the writer is a terminal and the
|
||||
// environment allows it (NO_COLOR and TERM=dumb disable color,
|
||||
// FORCE_COLOR enables it on any writer).
|
||||
ColorAuto ColorMode = iota
|
||||
// ColorAlways colors output whatever the writer is (--ansi).
|
||||
ColorAlways
|
||||
// ColorNever writes no escape codes at all (--no-ansi).
|
||||
ColorNever
|
||||
)
|
||||
|
||||
// Palette is the shared console look: the status styles of list, help,
|
||||
// widgets and log lines. Render through a writer that applies the color
|
||||
// profile (DetectProfile), because lipgloss always emits ANSI codes.
|
||||
type Palette struct {
|
||||
// Success marks completed work (green).
|
||||
Success lipgloss.Style
|
||||
// Current marks the item in progress and informational lines (cyan).
|
||||
Current lipgloss.Style
|
||||
// Warning marks warnings (yellow).
|
||||
Warning lipgloss.Style
|
||||
// Error marks failures (red).
|
||||
Error lipgloss.Style
|
||||
// ErrorBold marks fatal failures and error levels (bold red).
|
||||
ErrorBold lipgloss.Style
|
||||
// Muted marks secondary detail such as times and keys (gray).
|
||||
Muted lipgloss.Style
|
||||
// Header marks headings and names (bold).
|
||||
Header lipgloss.Style
|
||||
}
|
||||
|
||||
// DefaultPalette returns the console palette, ported from SSU's status
|
||||
// styles: green success, cyan current, yellow warning, red and bold red
|
||||
// errors, gray (240) muted text and bold headers.
|
||||
func DefaultPalette() Palette {
|
||||
return Palette{
|
||||
Success: lipgloss.NewStyle().Foreground(lipgloss.Color("2")),
|
||||
Current: lipgloss.NewStyle().Foreground(lipgloss.Color("6")),
|
||||
Warning: lipgloss.NewStyle().Foreground(lipgloss.Color("3")),
|
||||
Error: lipgloss.NewStyle().Foreground(lipgloss.Color("1")),
|
||||
ErrorBold: lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true),
|
||||
Muted: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
|
||||
Header: lipgloss.NewStyle().Bold(true),
|
||||
}
|
||||
}
|
||||
|
||||
// DetectProfile returns the color profile for console output written to w.
|
||||
// ColorNever gives colorprofile.NoTTY (no escape codes at all). ColorAlways
|
||||
// gives the detected profile raised to at least 256 colors. ColorAuto treats
|
||||
// a non-empty FORCE_COLOR as ColorAlways unless NO_COLOR is set or TERM is
|
||||
// dumb, and otherwise detects the profile from w and environ, which honours
|
||||
// NO_COLOR and TERM=dumb. A nil environ means os.Environ().
|
||||
func DetectProfile(w io.Writer, mode ColorMode, environ []string) colorprofile.Profile {
|
||||
if mode == ColorNever {
|
||||
return colorprofile.NoTTY
|
||||
}
|
||||
if environ == nil {
|
||||
environ = os.Environ()
|
||||
}
|
||||
if mode == ColorAuto && forceColor(environ) {
|
||||
mode = ColorAlways
|
||||
}
|
||||
p := colorprofile.Detect(w, environ)
|
||||
if mode == ColorAlways && p < colorprofile.ANSI256 {
|
||||
p = colorprofile.ANSI256
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func forceColor(environ []string) bool {
|
||||
if envValue(environ, "NO_COLOR") != "" || envValue(environ, "TERM") == "dumb" {
|
||||
return false
|
||||
}
|
||||
return envValue(environ, "FORCE_COLOR") != ""
|
||||
}
|
||||
|
||||
func envValue(environ []string, key string) string {
|
||||
for i := len(environ) - 1; i >= 0; i-- {
|
||||
k, v, ok := strings.Cut(environ[i], "=")
|
||||
if ok && k == key {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
144
modules/diary/README.md
Normal file
144
modules/diary/README.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# diary
|
||||
|
||||
Application logging modelled on Laravel's logging config: named channels (console, stderr, daily files, stacks), per-component levels and redaction on every channel.
|
||||
|
||||
`import "git.golem15.com/golem15/summercms/modules/diary"`
|
||||
|
||||
**Name:** A beach diary records each day; the log keeps one file per day.
|
||||
|
||||
## Overview
|
||||
|
||||
`diary` owns the process logger of a SummerCMS application. `config/log.yaml` names a default channel, a global level and a map of channels, the way Laravel's `config/logging.php` does: a console channel that prints styled lines on a terminal, daily files with retention, and a stack that fans a record out to several channels. Code logs through named component loggers (`diary.For`), so every record carries the plugin or module it came from. Every channel is wrapped by `sunscreen.Wrap`, so credentials are redacted whichever channel a record reaches.
|
||||
|
||||
The application `main` that `summer build` generates calls `diary.Bootstrap` as its first statement, which installs a console channel before configuration is read, and `diary.Setup` right after `compass` loads `config/`.
|
||||
|
||||
## Role in the framework
|
||||
|
||||
The single owner of the process logger. It builds on `compass` (configuration), `sunscreen` (redaction) and `bonfire` (the console palette and color detection). Framework modules and plugins only ask it for a component logger; they never configure handlers themselves.
|
||||
|
||||
Use cases:
|
||||
|
||||
- Keep a daily JSON log file per instance without extra setup, next to readable console output.
|
||||
- Turn one noisy component down (or one under investigation up) without touching code.
|
||||
- Send logs to stderr as logfmt or JSON when the process runs under systemd or Docker.
|
||||
|
||||
## Features
|
||||
|
||||
- Component loggers: `diary.For` returns a `*slog.Logger` whose records carry the attribute `component=<name>`. It is safe to create in a package `var` block: before setup its records go to the process default logger's handler, after `diary.Setup` they reach the configured channels, and a later `diary.Setup` or `diary.SetVerbosity` applies to loggers created earlier.
|
||||
- Channels: `console` (styled lines on a terminal, logfmt otherwise, or JSON with `format: json`), `daily` (one file per day) and `stack` (several channels at once), each with its own level.
|
||||
- Console lines in bonfire's palette: time, level, the component as a fixed-width prefix, the message and `key=value` attributes. Control characters other than tab are removed from messages and values, so logged data cannot inject terminal escape sequences.
|
||||
- Daily files: the path `storage/logs/summer.log` gives files `storage/logs/summer-2026-10-21.log`, created mode 0640 in directories created 0750, opened on the first record, rotated at local midnight, and deleted after the retention in days. A relative path resolves against the application root.
|
||||
- Redaction: every channel leaf is wrapped by `sunscreen.Wrap`.
|
||||
- Runtime verbosity: `diary.SetVerbosity` lets info (1) or debug (2) through for every component and adds source locations on styled lines (3); quiet raises only the console channels to warn.
|
||||
- The process default logger (`slog.Default`) and the standard `log` package log through the configured channels without a component.
|
||||
|
||||
## Usage
|
||||
|
||||
A plugin or module creates its logger once:
|
||||
|
||||
```go
|
||||
var logger = diary.For("blog")
|
||||
|
||||
func publish(ctx context.Context, id int) {
|
||||
logger.InfoContext(ctx, "post published", "id", id)
|
||||
}
|
||||
```
|
||||
|
||||
With the default configuration this prints on a terminal:
|
||||
|
||||
```text
|
||||
12:04:31 INFO blog post published id=7
|
||||
```
|
||||
|
||||
and writes warnings and errors as JSON to `storage/logs/summer-YYYY-MM-DD.log`.
|
||||
|
||||
The generated `main` installs it:
|
||||
|
||||
```go
|
||||
func run(args []string, out io.Writer) error {
|
||||
diary.Bootstrap(os.Stderr, diary.Options{})
|
||||
cfg, err := compass.Load("config")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := diary.Setup(cfg, diary.Options{}); err != nil {
|
||||
return err
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Tests that set diary up restore the previous process logger with `t.Cleanup(diary.Reset)`.
|
||||
|
||||
## API reference
|
||||
|
||||
| Identifier | Description |
|
||||
|------------|-------------|
|
||||
| `diary.For` | Returns the `*slog.Logger` of a component; safe in package `var` blocks. |
|
||||
| `diary.Bootstrap` | Installs a console channel on a writer before configuration is read and makes diary the process default logger. |
|
||||
| `diary.Setup` | Parses `log.*` from a `compass` configuration, builds the channels and installs them; returns configuration errors. |
|
||||
| `diary.SetVerbosity` | Changes verbosity and quiet at runtime. |
|
||||
| `diary.Verbosity` | Returns the current verbosity. |
|
||||
| `diary.Reset` | Drops the setup, closes its files and restores the process default logger captured at the first setup. |
|
||||
| `diary.Close` | Closes the open daily files; later records open them again. |
|
||||
| `diary.Options` | Setup options: `Verbosity`, `Quiet`, `Color`, `Root`, `Stderr`, `Environ` and `Now`. |
|
||||
| `diary.ParseConfig` | Reads `log.*` over `diary.DefaultConfig`. |
|
||||
| `diary.DefaultConfig` | The framework defaults. |
|
||||
| `diary.Config` | Parsed configuration: `Default`, `Level`, `Channels` and `Components`. |
|
||||
| `diary.ChannelConfig` | One channel: `Driver`, `Format`, `Path`, `Level`, `HasLevel`, `Days` and `Channels`. |
|
||||
| `diary.NewConsoleHandler` | The styled console `slog.Handler`, for programs that build their own logger. |
|
||||
| `diary.ConsoleOptions` | Console handler options: `Level`, `Profile` and `AddSource`. |
|
||||
| `diary.ComponentKey` | The attribute key of the component, `component`. |
|
||||
| `diary.DriverConsole`, `diary.DriverStderr`, `diary.DriverDaily`, `diary.DriverStack`, `diary.DriverNull` | Channel driver names. |
|
||||
| `diary.FormatAuto`, `diary.FormatJSON`, `diary.FormatLogfmt` | Channel formats. |
|
||||
| `diary.DefaultKey`, `diary.LevelKey`, `diary.ChannelsKey`, `diary.ComponentsKey` | The configuration keys `log.default`, `log.level`, `log.channels` and `log.components`. |
|
||||
|
||||
## Configuration
|
||||
|
||||
Without a `config/log.yaml` an application uses these defaults:
|
||||
|
||||
```yaml
|
||||
# config/log.yaml
|
||||
default: stack
|
||||
level: info
|
||||
channels:
|
||||
stack:
|
||||
driver: stack
|
||||
channels: [console, daily]
|
||||
console:
|
||||
driver: console
|
||||
format: auto
|
||||
daily:
|
||||
driver: daily
|
||||
path: storage/logs/summer.log
|
||||
days: 14
|
||||
format: json
|
||||
level: warn
|
||||
components:
|
||||
river: warn
|
||||
```
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `log.default` | `stack` | The channel every record goes to. |
|
||||
| `log.level` | `info` | The minimum level of every component without its own level. |
|
||||
| `log.channels.<name>.driver` | | `console`, `daily` or `stack`. |
|
||||
| `log.channels.<name>.level` | none | The channel's own minimum level. |
|
||||
| `log.channels.<name>.format` | per driver | `auto` (console only), `json` or `logfmt`; console defaults to `auto`, daily to `json`. |
|
||||
| `log.channels.<name>.path` | | The daily file path, relative to the application root. |
|
||||
| `log.channels.<name>.days` | `0` | Daily retention in days; `0` keeps every file. |
|
||||
| `log.channels.<name>.channels` | | A stack's children. |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- SummerCMS modules: `bonfire` (palette and color detection), `compass` (configuration), `sunscreen` (redaction).
|
||||
- Third-party: `charm.land/lipgloss/v2`, `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`).
|
||||
- Standard library: `context`, `errors`, `fmt`, `io`, `io/fs`, `log`, `log/slog`, `maps`, `os`, `path/filepath`, `runtime`, `slices`, `strconv`, `strings`, `sync`, `sync/atomic`, `time`, `unicode`.
|
||||
|
||||
## Testing
|
||||
|
||||
```sh
|
||||
go test ./modules/diary/...
|
||||
```
|
||||
|
||||
`TestDiaryTracerSmoke` loads a `config/log.yaml` through `compass`, logs one record from a component logger and checks that it reaches the console and the daily file redacted. The tests write to temporary directories and need no external services.
|
||||
157
modules/diary/channels.go
Normal file
157
modules/diary/channels.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package diary
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/sunscreen"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
)
|
||||
|
||||
// noFloor is the level of a channel without its own level: every record the
|
||||
// component floors let through reaches it.
|
||||
const noFloor = slog.Level(-1 << 30)
|
||||
|
||||
// consoleLevel is a console channel's runtime level: base is the configured
|
||||
// level, which quiet raises to warn.
|
||||
type consoleLevel struct {
|
||||
lv *slog.LevelVar
|
||||
base slog.Level
|
||||
}
|
||||
|
||||
func (c consoleLevel) apply(quiet bool) {
|
||||
l := c.base
|
||||
if quiet && l < slog.LevelWarn {
|
||||
l = slog.LevelWarn
|
||||
}
|
||||
c.lv.Set(l)
|
||||
}
|
||||
|
||||
// channels is the built handler tree of one configuration.
|
||||
type channels struct {
|
||||
root slog.Handler
|
||||
consoles []consoleLevel
|
||||
closers []io.Closer
|
||||
source *atomic.Bool
|
||||
}
|
||||
|
||||
type channelBuilder struct {
|
||||
cfg Config
|
||||
opts Options
|
||||
out *channels
|
||||
visiting map[string]bool
|
||||
warnTo slog.Handler
|
||||
}
|
||||
|
||||
// buildChannels builds the channel named by cfg.Default. Every leaf is
|
||||
// wrapped by sunscreen.Wrap and gated by its own level; a stack fans out
|
||||
// through slog.NewMultiHandler.
|
||||
func buildChannels(cfg Config, opts Options) (*channels, error) {
|
||||
b := &channelBuilder{
|
||||
cfg: cfg,
|
||||
opts: opts,
|
||||
out: &channels{source: new(atomic.Bool)},
|
||||
visiting: map[string]bool{},
|
||||
}
|
||||
b.out.source.Store(opts.Verbosity >= 3)
|
||||
root, err := b.build(cfg.Default)
|
||||
if err != nil {
|
||||
for _, c := range b.out.closers {
|
||||
_ = c.Close()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
b.out.root = root
|
||||
for _, c := range b.out.consoles {
|
||||
c.apply(opts.Quiet)
|
||||
}
|
||||
return b.out, nil
|
||||
}
|
||||
|
||||
func (b *channelBuilder) build(name string) (slog.Handler, error) {
|
||||
ch, ok := b.cfg.Channels[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("diary: channel %q is not defined in %s", name, ChannelsKey)
|
||||
}
|
||||
if b.visiting[name] {
|
||||
return nil, fmt.Errorf("diary: %s.%s.channels includes itself", ChannelsKey, name)
|
||||
}
|
||||
b.visiting[name] = true
|
||||
defer delete(b.visiting, name)
|
||||
|
||||
lv := new(slog.LevelVar)
|
||||
base := noFloor
|
||||
if ch.HasLevel {
|
||||
base = ch.Level
|
||||
}
|
||||
lv.Set(base)
|
||||
hopts := &slog.HandlerOptions{Level: lv}
|
||||
|
||||
switch ch.Driver {
|
||||
case DriverConsole:
|
||||
w := b.opts.Stderr
|
||||
var leaf slog.Handler
|
||||
switch ch.Format {
|
||||
case FormatJSON:
|
||||
leaf = slog.NewJSONHandler(w, hopts)
|
||||
case FormatLogfmt:
|
||||
leaf = slog.NewTextHandler(w, hopts)
|
||||
default:
|
||||
profile := bonfire.DetectProfile(w, b.opts.Color, b.opts.Environ)
|
||||
if profile > colorprofile.NoTTY {
|
||||
leaf = newConsoleHandler(w, ConsoleOptions{Level: lv, Profile: profile}, b.out.source)
|
||||
} else {
|
||||
leaf = slog.NewTextHandler(w, hopts)
|
||||
}
|
||||
}
|
||||
b.out.consoles = append(b.out.consoles, consoleLevel{lv: lv, base: base})
|
||||
wrapped := sunscreen.Wrap(leaf)
|
||||
if b.warnTo == nil {
|
||||
b.warnTo = wrapped
|
||||
}
|
||||
return wrapped, nil
|
||||
case DriverDaily:
|
||||
path := ch.Path
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(b.opts.Root, filepath.Clean(path))
|
||||
}
|
||||
dw := newDailyWriter(path, ch.Days, b.opts.Now, b.warn)
|
||||
b.out.closers = append(b.out.closers, dw)
|
||||
var leaf slog.Handler
|
||||
if ch.Format == FormatLogfmt {
|
||||
leaf = slog.NewTextHandler(dw, hopts)
|
||||
} else {
|
||||
leaf = slog.NewJSONHandler(dw, hopts)
|
||||
}
|
||||
return sunscreen.Wrap(leaf), nil
|
||||
case DriverStack:
|
||||
children := make([]slog.Handler, 0, len(ch.Channels))
|
||||
for _, child := range ch.Channels {
|
||||
h, err := b.build(child)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
children = append(children, h)
|
||||
}
|
||||
return slog.NewMultiHandler(children...), nil
|
||||
}
|
||||
return nil, fmt.Errorf("diary: %s.%s.driver %q is not supported", ChannelsKey, name, ch.Driver)
|
||||
}
|
||||
|
||||
// warn reports a daily file that cannot be opened: one WARN record from
|
||||
// component diary on the first console channel, or on a plain stderr
|
||||
// handler when the configuration has no console channel.
|
||||
func (b *channelBuilder) warn(path string, err error) {
|
||||
h := b.warnTo
|
||||
if h == nil {
|
||||
h = sunscreen.Wrap(slog.NewTextHandler(b.opts.Stderr, nil))
|
||||
}
|
||||
r := slog.NewRecord(b.opts.Now(), slog.LevelWarn, "daily log file disabled", 0)
|
||||
r.AddAttrs(slog.String("path", path), slog.String("error", err.Error()))
|
||||
_ = h.WithAttrs([]slog.Attr{slog.String(ComponentKey, "diary")}).Handle(context.Background(), r)
|
||||
}
|
||||
138
modules/diary/component.go
Normal file
138
modules/diary/component.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package diary
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"slices"
|
||||
"sync/atomic"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/sunscreen"
|
||||
)
|
||||
|
||||
// componentHandler is the handler behind every diary logger. It holds only a
|
||||
// component name and the attributes and groups bound with With and
|
||||
// WithGroup; the channels it writes to are looked up when a record is
|
||||
// logged. A logger created in a package var block, before Setup, therefore
|
||||
// routes through the channels Setup installs later, and through a new set
|
||||
// after the next Setup or SetVerbosity.
|
||||
type componentHandler struct {
|
||||
name string
|
||||
ops []handlerOp
|
||||
cache *atomic.Pointer[boundHandler]
|
||||
}
|
||||
|
||||
// handlerOp is one WithAttrs or WithGroup call, replayed in order onto the
|
||||
// live root handler.
|
||||
type handlerOp struct {
|
||||
group string
|
||||
attrs []slog.Attr
|
||||
}
|
||||
|
||||
type boundHandler struct {
|
||||
gen uint64
|
||||
h slog.Handler
|
||||
}
|
||||
|
||||
func newComponentHandler(name string) *componentHandler {
|
||||
return &componentHandler{name: name, cache: new(atomic.Pointer[boundHandler])}
|
||||
}
|
||||
|
||||
// fallbackHandler writes to stderr through sunscreen when there is no diary
|
||||
// state and the process default is itself a diary logger.
|
||||
var fallbackHandler = sunscreen.Wrap(slog.NewTextHandler(os.Stderr, nil))
|
||||
|
||||
// processDefault is the handler records go to before diary is set up: the
|
||||
// process default logger's handler, unless that is a diary handler, which
|
||||
// would recurse.
|
||||
func processDefault() slog.Handler {
|
||||
h := slog.Default().Handler()
|
||||
if _, ok := h.(*componentHandler); ok {
|
||||
return fallbackHandler
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *componentHandler) Enabled(ctx context.Context, l slog.Level) bool {
|
||||
st := current.Load()
|
||||
if st == nil {
|
||||
return processDefault().Enabled(ctx, l)
|
||||
}
|
||||
return l >= st.floor(h.name) && st.root.Enabled(ctx, l)
|
||||
}
|
||||
|
||||
func (h *componentHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
st := current.Load()
|
||||
if st == nil {
|
||||
return h.bind(processDefault()).Handle(ctx, r)
|
||||
}
|
||||
b := h.cache.Load()
|
||||
if b == nil || b.gen != st.gen {
|
||||
b = &boundHandler{gen: st.gen, h: h.bind(st.root)}
|
||||
h.cache.Store(b)
|
||||
}
|
||||
return b.h.Handle(ctx, r)
|
||||
}
|
||||
|
||||
// bind applies the component attribute, then the recorded operations, to
|
||||
// base. The component goes first so it stays a top-level attribute even
|
||||
// when groups are open.
|
||||
func (h *componentHandler) bind(base slog.Handler) slog.Handler {
|
||||
out := base
|
||||
if h.name != "" {
|
||||
out = out.WithAttrs([]slog.Attr{slog.String(ComponentKey, h.name)})
|
||||
}
|
||||
for _, op := range h.ops {
|
||||
if op.group != "" {
|
||||
out = out.WithGroup(op.group)
|
||||
} else {
|
||||
out = out.WithAttrs(op.attrs)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *componentHandler) hasGroups() bool {
|
||||
for _, op := range h.ops {
|
||||
if op.group != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WithAttrs records attrs. A top-level string attribute keyed "component"
|
||||
// rebinds the component name instead, so slog.Default().With("component",
|
||||
// "x") behaves like For("x").
|
||||
func (h *componentHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
if len(attrs) == 0 {
|
||||
return h
|
||||
}
|
||||
c := &componentHandler{name: h.name, ops: slices.Clip(h.ops), cache: new(atomic.Pointer[boundHandler])}
|
||||
rest := attrs
|
||||
if !h.hasGroups() {
|
||||
rest = make([]slog.Attr, 0, len(attrs))
|
||||
for _, a := range attrs {
|
||||
if a.Key == ComponentKey && a.Value.Kind() == slog.KindString {
|
||||
c.name = a.Value.String()
|
||||
continue
|
||||
}
|
||||
rest = append(rest, a)
|
||||
}
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
c.ops = append(c.ops, handlerOp{attrs: slices.Clone(rest)})
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (h *componentHandler) WithGroup(name string) slog.Handler {
|
||||
if name == "" {
|
||||
return h
|
||||
}
|
||||
return &componentHandler{
|
||||
name: h.name,
|
||||
ops: append(slices.Clip(h.ops), handlerOp{group: name}),
|
||||
cache: new(atomic.Pointer[boundHandler]),
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
257
modules/diary/console.go
Normal file
257
modules/diary/console.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package diary
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
)
|
||||
|
||||
// componentWidth is the column width of the component prefix.
|
||||
const componentWidth = 10
|
||||
|
||||
// ConsoleOptions configures NewConsoleHandler.
|
||||
type ConsoleOptions struct {
|
||||
// Level is the minimum level; nil means info.
|
||||
Level slog.Leveler
|
||||
// Profile is the color profile applied to the output; NoTTY writes no
|
||||
// escape codes at all.
|
||||
Profile colorprofile.Profile
|
||||
// AddSource appends the caller's file:line to each line.
|
||||
AddSource bool
|
||||
}
|
||||
|
||||
// NewConsoleHandler returns a slog.Handler that writes one styled line per
|
||||
// record to w in bonfire's palette: time, level, the component attribute as
|
||||
// a fixed-width prefix, the message, then key=value attributes with groups
|
||||
// as dotted key prefixes. Control characters other than tab are removed from
|
||||
// the message and attribute values, so logged data cannot inject terminal
|
||||
// escape sequences. Each line is written with one Write call, serialized
|
||||
// across the handler and its WithAttrs and WithGroup clones.
|
||||
func NewConsoleHandler(w io.Writer, opts ConsoleOptions) slog.Handler {
|
||||
source := new(atomic.Bool)
|
||||
source.Store(opts.AddSource)
|
||||
return newConsoleHandler(w, opts, source)
|
||||
}
|
||||
|
||||
func newConsoleHandler(w io.Writer, opts ConsoleOptions, source *atomic.Bool) *consoleHandler {
|
||||
level := opts.Level
|
||||
if level == nil {
|
||||
level = slog.LevelInfo
|
||||
}
|
||||
return &consoleHandler{
|
||||
out: &colorprofile.Writer{Forward: w, Profile: opts.Profile},
|
||||
mu: new(sync.Mutex),
|
||||
level: level,
|
||||
source: source,
|
||||
palette: bonfire.DefaultPalette(),
|
||||
}
|
||||
}
|
||||
|
||||
type consoleAttr struct {
|
||||
key string
|
||||
value string
|
||||
}
|
||||
|
||||
type consoleHandler struct {
|
||||
out io.Writer
|
||||
mu *sync.Mutex
|
||||
level slog.Leveler
|
||||
source *atomic.Bool
|
||||
palette bonfire.Palette
|
||||
component string
|
||||
attrs []consoleAttr
|
||||
groups []string
|
||||
}
|
||||
|
||||
func (h *consoleHandler) Enabled(_ context.Context, l slog.Level) bool {
|
||||
return l >= h.level.Level()
|
||||
}
|
||||
|
||||
func (h *consoleHandler) clone() *consoleHandler {
|
||||
c := *h
|
||||
c.attrs = h.attrs[:len(h.attrs):len(h.attrs)]
|
||||
c.groups = h.groups[:len(h.groups):len(h.groups)]
|
||||
return &c
|
||||
}
|
||||
|
||||
func (h *consoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
if len(attrs) == 0 {
|
||||
return h
|
||||
}
|
||||
c := h.clone()
|
||||
prefix := groupPrefix(c.groups)
|
||||
for _, a := range attrs {
|
||||
if len(c.groups) == 0 && a.Key == ComponentKey && a.Value.Kind() == slog.KindString {
|
||||
c.component = a.Value.String()
|
||||
continue
|
||||
}
|
||||
c.attrs = appendAttr(c.attrs, prefix, a)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (h *consoleHandler) WithGroup(name string) slog.Handler {
|
||||
if name == "" {
|
||||
return h
|
||||
}
|
||||
c := h.clone()
|
||||
c.groups = append(c.groups, name)
|
||||
return c
|
||||
}
|
||||
|
||||
func (h *consoleHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
component := h.component
|
||||
prefix := groupPrefix(h.groups)
|
||||
var recAttrs []consoleAttr
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
if len(h.groups) == 0 && a.Key == ComponentKey && a.Value.Kind() == slog.KindString {
|
||||
component = a.Value.String()
|
||||
return true
|
||||
}
|
||||
recAttrs = appendAttr(recAttrs, prefix, a)
|
||||
return true
|
||||
})
|
||||
|
||||
p := h.palette
|
||||
var b strings.Builder
|
||||
if !r.Time.IsZero() {
|
||||
b.WriteString(p.Muted.Render(r.Time.Format(time.TimeOnly)))
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
label, style := levelLabel(r.Level, p)
|
||||
b.WriteString(style.Render(label))
|
||||
b.WriteString(strings.Repeat(" ", max(0, 5-len(label))+1))
|
||||
name := sanitize(component)
|
||||
if name != "" {
|
||||
b.WriteString(p.Header.Render(name))
|
||||
}
|
||||
b.WriteString(strings.Repeat(" ", max(0, componentWidth-len(name))+1))
|
||||
b.WriteString(sanitize(r.Message))
|
||||
for _, list := range [][]consoleAttr{h.attrs, recAttrs} {
|
||||
for _, a := range list {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(p.Muted.Render(a.key + "="))
|
||||
b.WriteString(a.value)
|
||||
}
|
||||
}
|
||||
if h.source != nil && h.source.Load() && r.PC != 0 {
|
||||
frame, _ := runtime.CallersFrames([]uintptr{r.PC}).Next()
|
||||
if frame.File != "" {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(p.Muted.Render(filepath.Base(frame.File) + ":" + strconv.Itoa(frame.Line)))
|
||||
}
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
_, err := io.WriteString(h.out, b.String())
|
||||
return err
|
||||
}
|
||||
|
||||
func levelLabel(l slog.Level, p bonfire.Palette) (string, lipgloss.Style) {
|
||||
switch {
|
||||
case l < slog.LevelInfo:
|
||||
return l.String(), p.Muted
|
||||
case l < slog.LevelWarn:
|
||||
return l.String(), p.Current
|
||||
case l < slog.LevelError:
|
||||
return l.String(), p.Warning
|
||||
default:
|
||||
return l.String(), p.ErrorBold
|
||||
}
|
||||
}
|
||||
|
||||
func groupPrefix(groups []string) string {
|
||||
if len(groups) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(groups, ".") + "."
|
||||
}
|
||||
|
||||
// appendAttr flattens a into key=value pairs: empty attributes are skipped,
|
||||
// groups add their key as a dotted prefix, and groups with an empty key are
|
||||
// inlined (testing/slogtest semantics).
|
||||
func appendAttr(out []consoleAttr, prefix string, a slog.Attr) []consoleAttr {
|
||||
a.Value = a.Value.Resolve()
|
||||
if a.Equal(slog.Attr{}) {
|
||||
return out
|
||||
}
|
||||
if a.Value.Kind() == slog.KindGroup {
|
||||
group := a.Value.Group()
|
||||
if len(group) == 0 {
|
||||
return out
|
||||
}
|
||||
inner := prefix
|
||||
if a.Key != "" {
|
||||
inner = prefix + a.Key + "."
|
||||
}
|
||||
for _, g := range group {
|
||||
out = appendAttr(out, inner, g)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return append(out, consoleAttr{key: sanitize(prefix + a.Key), value: formatValue(a.Value)})
|
||||
}
|
||||
|
||||
func formatValue(v slog.Value) string {
|
||||
var s string
|
||||
switch v.Kind() {
|
||||
case slog.KindString:
|
||||
s = v.String()
|
||||
case slog.KindTime:
|
||||
s = v.Time().Format(time.RFC3339)
|
||||
case slog.KindAny:
|
||||
if err, ok := v.Any().(error); ok {
|
||||
s = err.Error()
|
||||
} else {
|
||||
s = fmt.Sprintf("%+v", v.Any())
|
||||
}
|
||||
default:
|
||||
s = v.String()
|
||||
}
|
||||
s = sanitize(s)
|
||||
if s == "" || strings.ContainsAny(s, " \t\"=") {
|
||||
return strconv.Quote(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// sanitize removes control characters other than tab; line breaks become
|
||||
// spaces so a multi-line value stays on one line.
|
||||
func sanitize(s string) string {
|
||||
clean := true
|
||||
for _, r := range s {
|
||||
if r != '\t' && unicode.IsControl(r) {
|
||||
clean = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if clean {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '\n' || r == '\r':
|
||||
b.WriteByte(' ')
|
||||
case r != '\t' && unicode.IsControl(r):
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
152
modules/diary/daily.go
Normal file
152
modules/diary/daily.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package diary
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dateLayout = "2006-01-02"
|
||||
|
||||
// dailyWriter writes to one file per local date: path storage/logs/summer.log
|
||||
// gives storage/logs/summer-2026-10-21.log. Files open lazily on the first
|
||||
// write (no empty files), are created 0o640 in directories created 0o750,
|
||||
// and on every open the files of the same base older than the retention are
|
||||
// deleted. If a file cannot be opened the writer reports once through warn
|
||||
// and is disabled: later writes succeed without writing anything.
|
||||
type dailyWriter struct {
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
base string
|
||||
ext string
|
||||
days int
|
||||
now func() time.Time
|
||||
warn func(path string, err error)
|
||||
file *os.File
|
||||
date string
|
||||
disabled bool
|
||||
}
|
||||
|
||||
func newDailyWriter(path string, days int, now func() time.Time, warn func(string, error)) *dailyWriter {
|
||||
dir, name := filepath.Split(path)
|
||||
ext := filepath.Ext(name)
|
||||
if dir == "" {
|
||||
dir = "."
|
||||
}
|
||||
return &dailyWriter{
|
||||
dir: filepath.Clean(dir),
|
||||
base: strings.TrimSuffix(name, ext),
|
||||
ext: ext,
|
||||
days: days,
|
||||
now: now,
|
||||
warn: warn,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dailyWriter) fileName(date string) string {
|
||||
return filepath.Join(d.dir, d.base+"-"+date+d.ext)
|
||||
}
|
||||
|
||||
// Write appends p to the current day's file in one write call.
|
||||
func (d *dailyWriter) Write(p []byte) (int, error) {
|
||||
d.mu.Lock()
|
||||
if d.disabled {
|
||||
d.mu.Unlock()
|
||||
return len(p), nil
|
||||
}
|
||||
date := d.now().Local().Format(dateLayout)
|
||||
if d.file == nil || date != d.date {
|
||||
if err := d.open(date); err != nil {
|
||||
d.disabled = true
|
||||
warn := d.warn
|
||||
name := d.fileName(date)
|
||||
d.mu.Unlock()
|
||||
if warn != nil {
|
||||
warn(name, err)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
}
|
||||
n, err := d.file.Write(p)
|
||||
d.mu.Unlock()
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (d *dailyWriter) open(date string) error {
|
||||
if d.file != nil {
|
||||
_ = d.file.Close()
|
||||
d.file = nil
|
||||
}
|
||||
if err := os.MkdirAll(d.dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
name := d.fileName(date)
|
||||
_, statErr := os.Stat(name)
|
||||
f, err := os.OpenFile(name, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if errors.Is(statErr, fs.ErrNotExist) {
|
||||
// The process umask may strip bits; new log files are 0o640 exactly.
|
||||
_ = f.Chmod(0o640)
|
||||
}
|
||||
d.file, d.date = f, date
|
||||
d.prune(date)
|
||||
return nil
|
||||
}
|
||||
|
||||
// prune deletes files named base-YYYY-MM-DD+ext whose date falls outside the
|
||||
// retention window: with days N the files of today and the N-1 days before
|
||||
// it are kept. Other files in the directory are never touched.
|
||||
func (d *dailyWriter) prune(today string) {
|
||||
if d.days <= 0 {
|
||||
return
|
||||
}
|
||||
now, err := time.ParseInLocation(dateLayout, today, time.Local)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := now.AddDate(0, 0, -d.days)
|
||||
entries, err := os.ReadDir(d.dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
prefix := d.base + "-"
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
rest, ok := strings.CutPrefix(name, prefix)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
stamp, ok := strings.CutSuffix(rest, d.ext)
|
||||
if !ok || len(stamp) != len(dateLayout) {
|
||||
continue
|
||||
}
|
||||
day, err := time.ParseInLocation(dateLayout, stamp, time.Local)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !day.After(cutoff) {
|
||||
_ = os.Remove(filepath.Join(d.dir, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the open file. A later write opens it again.
|
||||
func (d *dailyWriter) Close() error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.file == nil {
|
||||
return nil
|
||||
}
|
||||
err := d.file.Close()
|
||||
d.file, d.date = nil, ""
|
||||
return err
|
||||
}
|
||||
233
modules/diary/diary.go
Normal file
233
modules/diary/diary.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// Package diary is the application logging pipeline, modelled on Laravel's
|
||||
// logging config (D-08): config/log.yaml names a default channel, a global
|
||||
// level and named channels (console, stderr, daily files, stacks, null), and
|
||||
// every channel redacts through sunscreen. Code logs through named component
|
||||
// loggers (D-09): For("journal") returns a logger whose records carry the
|
||||
// component and pass its level from log.components, and which is safe to
|
||||
// create in a package var before Setup runs.
|
||||
package diary
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
)
|
||||
|
||||
// ComponentKey is the attribute that carries a record's component.
|
||||
const ComponentKey = "component"
|
||||
|
||||
// Options configures Bootstrap and Setup.
|
||||
type Options struct {
|
||||
// Verbosity lowers every component floor: 1 to at most info, 2 or more
|
||||
// to debug; 3 or more also adds source locations on the console.
|
||||
Verbosity int
|
||||
// Quiet raises the console channels to warn; files keep their levels.
|
||||
Quiet bool
|
||||
// Color decides on styled console output (bonfire.ColorAuto by default).
|
||||
Color bonfire.ColorMode
|
||||
// Root is the application root that relative daily paths resolve
|
||||
// against; empty means the working directory.
|
||||
Root string
|
||||
// Stderr is where console channels write; nil means os.Stderr.
|
||||
Stderr io.Writer
|
||||
// Environ is used to detect color support; nil means os.Environ().
|
||||
Environ []string
|
||||
// Now is the clock of the daily writers; nil means time.Now.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (o Options) normalize() Options {
|
||||
if o.Stderr == nil {
|
||||
o.Stderr = os.Stderr
|
||||
}
|
||||
if o.Root == "" {
|
||||
o.Root = "."
|
||||
}
|
||||
if o.Now == nil {
|
||||
o.Now = time.Now
|
||||
}
|
||||
if o.Environ == nil {
|
||||
o.Environ = os.Environ()
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// state is one installed configuration. It is replaced as a whole; only the
|
||||
// console level vars and the source switch change in place.
|
||||
type state struct {
|
||||
gen uint64
|
||||
root slog.Handler
|
||||
level slog.Level
|
||||
verbosity int
|
||||
quiet bool
|
||||
channels *channels
|
||||
}
|
||||
|
||||
// floor is the minimum level for records of component name.
|
||||
func (s *state) floor(name string) slog.Level {
|
||||
f := s.level
|
||||
switch {
|
||||
case s.verbosity >= 2:
|
||||
f = min(f, slog.LevelDebug)
|
||||
case s.verbosity >= 1:
|
||||
f = min(f, slog.LevelInfo)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
type savedDefault struct {
|
||||
logger *slog.Logger
|
||||
writer io.Writer
|
||||
flags int
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
current atomic.Pointer[state]
|
||||
gens atomic.Uint64
|
||||
verbosity atomic.Int64
|
||||
saved *savedDefault
|
||||
)
|
||||
|
||||
// For returns the logger of a component, normally a plugin or module code
|
||||
// without dots (koanf splits config keys on dots). Its records carry the
|
||||
// attribute component=<name> and pass the component's level. Before Bootstrap
|
||||
// or Setup they go to the process default logger's handler.
|
||||
func For(component string) *slog.Logger {
|
||||
return slog.New(newComponentHandler(component))
|
||||
}
|
||||
|
||||
// Bootstrap installs a console channel on w, before configuration is read,
|
||||
// and makes diary the process default logger. The generated main calls it
|
||||
// first; Setup replaces it once config/log.yaml is loaded.
|
||||
func Bootstrap(w io.Writer, opts Options) {
|
||||
if w == nil {
|
||||
w = os.Stderr
|
||||
}
|
||||
opts.Stderr = w
|
||||
opts = opts.normalize()
|
||||
cfg := DefaultConfig()
|
||||
cfg.Default = "console"
|
||||
cfg.Channels = map[string]ChannelConfig{"console": {Driver: DriverConsole, Format: FormatAuto}}
|
||||
ch, err := buildChannels(cfg, opts)
|
||||
if err != nil {
|
||||
// A console-only configuration has nothing that can fail; keep the
|
||||
// previous state if it ever does.
|
||||
return
|
||||
}
|
||||
install(cfg, ch, opts)
|
||||
}
|
||||
|
||||
// Setup parses log.* from cfg (ParseConfig), builds the channels and makes
|
||||
// them the target of every diary logger and of the process default logger.
|
||||
// A configuration error is returned and leaves the previous setup in place;
|
||||
// a daily file that cannot be opened never fails Setup.
|
||||
func Setup(cfg *compass.Config, opts Options) error {
|
||||
conf, err := ParseConfig(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts = opts.normalize()
|
||||
ch, err := buildChannels(conf, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
install(conf, ch, opts)
|
||||
return nil
|
||||
}
|
||||
|
||||
func install(conf Config, ch *channels, opts Options) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if saved == nil {
|
||||
saved = &savedDefault{logger: slog.Default(), writer: log.Writer(), flags: log.Flags()}
|
||||
}
|
||||
verbosity.Store(int64(opts.Verbosity))
|
||||
st := &state{
|
||||
gen: gens.Add(1),
|
||||
root: ch.root,
|
||||
level: conf.Level,
|
||||
verbosity: opts.Verbosity,
|
||||
quiet: opts.Quiet,
|
||||
channels: ch,
|
||||
}
|
||||
old := current.Swap(st)
|
||||
slog.SetDefault(slog.New(newComponentHandler("")))
|
||||
if old != nil {
|
||||
closeChannels(old.channels)
|
||||
}
|
||||
}
|
||||
|
||||
// SetVerbosity changes the verbosity and quiet switches at runtime: 1 lets
|
||||
// info through for every component, 2 or more debug, 3 or more adds source
|
||||
// locations on styled console lines. Quiet raises only the console channels
|
||||
// to warn. Before Bootstrap or Setup it only changes what Verbosity returns;
|
||||
// Options.Verbosity and Options.Quiet set the values of a new setup.
|
||||
func SetVerbosity(v int, quiet bool) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
verbosity.Store(int64(v))
|
||||
st := current.Load()
|
||||
if st == nil {
|
||||
return
|
||||
}
|
||||
next := *st
|
||||
next.verbosity, next.quiet = v, quiet
|
||||
for _, c := range st.channels.consoles {
|
||||
c.apply(quiet)
|
||||
}
|
||||
st.channels.source.Store(v >= 3)
|
||||
current.Store(&next)
|
||||
}
|
||||
|
||||
// Verbosity returns the current verbosity (0 by default).
|
||||
func Verbosity() int {
|
||||
return int(verbosity.Load())
|
||||
}
|
||||
|
||||
// Close closes the open daily files. Later records open them again.
|
||||
func Close() error {
|
||||
st := current.Load()
|
||||
if st == nil {
|
||||
return nil
|
||||
}
|
||||
return closeChannels(st.channels)
|
||||
}
|
||||
|
||||
func closeChannels(ch *channels) error {
|
||||
if ch == nil {
|
||||
return nil
|
||||
}
|
||||
var errs []error
|
||||
for _, c := range ch.closers {
|
||||
errs = append(errs, c.Close())
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Reset drops the installed setup, closes its files and restores the process
|
||||
// default logger (and the standard log package's output) captured at the
|
||||
// first Bootstrap or Setup. Tests call it in t.Cleanup.
|
||||
func Reset() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
old := current.Swap(nil)
|
||||
verbosity.Store(0)
|
||||
if saved != nil {
|
||||
slog.SetDefault(saved.logger)
|
||||
log.SetOutput(saved.writer)
|
||||
log.SetFlags(saved.flags)
|
||||
saved = nil
|
||||
}
|
||||
if old != nil {
|
||||
closeChannels(old.channels)
|
||||
}
|
||||
}
|
||||
103
modules/diary/tracer_smoke_test.go
Normal file
103
modules/diary/tracer_smoke_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package diary_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/diary"
|
||||
)
|
||||
|
||||
// earlyLogger is created before any Setup, the way modules create their
|
||||
// component loggers in package var blocks.
|
||||
var earlyLogger = diary.For("x")
|
||||
|
||||
// TestDiaryTracerSmoke: one record from a component logger flows from
|
||||
// config/log.yaml through compass into a redacting stack of a console and a
|
||||
// daily file.
|
||||
func TestDiaryTracerSmoke(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
configDir := filepath.Join(tmp, "config")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logYAML := `default: stack
|
||||
level: info
|
||||
channels:
|
||||
stack:
|
||||
driver: stack
|
||||
channels: [console, daily]
|
||||
console:
|
||||
driver: console
|
||||
format: auto
|
||||
daily:
|
||||
driver: daily
|
||||
path: storage/logs/summer.log
|
||||
days: 14
|
||||
format: json
|
||||
level: info
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(configDir, "log.yaml"), []byte(logYAML), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: configDir, Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
now := func() time.Time { return time.Date(2026, 10, 21, 12, 0, 0, 0, time.Local) }
|
||||
t.Cleanup(diary.Reset)
|
||||
if err := diary.Setup(cfg, diary.Options{Root: tmp, Stderr: &buf, Color: bonfire.ColorNever, Now: now}); err != nil {
|
||||
t.Fatalf("Setup: %v", err)
|
||||
}
|
||||
|
||||
diary.For("journal").Info("post saved", "password", "hunter2", "id", 7)
|
||||
earlyLogger.Info("early logger reached")
|
||||
|
||||
console := buf.String()
|
||||
for _, want := range []string{"component=journal", "post saved", "[REDACTED]", "component=x", "early logger reached"} {
|
||||
if !strings.Contains(console, want) {
|
||||
t.Errorf("console output lacks %q:\n%s", want, console)
|
||||
}
|
||||
}
|
||||
if strings.Contains(console, "hunter2") {
|
||||
t.Errorf("console output leaks the password:\n%s", console)
|
||||
}
|
||||
|
||||
if err := diary.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
file := filepath.Join(tmp, "storage", "logs", "summer-2026-10-21.log")
|
||||
info, err := os.Stat(file)
|
||||
if err != nil {
|
||||
t.Fatalf("daily file: %v", err)
|
||||
}
|
||||
if mode := info.Mode().Perm(); mode != 0o640 {
|
||||
t.Errorf("daily file mode = %o, want 640", mode)
|
||||
}
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(raw, []byte("hunter2")) {
|
||||
t.Errorf("daily file leaks the password:\n%s", raw)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("daily file has %d lines, want 2:\n%s", len(lines), raw)
|
||||
}
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal([]byte(lines[0]), &rec); err != nil {
|
||||
t.Fatalf("daily line is not JSON: %v\n%s", err, lines[0])
|
||||
}
|
||||
if rec["component"] != "journal" || rec["password"] != "[REDACTED]" || rec["msg"] != "post saved" {
|
||||
t.Errorf("daily record = %v, want component journal, password [REDACTED], msg post saved", rec)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user