From 5b09b325125a217ef505723f038d28c1e43ce73b Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 21 Oct 2026 11:48:25 +0200 Subject: [PATCH] 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 --- docs/services/configuration.md | 2 +- docs/services/logging.md | 99 ++++++++++++- docs/setup/configuration.md | 3 +- internal/build/new.go | 1 + internal/build/new_golden_test.go | 2 +- internal/build/stubs/new.tmpl | 30 +++- .../testdata/new-no-plugin/CLAUDE.md.golden | 2 +- internal/build/testdata/new/CLAUDE.md.golden | 2 +- .../testdata/new/config__log.yaml.golden | 26 ++++ modules/diary/README.md | 28 ++-- modules/diary/channels.go | 17 ++- modules/diary/config.go | 72 +++++++++- modules/diary/config_smoke_test.go | 135 ++++++++++++++++++ modules/diary/console.go | 2 +- modules/diary/diary.go | 33 +++-- modules/diary/example_test.go | 34 +++++ 16 files changed, 445 insertions(+), 43 deletions(-) create mode 100644 internal/build/testdata/new/config__log.yaml.golden create mode 100644 modules/diary/config_smoke_test.go create mode 100644 modules/diary/example_test.go diff --git a/docs/services/configuration.md b/docs/services/configuration.md index 0393cff0..4e0d62d4 100644 --- a/docs/services/configuration.md +++ b/docs/services/configuration.md @@ -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 `.`, so `acme.blog.posts_per_page` is the WinterCMS `acme.blog::posts_per_page`. Any other `config/.yaml` becomes `..`. 2. `config/*.yaml`. Each file is a section named after the file, so `config/app.yaml` provides `app.*`. 3. `config/env//*.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//overrides.yaml`, written by `compass.Config.Persist`. 6. Values set in memory with `compass.Config.Set`. diff --git a/docs/services/logging.md b/docs/services/logging.md index 35f5c98b..2f91c8d9 100644 --- a/docs/services/logging.md +++ b/docs/services/logging.md @@ -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 `, `sk-` style API keys and `x-api-key: `. @@ -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. diff --git a/docs/setup/configuration.md b/docs/setup/configuration.md index 9be9fa8a..0b2b63eb 100644 --- a/docs/setup/configuration.md +++ b/docs/setup/configuration.md @@ -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//*.yaml`, `SUMMER_` variables, then runtime overrides that a command saved to `config/env//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) | diff --git a/internal/build/new.go b/internal/build/new.go index a2b915ba..67735ccb 100644 --- a/internal/build/new.go +++ b/internal/build/new.go @@ -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}, diff --git a/internal/build/new_golden_test.go b/internal/build/new_golden_test.go index 13cc8db2..9df84725 100644 --- a/internal/build/new_golden_test.go +++ b/internal/build/new_golden_test.go @@ -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", } { diff --git a/internal/build/stubs/new.tmpl b/internal/build/stubs/new.tmpl index de08f4d9..477fa320 100644 --- a/internal/build/stubs/new.tmpl +++ b/internal/build/stubs/new.tmpl @@ -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. diff --git a/internal/build/testdata/new-no-plugin/CLAUDE.md.golden b/internal/build/testdata/new-no-plugin/CLAUDE.md.golden index 970e6028..d9543c8b 100644 --- a/internal/build/testdata/new-no-plugin/CLAUDE.md.golden +++ b/internal/build/testdata/new-no-plugin/CLAUDE.md.golden @@ -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. diff --git a/internal/build/testdata/new/CLAUDE.md.golden b/internal/build/testdata/new/CLAUDE.md.golden index 43fc8947..383db2de 100644 --- a/internal/build/testdata/new/CLAUDE.md.golden +++ b/internal/build/testdata/new/CLAUDE.md.golden @@ -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. diff --git a/internal/build/testdata/new/config__log.yaml.golden b/internal/build/testdata/new/config__log.yaml.golden new file mode 100644 index 00000000..ea3132b9 --- /dev/null +++ b/internal/build/testdata/new/config__log.yaml.golden @@ -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 diff --git a/modules/diary/README.md b/modules/diary/README.md index 126c8d86..4ba98dfb 100644 --- a/modules/diary/README.md +++ b/modules/diary/README.md @@ -25,9 +25,12 @@ Use cases: ## Features - Component loggers: `diary.For` returns a `*slog.Logger` whose records carry the attribute `component=`. 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..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..driver` | required for a new channel | `console`, `stderr`, `daily`, `stack` or `null`. | | `log.channels..level` | none | The channel's own minimum level. | -| `log.channels..format` | per driver | `auto` (console only), `json` or `logfmt`; console defaults to `auto`, daily to `json`. | -| `log.channels..path` | | The daily file path, relative to the application root. | -| `log.channels..days` | `0` | Daily retention in days; `0` keeps every file. | -| `log.channels..channels` | | A stack's children. | +| `log.channels..format` | `auto` for console, `logfmt` for stderr, `json` for daily | `auto` (console only: styled on a terminal, logfmt otherwise), `json` or `logfmt`. | +| `log.channels..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..days` | `14` for `daily` | Retention in days, a whole number of at least 0; `0` keeps every file. | +| `log.channels..channels` | `[console, daily]` for `stack` | A stack's children, as a YAML list or a comma-separated string. | +| `log.components.` | `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. diff --git a/modules/diary/channels.go b/modules/diary/channels.go index d30ad4b3..b3ba1a69 100644 --- a/modules/diary/channels.go +++ b/modules/diary/channels.go @@ -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) { diff --git a/modules/diary/config.go b/modules/diary/config.go index d23f3ab2..c9c6dcee 100644 --- a/modules/diary/config.go +++ b/modules/diary/config.go @@ -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 diff --git a/modules/diary/config_smoke_test.go b/modules/diary/config_smoke_test.go new file mode 100644 index 00000000..fd8b06a5 --- /dev/null +++ b/modules/diary/config_smoke_test.go @@ -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) + } + } +} diff --git a/modules/diary/console.go b/modules/diary/console.go index 3e001af9..11e21aa4 100644 --- a/modules/diary/console.go +++ b/modules/diary/console.go @@ -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" ) diff --git a/modules/diary/diary.go b/modules/diary/diary.go index c282764b..3e96aa53 100644 --- a/modules/diary/diary.go +++ b/modules/diary/diary.go @@ -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(""))) diff --git a/modules/diary/example_test.go b/modules/diary/example_test.go new file mode 100644 index 00000000..1b089369 --- /dev/null +++ b/modules/diary/example_test.go @@ -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 +}