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:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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",
|
||||
} {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
2
internal/build/testdata/new/CLAUDE.md.golden
vendored
2
internal/build/testdata/new/CLAUDE.md.golden
vendored
@@ -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.
|
||||
|
||||
26
internal/build/testdata/new/config__log.yaml.golden
vendored
Normal file
26
internal/build/testdata/new/config__log.yaml.golden
vendored
Normal 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
135
modules/diary/config_smoke_test.go
Normal file
135
modules/diary/config_smoke_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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("")))
|
||||
|
||||
34
modules/diary/example_test.go
Normal file
34
modules/diary/example_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user