feat(diary): channels, components and config/log.yaml

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

View File

@@ -15,7 +15,7 @@ WinterCMS reads configuration with `Config::get('app.name')` from `config/*.php`
1. Plugin defaults, merged with `compass.Config.MergePlugin` when the plugin is activated. A plugin's `config/config.yaml` becomes `<plugin id>.<key>`, so `acme.blog.posts_per_page` is the WinterCMS `acme.blog::posts_per_page`. Any other `config/<name>.yaml` becomes `<plugin id>.<name>.<key>`.
2. `config/*.yaml`. Each file is a section named after the file, so `config/app.yaml` provides `app.*`.
3. `config/env/<environment>/*.yaml`, the per-environment sections.
4. `SUMMER_` environment variables and the `.env` file next to `config/`. `SUMMER_MAIL__DRIVER` sets `mail.driver`: the prefix is removed, `__` separates the path segments and the name is lower-cased. A `.env` value applies only when the real environment does not set the same variable.
4. `SUMMER_` environment variables and the `.env` file next to `config/`. `SUMMER_MAIL__DRIVER` sets `mail.driver`: the prefix is removed, `__` separates the path segments and the name is lower-cased. A `.env` value applies only when the real environment does not set the same variable. Values arrive as strings; modules that expect numbers or lists either parse them (`SUMMER_LOG__CHANNELS__DAILY__DAYS=30` and `SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily` work for the [logging](logging.md) keys) or document that the key belongs in YAML.
5. `config/env/<environment>/overrides.yaml`, written by `compass.Config.Persist`.
6. Values set in memory with `compass.Config.Set`.

View File

