Files
summercms/modules/diary/README.md
Jakub Zych c38dc1fe48 feat(surf): serve startup summary, shutdown report and http request log
- serve prints a startup summary on a terminal (version, environment, URL,
  admin, database host and name, pending migrations, plugins, drivers) and
  logs it as a 'startup summary' record plus 'listening on <addr>'
- on SIGINT/SIGTERM serve drains, prints and logs a shutdown report and
  returns nil (exit 0), or the drain errors (exit 1)
- the request log is on by default under diary component http with
  request_id; the route pattern, never the path, is logged
- lagoon.PendingMigrations, conga.Worker.Stats, diary.HideOnTTY,
  bonfire.IsTerminal and bonfire.Details back the summary and report
2026-10-21 12:59:07 +02:00

161 lines
11 KiB
Markdown

# 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 reads the console global options first (`bonfire.PeekGlobals`), calls `diary.Bootstrap` with them, which installs a console channel before configuration is read, and `diary.Setup` right after `compass` loads `config/`. Once the command line is parsed, `diary.ApplyGlobals` re-applies `-v`, `-q` and `--ansi`/`--no-ansi`.
## 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. River logs as component `river` (warn by default), GORM as `db` (through `lagoon`), and each framework module under its package name: `cabana`, `lagoon`, `attach`, `conga`, `cooler`, `postcard`, `beachcomber`, `lighthouse`, `centrifugo`, `flare`, `phrasebook`, `wristband` and `bouncer`. Modules that let the application publish its own `*slog.Logger` (`conga`, `cooler`, `postcard`, `beachcomber`, `lighthouse`, `flare`) still prefer it.
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`), `stderr` (logfmt or JSON, never styled), `daily` (one file per day), `stack` (several channels at once, nesting allowed) and `null` (discards), each with its own level.
- Component levels: `log.components` sets a minimum level per component (`river: warn`, `blog: debug`); components without one use `log.level`.
- Environment overrides: `SUMMER_LOG__` variables overlay the defaults key by key, and string values parse where YAML gives numbers or lists (`SUMMER_LOG__CHANNELS__DAILY__DAYS=30`, `SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily`).
- Validation: an unknown driver, format or level, a stack naming a missing channel or containing itself, and a relative daily path that leaves the application root fail `diary.Setup` with an error naming the key.
- 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. While a bonfire live view (spinner, progress bar, process view) runs, styled lines print above it through `bonfire.PrintAbove`.
- 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 (only files of that name pattern). A relative path resolves against the application root. A directory that cannot be written logs one warning from component `diary` on the console and disables the channel; it never fails the start-up.
- 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.
- Console global options: `diary.FromGlobals` turns `-v`/`-vv`/`-vvv`, `-q` and `--ansi`/`--no-ansi` into `diary.Options`, and `diary.ApplyGlobals` applies them to the running setup (the generated main registers it with `bonfire.OnGlobals`).
- 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) int {
g := bonfire.PeekGlobals(args)
diary.Bootstrap(os.Stderr, diary.FromGlobals(g))
cfg, err := compass.Open(compass.Options{Dir: "config", Env: g.Env})
if err != nil {
return bonfire.Fail(os.Stderr, "acme", err, g)
}
if err := diary.Setup(cfg, diary.FromGlobals(g)); err != nil {
return bonfire.Fail(os.Stderr, "acme", err, g)
}
// ...
root, err := bonfire.NewRoot("acme", commands, out,
bonfire.WithVersion(bonfire.BuildVersion()), bonfire.OnGlobals(diary.ApplyGlobals))
// ...
}
```
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.FromGlobals` | Returns the `diary.Options` (verbosity, quiet, color mode) of the console global options in a `bonfire.Globals`. |
| `diary.ApplyGlobals` | Applies parsed `bonfire.Globals` to the running setup: verbosity and quiet, and the console color mode when it changed. |
| `diary.Verbosity` | Returns the current verbosity. |
| `diary.HideOnTTY` | Marks a context so a console channel writing to a terminal skips the records logged with it; files, stderr channels and a console that is not a terminal still write them. Used for facts a command has just printed as a block, such as `serve`'s startup summary. |
| `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 (`summer new` writes the same file, plus `http: info`):
```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
```
The defaults also define `stderr` (`driver: stderr`, `format: logfmt`) and `null` (`driver: null`).
| 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, and of records without a component. `debug`, `info`, `warn` (or `warning`) and `error`, in any letter case. |
| `log.channels.<name>.driver` | required for a new channel | `console`, `stderr`, `daily`, `stack` or `null`. |
| `log.channels.<name>.level` | none | The channel's own minimum level. |
| `log.channels.<name>.format` | `auto` for console, `logfmt` for stderr, `json` for daily | `auto` (console only: styled on a terminal, logfmt otherwise), `json` or `logfmt`. |
| `log.channels.<name>.path` | `storage/logs/summer.log` for `daily` | The daily file path; a relative path resolves against the application root and may not leave it. |
| `log.channels.<name>.days` | `14` for `daily` | Retention in days, a whole number of at least 0; `0` keeps every file. |
| `log.channels.<name>.channels` | `[console, daily]` for `stack` | A stack's children, as a YAML list or a comma-separated string. |
| `log.components.<name>` | `river: warn` | The minimum level of a component. Component names cannot contain dots. |
Environment variables use the `SUMMER_LOG__` prefix with a double underscore between path segments: `SUMMER_LOG__COMPONENTS__RIVER=error`.
## 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`), `golang.org/x/term` (whether a console channel writes to a terminal, for `diary.HideOnTTY`).
- 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. `TestConfigEnvSmoke` parses `SUMMER_LOG__` overrides given as strings and checks that every invalid value fails with an error naming its key. `TestDailyDegradesSmoke` points the daily channel at a read-only directory and checks that it warns once and the console keeps logging. The tests write to temporary directories and need no external services.