@@ -1,18 +1,105 @@
---
title: Logging
description: Log through log/slog with sunscreen, the redacting handler every generated main installs, and keep tokens, keys and request bodies out of log records.
description: Configure log channels in config/log.yaml, log through diary component loggers and keep credentials out of log records.
section: services
order: 105
---
# Logging
SummerCMS logs through the standard library's `log/slog`. Where a WinterCMS plugin calls `Log::error` and relies on a Monolog tap to scrub credentials, a SummerCMS plugin logs through a `*slog.Logger`, and [sunscreen](../../modules/sunscreen/README.md) does the scrubbing.
SummerCMS logs through the standard library's `log/slog`, configured by [diary](../../modules/diary/README.md). Where a WinterCMS application lists channels in `config/logging.php` and calls `Log::error`, a SummerCMS application lists channels in `config/log.yaml` and logs through a component logger from `diary.For`. Every channel redacts credentials through [sunscreen](../../modules/sunscreen/README.md).
## The redacting default logger
The application `main` that `summer build` generates calls `diary.Bootstrap(os.Stderr, diary.Options{})` as the first statement of `run`, so even configuration errors are logged, and `diary.Setup` right after `compass` has loaded `config/`. From then on component loggers, `slog.Default`, the package-level `slog` functions and the standard `log` package all write through the configured channels.
Every application `main` that `summer build` generates calls `sunscreen.InstallDefault(os.Stderr)` as the first statement of `run`, before the configuration is loaded and before any plugin is activated. From then on `slog.Default`, the package-level `slog` functions and the standard `log` package all write through a text handler wrapped by `sunscreen.Wrap`.
## Channels
The handler rewrites every record before it is written:
A channel is a named destination with a driver:
| Driver | Writes |
|--------|--------|
| `console` | One line per record to stderr. With `format: auto` (the default) the line is styled when stderr is a terminal (time, level, the component as a fixed-width prefix, the message, `key=value` attributes) and logfmt otherwise, for systemd and Docker; `format: json` writes JSON lines. |
| `stderr` | logfmt (the default) or JSON to stderr, never styled. |
| `daily` | One file per day, JSON by default or logfmt; see [Daily files](#daily-files). |
| `stack` | Every record to each channel listed under `channels`. |
| `null` | Nothing. |
Each channel may set its own `level`; a record reaches a channel when it passes both its component's level and the channel's level.
## config/log.yaml
`summer new` writes this file, and an application without it gets the same values as framework 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
http: info
```
`default` names the channel every record goes to, `level` is the global minimum, `channels` defines the channels and `components` sets levels per component (the `http: info` line only spells out what `level: info` already gives). It is the shape of Laravel's `config/logging.php` (`default`, `channels` with a `driver` each, `stack` and `daily` drivers, `days` for retention), written in YAML. The framework defaults also define a `stderr` channel (logfmt) and a `null` channel, so `default: stderr` or `default: null` works without defining them.
Like any key, these can be overridden from the environment with a double underscore between path segments, and string values are accepted where YAML would give a number or a list:
```sh
SUMMER_LOG__CHANNELS__DAILY__DAYS=30
SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily
SUMMER_LOG__COMPONENTS__RIVER=error
```
An override changes one key and keeps every other default. An unknown driver, format or level, a stack that names a missing channel or contains itself, and a daily path outside the application root stop the start-up with an error that names the key, for example `diary: log.channels.daily.days must be a whole number of at least 0`.
## Component loggers
Code logs through the logger of its component:
```go src=modules/diary/example_test.go#ExampleFor
// Before diary.Setup runs, records go to the process default logger.
// This one drops the time so the output is stable.
previous := slog.Default()
defer slog.SetDefault(previous)
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
},
})))
// After diary.Setup the same record reaches the configured channels and
// passes log.components.blog, else log.level.
blogLog.InfoContext(context.Background(), "post published", "id", 7)
// Output:
// level=INFO msg="post published" component=blog id=7
```
Every record carries the attribute `component=blog`, and passes the component's level: `log.components.blog` when it is set, `log.level` otherwise. The name is the plugin or module code without dots, because configuration keys split on dots (`acme.blog` would nest). Framework modules use their package name (`cabana`, `lagoon`, `conga`), River logs as `river` and GORM as `db`. `diary.SetVerbosity` lowers every component to info (1) or debug (2 and more) at runtime.
`diary.For` is safe in a package `var` block: before `diary.Setup` runs, its records go to the process default logger, and after it they reach the configured channels. Records logged through `slog.Default` carry no component and use `log.level`.
## Daily files
A daily channel with `path: storage/logs/summer.log` writes `storage/logs/summer-2026-10-21.log` today and `storage/logs/summer-2026-10-22.log` after local midnight. A relative path resolves against the application root, the directory that holds `config/`, and may not leave it; an absolute path is used as given. Files are created with mode 0640 in directories created with mode 0750, because logs carry user identifiers, and a file is only created once a record reaches it. With `days: 14` the files of today and the 13 days before are kept and older files of the same name pattern are deleted; other files in the directory are never touched. `days: 0` keeps every file.
If the directory cannot be created or the file cannot be opened (a read-only container filesystem, missing permissions), the channel logs one warning from component `diary` on the console naming the path and the error, and is disabled. The start-up never fails because of a log file.
## Redaction
Every channel, console, stderr, daily and null alike, is wrapped by `sunscreen.Wrap`, which rewrites every record before it is written:
- The value of an attribute whose key is one of `sunscreen.RedactedKeys` becomes `[REDACTED]`, at any group depth and whatever the letter case: `api_key`, `apikey`, `authorization`, `bearer`, `password`, `secret`, `token`, `webhook_secret`, `admin_password` and the `openai_api_key`, `anthropic_api_key` and `perplexity_api_key` variants.
- The message, string values, error values and the formatted text of other values are passed through `sunscreen.Scrub`, which replaces `Bearer <token>`, `sk-` style API keys and `x-api-key: <value>`.
@@ -39,6 +126,8 @@ logger.Info("vendor call failed",
// level=INFO msg="vendor call failed" api_key=[REDACTED] request.Authorization=[REDACTED] err="401 for key sk-[REDACTED]"
```
`sunscreen.InstallDefault` remains for programs that install their own default logger instead of diary.
## Logging from a plugin
A plugin resolves a `*slog.Logger` from the application with `backpack.Lookup` and falls back to `slog.Default` when none is published, as the framework modules do. Either way the record goes through the redacting handler.

View File

@@ -37,7 +37,7 @@ Any key can be overridden with an environment variable. Take the dot path, repla
A `.env` file next to the `config/` directory supplies `KEY=VALUE` lines for variables that are not already set in the real environment. Keep secrets in the environment or in `.env`, never in committed YAML.
Environment values are strings. Keys that must be numbers, such as `http.body_limits.default_bytes`, belong in a YAML file.
Environment values are strings. Keys that must be numbers, such as `http.body_limits.default_bytes`, belong in a YAML file. The `log.*` keys accept strings: `SUMMER_LOG__CHANNELS__DAILY__DAYS=30` keeps daily log files for 30 days, and `SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily` sets a list.
The layers merge in this order, each overriding the ones before it: plugin defaults, `config/*.yaml`, `config/env/<environment>/*.yaml`, `SUMMER_` variables, then runtime overrides that a command saved to `config/env/<environment>/overrides.yaml`.
@@ -59,6 +59,7 @@ These are the keys most applications set. Each module's reference page lists all
| `cache.driver`, `cache.prefix`, `cache.memory.max_entries` | The cache driver (`memory`, the default, or `redis`), the key prefix (default from `app.name`) and the memory driver's size bound (default 10000). | [cooler](../../modules/cooler/README.md) |
| `site_cache.purge_url`, `site_cache.purge_token` | The host site's page-cache purge route and its bearer token (at least 32 characters). Set both or neither; http only to a loopback host. Keep the token in `SUMMER_SITE_CACHE__PURGE_TOKEN`. | [cooler](../../modules/cooler/README.md) |
| `redis.url` | Redis URL (`redis://`, `rediss://` or `unix://`), required when a Redis cache or rate-limit store is selected. Keep it in `SUMMER_REDIS__URL` when it carries a password. | [cooler](../../modules/cooler/README.md) |
| `log.default`, `log.level`, `log.channels.*`, `log.components.*` | Logging: the default channel (`stack` of `console` and `daily`), the global level (`info`), the channels (`driver`, `level`, `format`, `path`, `days`, `channels`) and per-component levels. Daily files go to `storage/logs/` and are kept 14 days. See [Logging](../services/logging.md). | [diary](../../modules/diary/README.md) |
| `http.access_log` | Logs one record per request with method, matched route pattern, status and duration. Defaults to `false`. | [surf](../../modules/surf/README.md) |
| `http.error_pages.enabled` | HTML error pages for browser navigations (defaults to `true`); request details appear when `app.debug` is true or outside production. | [surf](../../modules/surf/README.md) |
| `storage.uploads.bucket_url` | Uploads bucket, `file://`, `mem://` or `s3://` (the last only in a binary built with the `summercms_s3` tag from `build.tags` in `summer.yaml`; credentials from the AWS environment). Required by `serve`. | [lagoon](../../modules/lagoon/README.md) |

View File

@@ -389,6 +389,7 @@ func planNewApp(opts NewOptions, target, framework string) ([]genFile, error) {
{"config/database.yaml", "new/config/database.yaml", 0o644},
{"config/http.yaml", "new/config/http.yaml", 0o644},
{"config/storage.yaml", "new/config/storage.yaml", 0o644},
{"config/log.yaml", "new/config/log.yaml", 0o644},
{".env", "new/env", 0o600},
{".env.example", "new/env.example", 0o644},
{"compose.yaml", "new/compose.yaml", 0o644},

View File

@@ -133,7 +133,7 @@ func TestNewGolden(t *testing.T) {
dir, out := newGoldenApp(t, false)
for _, rel := range []string{
"go.mod", "go.work", "summer.yaml", "main.go", "plugins.gen.go",
"config/app.yaml", "config/database.yaml", "config/http.yaml", "config/storage.yaml",
"config/app.yaml", "config/database.yaml", "config/http.yaml", "config/storage.yaml", "config/log.yaml",
".env", ".env.example", ".gitignore", "compose.yaml", "CLAUDE.md",
"plugins/demo/plugin.go", "plugins/demo/routes.go",
} {

View File

@@ -34,6 +34,34 @@ uploads:
public_path_prefix: "/storage/uploads"
{{end}}
{{define "new/config/log.yaml"}}# Logging, shaped like Laravel's config/logging.php: records go to the
# default channel, and a stack sends them to several channels at once.
# Override any key with a SUMMER_LOG__ variable, a double underscore
# separating path segments: SUMMER_LOG__CHANNELS__DAILY__DAYS=30.
default: stack
level: info
channels:
stack:
driver: stack
channels: [console, daily]
console:
# auto: styled lines on a terminal, logfmt otherwise (systemd, Docker);
# json writes JSON lines instead.
driver: console
format: auto
daily:
# One file per day under storage/logs (summer-YYYY-MM-DD.log), kept 14 days.
driver: daily
path: storage/logs/summer.log
days: 14
format: json
level: warn
components:
# Per-component levels. http: warn silences the request log.
river: warn
http: info
{{end}}
{{define "new/env"}}# Local secrets for this application: the database DSN, the application key
# and the admin token secret. .gitignore excludes this file; never commit it.
# Real environment variables override the values here.
@@ -123,7 +151,7 @@ summer admin:create --email you@example.com --superuser # create a backend admi
- `summer.yaml`: the application manifest (module path, binary name, ordered plugin list).
- `plugins/`: local plugin modules.{{if not .NoPlugin}} `plugins/{{.Name}}` is the starter plugin `golem15.{{.Name}}`; its `routes.go` answers `GET /` with JSON.{{end}}
- `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`).
- `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `log.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`).
- `.env`: local secrets (database DSN, application key, admin token secret). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables.
- `compose.yaml`: the local PostgreSQL 16.
- `main.go`, `plugins.gen.go`: generated by `summer build` from `summer.yaml`. Do not edit them by hand; they are committed.

View File

@@ -31,7 +31,7 @@ summer admin:create --email you@example.com --superuser # create a backend admi
- `summer.yaml`: the application manifest (module path, binary name, ordered plugin list).
- `plugins/`: local plugin modules.
- `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`).
- `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `log.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`).
- `.env`: local secrets (database DSN, application key, admin token secret). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables.
- `compose.yaml`: the local PostgreSQL 16.
- `main.go`, `plugins.gen.go`: generated by `summer build` from `summer.yaml`. Do not edit them by hand; they are committed.

View File

@@ -31,7 +31,7 @@ summer admin:create --email you@example.com --superuser # create a backend admi
- `summer.yaml`: the application manifest (module path, binary name, ordered plugin list).
- `plugins/`: local plugin modules. `plugins/demo` is the starter plugin `golem15.demo`; its `routes.go` answers `GET /` with JSON.
- `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`).
- `config/`: YAML configuration (`app.yaml`, `database.yaml`, `http.yaml`, `log.yaml`, `storage.yaml`). Any key can be overridden with a `SUMMER_` environment variable, a double underscore separating path segments (`SUMMER_DATABASE__DSN` sets `database.dsn`).
- `.env`: local secrets (database DSN, application key, admin token secret). Never commit it; `.gitignore` excludes it. `.env.example` lists the variables.
- `compose.yaml`: the local PostgreSQL 16.
- `main.go`, `plugins.gen.go`: generated by `summer build` from `summer.yaml`. Do not edit them by hand; they are committed.

View File

@@ -0,0 +1,26 @@
# Logging, shaped like Laravel's config/logging.php: records go to the
# default channel, and a stack sends them to several channels at once.
# Override any key with a SUMMER_LOG__ variable, a double underscore
# separating path segments: SUMMER_LOG__CHANNELS__DAILY__DAYS=30.
default: stack
level: info
channels:
stack:
driver: stack
channels: [console, daily]
console:
# auto: styled lines on a terminal, logfmt otherwise (systemd, Docker);
# json writes JSON lines instead.
driver: console
format: auto
daily:
# One file per day under storage/logs (summer-YYYY-MM-DD.log), kept 14 days.
driver: daily
path: storage/logs/summer.log
days: 14
format: json
level: warn
components:
# Per-component levels. http: warn silences the request log.
river: warn
http: info

View File

@@ -25,9 +25,12 @@ Use cases:
## 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.
- 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.
- 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.
- 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.
- The process default logger (`slog.Default`) and the standard `log` package log through the configured channels without a component.
@@ -95,7 +98,7 @@ Tests that set diary up restore the previous process logger with `t.Cleanup(diar
## Configuration
Without a `config/log.yaml` an application uses these defaults:
Without a `config/log.yaml` an application uses these defaults (`summer new` writes the same file, plus `http: info`):
```yaml
# config/log.yaml
@@ -118,16 +121,21 @@ 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. |
| `log.channels.<name>.driver` | | `console`, `daily` or `stack`. |
| `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` | 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. |
| `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
@@ -141,4 +149,4 @@ components:
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.
`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.

View File

@@ -48,9 +48,10 @@ type channelBuilder struct {
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.
// buildChannels builds the channel named by cfg.Default. Every leaf
// (console, stderr, daily and null) is wrapped by sunscreen.Wrap and gated by
// its own level; a stack fans out through slog.NewMultiHandler. A relative
// daily path resolves against Options.Root.
func buildChannels(cfg Config, opts Options) (*channels, error) {
b := &channelBuilder{
cfg: cfg,
@@ -115,6 +116,16 @@ func (b *channelBuilder) build(name string) (slog.Handler, error) {
b.warnTo = wrapped
}
return wrapped, nil
case DriverStderr:
var leaf slog.Handler
if ch.Format == FormatJSON {
leaf = slog.NewJSONHandler(b.opts.Stderr, hopts)
} else {
leaf = slog.NewTextHandler(b.opts.Stderr, hopts)
}
return sunscreen.Wrap(leaf), nil
case DriverNull:
return sunscreen.Wrap(slog.DiscardHandler), nil
case DriverDaily:
path := ch.Path
if !filepath.IsAbs(path) {

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"log/slog"
"maps"
"path/filepath"
"slices"
"strconv"
"strings"
@@ -84,7 +85,9 @@ type ChannelConfig struct {
// 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.
// 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",
@@ -92,6 +95,8 @@ func DefaultConfig() Config {
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",
@@ -150,6 +155,23 @@ func ParseConfig(cfg *compass.Config) (Config, error) {
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
}
@@ -204,11 +226,11 @@ func (c Config) validate() error {
ch := c.Channels[name]
key := ChannelsKey + "." + name
switch ch.Driver {
case DriverConsole, DriverDaily, DriverStack:
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, daily, stack", key, ch.Driver)
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:
@@ -219,8 +241,48 @@ func (c Config) validate() error {
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)
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

View File

@@ -0,0 +1,135 @@
package diary_test
import (
"bytes"
"log/slog"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/diary"
)
// openConfig opens a compass config from an empty config directory with
// only the given environment.
func openConfig(t *testing.T, environ ...string) *compass.Config {
t.Helper()
dir := filepath.Join(t.TempDir(), "config")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: append([]string{}, environ...)})
if err != nil {
t.Fatal(err)
}
return cfg
}
// TestConfigEnvSmoke: SUMMER_LOG__ overrides arrive as strings, parse, and
// overlay the framework defaults key by key; every invalid value fails with
// an error naming its key.
func TestConfigEnvSmoke(t *testing.T) {
cfg := openConfig(t,
"SUMMER_LOG__CHANNELS__DAILY__DAYS=30",
"SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily",
"SUMMER_LOG__COMPONENTS__RIVER=error",
)
got, err := diary.ParseConfig(cfg)
if err != nil {
t.Fatalf("ParseConfig: %v", err)
}
want := diary.DefaultConfig()
daily := want.Channels["daily"]
daily.Days = 30
want.Channels["daily"] = daily
want.Components["river"] = slog.LevelError
if !reflect.DeepEqual(got, want) {
t.Errorf("parsed config:\n got %+v\nwant %+v", got, want)
}
if ch := got.Channels["stack"].Channels; !reflect.DeepEqual(ch, []string{"console", "daily"}) {
t.Errorf("stack children = %q, want console, daily", ch)
}
errorCases := []struct {
name string
environ []string
key string
}{
{"level", []string{"SUMMER_LOG__LEVEL=loud"}, "log.level"},
{"default", []string{"SUMMER_LOG__DEFAULT=missing"}, "log.default"},
{"driver", []string{"SUMMER_LOG__CHANNELS__DAILY__DRIVER=syslog"}, "log.channels.daily.driver"},
{"driver required", []string{"SUMMER_LOG__CHANNELS__EXTRA__LEVEL=info"}, "log.channels.extra.driver"},
{"format", []string{"SUMMER_LOG__CHANNELS__CONSOLE__FORMAT=xml"}, "log.channels.console.format"},
{"auto outside console", []string{"SUMMER_LOG__CHANNELS__DAILY__FORMAT=auto"}, "log.channels.daily.format"},
{"channel level", []string{"SUMMER_LOG__CHANNELS__CONSOLE__LEVEL=verbose"}, "log.channels.console.level"},
{"days", []string{"SUMMER_LOG__CHANNELS__DAILY__DAYS=-1"}, "log.channels.daily.days"},
{"days text", []string{"SUMMER_LOG__CHANNELS__DAILY__DAYS=two"}, "log.channels.daily.days"},
{"path escapes root", []string{"SUMMER_LOG__CHANNELS__DAILY__PATH=../outside/summer.log"}, "log.channels.daily.path"},
{"stack child", []string{"SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,nowhere"}, "log.channels.stack.channels"},
{"stack cycle", []string{
"SUMMER_LOG__DEFAULT=a",
"SUMMER_LOG__CHANNELS__A__DRIVER=stack",
"SUMMER_LOG__CHANNELS__A__CHANNELS=console,b",
"SUMMER_LOG__CHANNELS__B__DRIVER=stack",
"SUMMER_LOG__CHANNELS__B__CHANNELS=a",
}, "log.channels.a.channels"},
{"component level", []string{"SUMMER_LOG__COMPONENTS__RIVER=chatty"}, "log.components.river"},
}
for _, tc := range errorCases {
_, err := diary.ParseConfig(openConfig(t, tc.environ...))
if err == nil {
t.Errorf("%s: ParseConfig accepted %q", tc.name, tc.environ)
continue
}
if !strings.Contains(err.Error(), tc.key) {
t.Errorf("%s: error %q does not name %s", tc.name, err, tc.key)
}
}
}
// TestDailyDegradesSmoke: a daily channel whose directory cannot be created
// warns once on the console, is disabled, and never fails Setup or stops
// later records from reaching the console.
func TestDailyDegradesSmoke(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root ignores directory permissions")
}
root := t.TempDir()
readOnly := filepath.Join(root, "readonly")
if err := os.Mkdir(readOnly, 0o555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(readOnly, 0o755) })
cfg := openConfig(t, "SUMMER_LOG__CHANNELS__DAILY__PATH=readonly/logs/summer.log")
var buf bytes.Buffer
t.Cleanup(diary.Reset)
if err := diary.Setup(cfg, diary.Options{Root: root, Stderr: &buf, Color: bonfire.ColorNever}); err != nil {
t.Fatalf("Setup failed because of the log file: %v", err)
}
logger := diary.For("journal")
logger.Warn("first warning")
logger.Warn("second warning")
logger.Info("still logging")
out := buf.String()
if n := strings.Count(out, "daily log file disabled"); n != 1 {
t.Errorf("daily warning appears %d times, want 1:\n%s", n, out)
}
if !strings.Contains(out, filepath.Join(readOnly, "logs")) {
t.Errorf("warning does not name the path:\n%s", out)
}
if !strings.Contains(out, "component=diary") {
t.Errorf("warning does not come from component diary:\n%s", out)
}
for _, want := range []string{"first warning", "second warning", "still logging"} {
if !strings.Contains(out, want) {
t.Errorf("console lacks %q after the daily channel failed:\n%s", want, out)
}
}
}

View File

@@ -14,8 +14,8 @@ import (
"time"
"unicode"
"git.golem15.com/golem15/summercms/modules/bonfire"
"charm.land/lipgloss/v2"
"git.golem15.com/golem15/summercms/modules/bonfire"
"github.com/charmbracelet/colorprofile"
)

View File

@@ -63,17 +63,23 @@ func (o Options) normalize() Options {
// 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
gen uint64
root slog.Handler
level slog.Level
components map[string]slog.Level
verbosity int
quiet bool
channels *channels
}
// floor is the minimum level for records of component name.
// floor is the minimum level for records of component name: its
// log.components level, else log.level, lowered by verbosity. Records without
// a component use log.level.
func (s *state) floor(name string) slog.Level {
f := s.level
if l, ok := s.components[name]; ok && name != "" {
f = l
}
switch {
case s.verbosity >= 2:
f = min(f, slog.LevelDebug)
@@ -152,12 +158,13 @@ func install(conf Config, ch *channels, opts Options) {
}
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,
gen: gens.Add(1),
root: ch.root,
level: conf.Level,
components: conf.Components,
verbosity: opts.Verbosity,
quiet: opts.Quiet,
channels: ch,
}
old := current.Swap(st)
slog.SetDefault(slog.New(newComponentHandler("")))

View File

@@ -0,0 +1,34 @@
package diary_test
import (
"context"
"log/slog"
"os"
"git.golem15.com/golem15/summercms/modules/diary"
)
// blogLog is the component logger of a blog plugin, created once in a
// package var block, before diary is set up.
var blogLog = diary.For("blog")
func ExampleFor() {
// Before diary.Setup runs, records go to the process default logger.
// This one drops the time so the output is stable.
previous := slog.Default()
defer slog.SetDefault(previous)
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
},
})))
// After diary.Setup the same record reaches the configured channels and
// passes log.components.blog, else log.level.
blogLog.InfoContext(context.Background(), "post published", "id", 7)
// Output:
// level=INFO msg="post published" component=blog id=7
}