feat(diary): component loggers through a redacting console and daily stack

- New diary module: For, Bootstrap, Setup, SetVerbosity, Reset, Close, ParseConfig
- Proxy component handler safe in package vars; styled console handler in bonfire's palette
- Daily writer: one file per day, 0640, lazy open, retention, disables itself on open failure
- Every channel leaf wrapped by sunscreen.Wrap; stack via slog.NewMultiHandler
- bonfire: ColorMode, Palette, DefaultPalette, DetectProfile (lipgloss v2, colorprofile)
- Generated main runs diary.Bootstrap first and diary.Setup after compass.Load
- Ignore /storage/ and /examples/hello/storage/
This commit is contained in:
Jakub Zych
2026-10-21 11:41:48 +02:00
parent e6039b445f
commit e903cc32be
18 changed files with 1676 additions and 23 deletions

4
.gitignore vendored
View File

@@ -1,6 +1,10 @@
# Go
/bin/
/examples/hello/bin/
# Application log files (diary daily channel)
/storage/
/examples/hello/storage/
/dist/
/site/
*.test

View File

@@ -101,6 +101,7 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands
| [compass](modules/compass/README.md) | Layered YAML configuration with per-environment directories, `SUMMER_` environment overrides, embedded plugin defaults and dot-path access. | Points you the right way; configuration tells the application how to behave in each environment. |
| [conga](modules/conga/README.md) | Background jobs on River over the shared Postgres pool: transactional dispatch, a `summer_jobs` progress record, in-process or dedicated workers, and a wall-clock scheduler. | A line of dancers moving one after another; jobs queue up and are worked through in order. |
| [cooler](modules/cooler/README.md) | Cache drivers and shared counters for SummerCMS: memory and Redis caches with a key prefix and TTLs, a Redis-backed rate-limit store, and the site-cache purge after admin saves. | The cool box you bring to the beach; it keeps things fresh and at hand for everyone in the group. |
| [diary](modules/diary/README.md) | Application logging modelled on Laravel's logging config: named channels (console, stderr, daily files, stacks), per-component levels and redaction on every channel. | A beach diary records each day; the log keeps one file per day. |
| [festival](modules/festival/README.md) | Typed, synchronous event bus with listener priorities, payload collection and stop-when-handled dispatch. | A summer festival announces events and the crowd reacts; it is the event bus. |
| [fetchguard](modules/fetchguard/README.md) | Guarded outbound HTTP client and fetcher that blocks private and reserved addresses, enforces host, size and timeout limits, and offers an explicit trusted mode for operator-configured endpoints. | A lifeguard for outbound fetches; it watches every request the application sends out and stops the dangerous ones. |
| [flare](modules/flare/README.md) | Web Push delivery with VAPID (RFC 8292) and aes128gcm payload encryption (RFC 8291) behind a small Pusher interface. | A signal flare reaches people far away; it sends Web Push notifications to browsers. |

View File

@@ -12,10 +12,10 @@ import (
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/conga"
"git.golem15.com/golem15/summercms/modules/diary"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/sunscreen"
"git.golem15.com/golem15/summercms/modules/surf"
)
@@ -27,11 +27,14 @@ func main() {
}
func run(args []string, out io.Writer) error {
sunscreen.InstallDefault(os.Stderr)
diary.Bootstrap(os.Stderr, diary.Options{})
cfg, err := compass.Load("config")
if err != nil {
return err
}
if err := diary.Setup(cfg, diary.Options{}); err != nil {
return err
}
app := backpack.New(cfg)
plugins, err := party.Activate(app, PluginIDs)
if err != nil {

14
go.mod
View File

@@ -6,8 +6,10 @@ go 1.27.0
ignore ./admin/node_modules
require (
charm.land/lipgloss/v2 v2.0.6
github.com/alecthomas/chroma/v2 v2.27.0
github.com/alicebob/miniredis/v2 v2.40.0
github.com/charmbracelet/colorprofile v0.4.3
github.com/disintegration/imaging v1.6.2
github.com/fsnotify/fsnotify v1.10.1
github.com/go-gormigrate/gormigrate/v2 v2.1.7
@@ -68,6 +70,13 @@ require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
@@ -100,8 +109,10 @@ require (
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
@@ -113,12 +124,14 @@ require (
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/riverqueue/river/riverdriver v0.47.0 // indirect
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 // indirect
github.com/riverqueue/river/rivershared v0.47.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spf13/pflag v1.0.9 // indirect
@@ -129,6 +142,7 @@ require (
github.com/tidwall/sjson v1.2.5 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect

30
go.sum
View File

@@ -1,5 +1,7 @@
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
@@ -86,6 +88,22 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -211,10 +229,14 @@ github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w=
@@ -245,6 +267,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@@ -270,6 +294,8 @@ github.com/riverqueue/river/rivershared v0.47.0 h1:jdtFsBexCvLqTXf8wnDnGXvB/eeOt
github.com/riverqueue/river/rivershared v0.47.0/go.mod h1:w8Pi1T+6ypyko5/hs9Mv7IIIKo4fAL9eXYnkVV/Y418=
github.com/riverqueue/river/rivertype v0.47.0 h1:SzNavtLGR4nMT1QkrEYQ7n96OMatYsn/z3aJWhewmv0=
github.com/riverqueue/river/rivertype v0.47.0/go.mod h1:XKkcRQR6zm8RR/JQa1Q2ywpj8uXQu21quPa4Lpw1Xhw=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -311,6 +337,8 @@ github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqo
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM=
github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
@@ -350,6 +378,8 @@ gocloud.dev v0.46.0 h1:niIuZwSjMtBx8K+ITB2s5kZullB13PGOS2ZoQPZxQ4Q=
gocloud.dev v0.46.0/go.mod h1:ACQe+2qO+hEO+pdcvvsM+RB63r8TyGD1W3ESCLFyzvM=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=

View File

@@ -104,10 +104,10 @@ func generateMain(m Manifest) ([]byte, error) {
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/cabana\"\n")
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/compass\"\n")
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/conga\"\n")
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/diary\"\n")
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/lagoon\"\n")
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/pact\"\n")
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/party\"\n")
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/sunscreen\"\n")
b.WriteString("\t\"git.golem15.com/golem15/summercms/modules/surf\"\n")
b.WriteString(")\n\n")
b.WriteString("func main() {\n")
@@ -117,11 +117,14 @@ func generateMain(m Manifest) ([]byte, error) {
b.WriteString("\t}\n")
b.WriteString("}\n\n")
b.WriteString("func run(args []string, out io.Writer) error {\n")
b.WriteString("\tsunscreen.InstallDefault(os.Stderr)\n")
b.WriteString("\tdiary.Bootstrap(os.Stderr, diary.Options{})\n")
b.WriteString("\tcfg, err := compass.Load(\"config\")\n")
b.WriteString("\tif err != nil {\n")
b.WriteString("\t\treturn err\n")
b.WriteString("\t}\n")
b.WriteString("\tif err := diary.Setup(cfg, diary.Options{}); err != nil {\n")
b.WriteString("\t\treturn err\n")
b.WriteString("\t}\n")
b.WriteString("\tapp := backpack.New(cfg)\n")
b.WriteString("\tplugins, err := party.Activate(app, PluginIDs)\n")
b.WriteString("\tif err != nil {\n")

View File

@@ -142,29 +142,40 @@ func TestGenerateMainRegistersCongaRuntimeCommands(t *testing.T) {
}
}
// TestGenerateMainInstallsRedactingLogger: the first statement of run makes
// the credential-redacting handler the default logger, before config loads
// or any plugin can log.
func TestGenerateMainInstallsRedactingLogger(t *testing.T) {
// TestGenerateMainInstallsDiary: the first statement of run installs the
// diary console logger, before config loads or any plugin can log, and
// diary.Setup applies config/log.yaml right after compass loads it and before
// the application container exists.
func TestGenerateMainInstallsDiary(t *testing.T) {
mainSrc, err := generateMain(Manifest{Module: "example.com/app", Binary: "hello"})
if err != nil {
t.Fatal(err)
}
line := []byte("sunscreen.InstallDefault(os.Stderr)")
if n := bytes.Count(mainSrc, line); n != 1 {
t.Fatalf("InstallDefault count = %d, want 1\n%s", n, mainSrc)
bootstrap := []byte("diary.Bootstrap(os.Stderr, diary.Options{})")
setup := []byte("diary.Setup(cfg, diary.Options{})")
if n := bytes.Count(mainSrc, bootstrap); n != 1 {
t.Fatalf("Bootstrap count = %d, want 1\n%s", n, mainSrc)
}
if !bytes.Contains(mainSrc, []byte(`"git.golem15.com/golem15/summercms/modules/sunscreen"`)) {
t.Fatalf("generated main does not import sunscreen:\n%s", mainSrc)
if n := bytes.Count(mainSrc, setup); n != 1 {
t.Fatalf("Setup count = %d, want 1\n%s", n, mainSrc)
}
install := bytes.Index(mainSrc, line)
runStart := bytes.Index(mainSrc, []byte("func run(args []string, out io.Writer) error {"))
if !bytes.Contains(mainSrc, []byte(`"git.golem15.com/golem15/summercms/modules/diary"`)) {
t.Fatalf("generated main does not import diary:\n%s", mainSrc)
}
if bytes.Contains(mainSrc, []byte(`"git.golem15.com/golem15/summercms/modules/sunscreen"`)) {
t.Fatalf("generated main still imports sunscreen:\n%s", mainSrc)
}
runSig := []byte("func run(args []string, out io.Writer) error {")
runStart := bytes.Index(mainSrc, runSig)
boot := bytes.Index(mainSrc, bootstrap)
load := bytes.Index(mainSrc, []byte("compass.Load("))
if runStart < 0 || load < 0 || !(runStart < install && install < load) {
t.Fatalf("InstallDefault must be the first statement of run, before compass.Load:\n%s", mainSrc)
set := bytes.Index(mainSrc, setup)
newApp := bytes.Index(mainSrc, []byte("backpack.New("))
if runStart < 0 || load < 0 || newApp < 0 || !(runStart < boot && boot < load && load < set && set < newApp) {
t.Fatalf("want run { Bootstrap; compass.Load; Setup; backpack.New }:\n%s", mainSrc)
}
if between := bytes.TrimSpace(mainSrc[runStart+len("func run(args []string, out io.Writer) error {") : install]); len(between) != 0 {
t.Fatalf("statements before InstallDefault: %q", between)
if between := bytes.TrimSpace(mainSrc[runStart+len(runSig) : boot]); len(between) != 0 {
t.Fatalf("statements before Bootstrap: %q", between)
}
}

View File

@@ -12,10 +12,10 @@ import (
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/conga"
"git.golem15.com/golem15/summercms/modules/diary"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/sunscreen"
"git.golem15.com/golem15/summercms/modules/surf"
)
@@ -27,11 +27,14 @@ func main() {
}
func run(args []string, out io.Writer) error {
sunscreen.InstallDefault(os.Stderr)
diary.Bootstrap(os.Stderr, diary.Options{})
cfg, err := compass.Load("config")
if err != nil {
return err
}
if err := diary.Setup(cfg, diary.Options{}); err != nil {
return err
}
app := backpack.New(cfg)
plugins, err := party.Activate(app, PluginIDs)
if err != nil {

View File

@@ -12,7 +12,7 @@ bonfire is the console layer of SummerCMS. Plugins and framework modules describ
## Role in the framework
A dependency-free base layer for console commands. `pact` uses it so plugins can contribute commands, and `lagoon`, `conga`, `cabana`, `lighthouse`, `flare` and `surf` build their own commands (migrate, workers, serve, admin tools) on it. Keeping it separate means every module describes commands the same way without depending on the CLI binary.
A base layer for console commands with no framework dependencies. `pact` uses it so plugins can contribute commands, and `lagoon`, `conga`, `cabana`, `lighthouse`, `flare` and `surf` build their own commands (migrate, workers, serve, admin tools) on it. Keeping it separate means every module describes commands the same way without depending on the CLI binary.
Use cases:
@@ -107,6 +107,10 @@ if catalog.Has("blog:import") {
| `bonfire.ErrUnknownCommand` | Returned by `bonfire.Call` when no command has the name. |
| `bonfire.Catalog` | An immutable copy of a binary's command list; the generated app main publishes one on the app. |
| `bonfire.NewCatalog` | Builds a `bonfire.Catalog` from a command slice. |
| `bonfire.ColorMode` | How console output decides on color: `bonfire.ColorAuto` (terminal and environment), `bonfire.ColorAlways` or `bonfire.ColorNever`. |
| `bonfire.Palette` | The console styles (`Success`, `Current`, `Warning`, `Error`, `ErrorBold`, `Muted`, `Header`), shared with the `diary` console log lines. |
| `bonfire.DefaultPalette` | Returns the palette ported from SSU's status styles: green success, cyan current, yellow warning, red and bold red errors, gray muted text, bold headers. |
| `bonfire.DetectProfile` | Returns the color profile for a writer under a `bonfire.ColorMode`, honouring `NO_COLOR`, `TERM=dumb` and `FORCE_COLOR`; render palette styles through a `colorprofile.Writer` with it. |
## Configuration
@@ -123,7 +127,7 @@ Without these variables, color is enabled only when stdout is a terminal.
## Dependencies
- SummerCMS modules: none.
- Third-party: `github.com/spf13/cobra`, `golang.org/x/term`.
- Third-party: `github.com/spf13/cobra`, `golang.org/x/term`, `charm.land/lipgloss/v2` and `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`).
- Standard library: `bufio`, `context`, `errors`, `fmt`, `io`, `os`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`.
## Testing

100
modules/bonfire/styles.go Normal file
View File

@@ -0,0 +1,100 @@
package bonfire
import (
"io"
"os"
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/colorprofile"
)
// ColorMode selects how console output decides on color: from the terminal
// and the environment, always, or never.
type ColorMode int
const (
// ColorAuto colors output when the writer is a terminal and the
// environment allows it (NO_COLOR and TERM=dumb disable color,
// FORCE_COLOR enables it on any writer).
ColorAuto ColorMode = iota
// ColorAlways colors output whatever the writer is (--ansi).
ColorAlways
// ColorNever writes no escape codes at all (--no-ansi).
ColorNever
)
// Palette is the shared console look: the status styles of list, help,
// widgets and log lines. Render through a writer that applies the color
// profile (DetectProfile), because lipgloss always emits ANSI codes.
type Palette struct {
// Success marks completed work (green).
Success lipgloss.Style
// Current marks the item in progress and informational lines (cyan).
Current lipgloss.Style
// Warning marks warnings (yellow).
Warning lipgloss.Style
// Error marks failures (red).
Error lipgloss.Style
// ErrorBold marks fatal failures and error levels (bold red).
ErrorBold lipgloss.Style
// Muted marks secondary detail such as times and keys (gray).
Muted lipgloss.Style
// Header marks headings and names (bold).
Header lipgloss.Style
}
// DefaultPalette returns the console palette, ported from SSU's status
// styles: green success, cyan current, yellow warning, red and bold red
// errors, gray (240) muted text and bold headers.
func DefaultPalette() Palette {
return Palette{
Success: lipgloss.NewStyle().Foreground(lipgloss.Color("2")),
Current: lipgloss.NewStyle().Foreground(lipgloss.Color("6")),
Warning: lipgloss.NewStyle().Foreground(lipgloss.Color("3")),
Error: lipgloss.NewStyle().Foreground(lipgloss.Color("1")),
ErrorBold: lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true),
Muted: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
Header: lipgloss.NewStyle().Bold(true),
}
}
// DetectProfile returns the color profile for console output written to w.
// ColorNever gives colorprofile.NoTTY (no escape codes at all). ColorAlways
// gives the detected profile raised to at least 256 colors. ColorAuto treats
// a non-empty FORCE_COLOR as ColorAlways unless NO_COLOR is set or TERM is
// dumb, and otherwise detects the profile from w and environ, which honours
// NO_COLOR and TERM=dumb. A nil environ means os.Environ().
func DetectProfile(w io.Writer, mode ColorMode, environ []string) colorprofile.Profile {
if mode == ColorNever {
return colorprofile.NoTTY
}
if environ == nil {
environ = os.Environ()
}
if mode == ColorAuto && forceColor(environ) {
mode = ColorAlways
}
p := colorprofile.Detect(w, environ)
if mode == ColorAlways && p < colorprofile.ANSI256 {
p = colorprofile.ANSI256
}
return p
}
func forceColor(environ []string) bool {
if envValue(environ, "NO_COLOR") != "" || envValue(environ, "TERM") == "dumb" {
return false
}
return envValue(environ, "FORCE_COLOR") != ""
}
func envValue(environ []string, key string) string {
for i := len(environ) - 1; i >= 0; i-- {
k, v, ok := strings.Cut(environ[i], "=")
if ok && k == key {
return v
}
}
return ""
}

144
modules/diary/README.md Normal file
View File

@@ -0,0 +1,144 @@
# diary
Application logging modelled on Laravel's logging config: named channels (console, stderr, daily files, stacks), per-component levels and redaction on every channel.
`import "git.golem15.com/golem15/summercms/modules/diary"`
**Name:** A beach diary records each day; the log keeps one file per day.
## Overview
`diary` owns the process logger of a SummerCMS application. `config/log.yaml` names a default channel, a global level and a map of channels, the way Laravel's `config/logging.php` does: a console channel that prints styled lines on a terminal, daily files with retention, and a stack that fans a record out to several channels. Code logs through named component loggers (`diary.For`), so every record carries the plugin or module it came from. Every channel is wrapped by `sunscreen.Wrap`, so credentials are redacted whichever channel a record reaches.
The application `main` that `summer build` generates calls `diary.Bootstrap` as its first statement, which installs a console channel before configuration is read, and `diary.Setup` right after `compass` loads `config/`.
## Role in the framework
The single owner of the process logger. It builds on `compass` (configuration), `sunscreen` (redaction) and `bonfire` (the console palette and color detection). Framework modules and plugins only ask it for a component logger; they never configure handlers themselves.
Use cases:
- Keep a daily JSON log file per instance without extra setup, next to readable console output.
- Turn one noisy component down (or one under investigation up) without touching code.
- Send logs to stderr as logfmt or JSON when the process runs under systemd or Docker.
## Features
- Component loggers: `diary.For` returns a `*slog.Logger` whose records carry the attribute `component=<name>`. It is safe to create in a package `var` block: before setup its records go to the process default logger's handler, after `diary.Setup` they reach the configured channels, and a later `diary.Setup` or `diary.SetVerbosity` applies to loggers created earlier.
- Channels: `console` (styled lines on a terminal, logfmt otherwise, or JSON with `format: json`), `daily` (one file per day) and `stack` (several channels at once), each with its own level.
- Console lines in bonfire's palette: time, level, the component as a fixed-width prefix, the message and `key=value` attributes. Control characters other than tab are removed from messages and values, so logged data cannot inject terminal escape sequences.
- Daily files: the path `storage/logs/summer.log` gives files `storage/logs/summer-2026-10-21.log`, created mode 0640 in directories created 0750, opened on the first record, rotated at local midnight, and deleted after the retention in days. A relative path resolves against the application root.
- Redaction: every channel leaf is wrapped by `sunscreen.Wrap`.
- Runtime verbosity: `diary.SetVerbosity` lets info (1) or debug (2) through for every component and adds source locations on styled lines (3); quiet raises only the console channels to warn.
- The process default logger (`slog.Default`) and the standard `log` package log through the configured channels without a component.
## Usage
A plugin or module creates its logger once:
```go
var logger = diary.For("blog")
func publish(ctx context.Context, id int) {
logger.InfoContext(ctx, "post published", "id", id)
}
```
With the default configuration this prints on a terminal:
```text
12:04:31 INFO blog post published id=7
```
and writes warnings and errors as JSON to `storage/logs/summer-YYYY-MM-DD.log`.
The generated `main` installs it:
```go
func run(args []string, out io.Writer) error {
diary.Bootstrap(os.Stderr, diary.Options{})
cfg, err := compass.Load("config")
if err != nil {
return err
}
if err := diary.Setup(cfg, diary.Options{}); err != nil {
return err
}
// ...
}
```
Tests that set diary up restore the previous process logger with `t.Cleanup(diary.Reset)`.
## API reference
| Identifier | Description |
|------------|-------------|
| `diary.For` | Returns the `*slog.Logger` of a component; safe in package `var` blocks. |
| `diary.Bootstrap` | Installs a console channel on a writer before configuration is read and makes diary the process default logger. |
| `diary.Setup` | Parses `log.*` from a `compass` configuration, builds the channels and installs them; returns configuration errors. |
| `diary.SetVerbosity` | Changes verbosity and quiet at runtime. |
| `diary.Verbosity` | Returns the current verbosity. |
| `diary.Reset` | Drops the setup, closes its files and restores the process default logger captured at the first setup. |
| `diary.Close` | Closes the open daily files; later records open them again. |
| `diary.Options` | Setup options: `Verbosity`, `Quiet`, `Color`, `Root`, `Stderr`, `Environ` and `Now`. |
| `diary.ParseConfig` | Reads `log.*` over `diary.DefaultConfig`. |
| `diary.DefaultConfig` | The framework defaults. |
| `diary.Config` | Parsed configuration: `Default`, `Level`, `Channels` and `Components`. |
| `diary.ChannelConfig` | One channel: `Driver`, `Format`, `Path`, `Level`, `HasLevel`, `Days` and `Channels`. |
| `diary.NewConsoleHandler` | The styled console `slog.Handler`, for programs that build their own logger. |
| `diary.ConsoleOptions` | Console handler options: `Level`, `Profile` and `AddSource`. |
| `diary.ComponentKey` | The attribute key of the component, `component`. |
| `diary.DriverConsole`, `diary.DriverStderr`, `diary.DriverDaily`, `diary.DriverStack`, `diary.DriverNull` | Channel driver names. |
| `diary.FormatAuto`, `diary.FormatJSON`, `diary.FormatLogfmt` | Channel formats. |
| `diary.DefaultKey`, `diary.LevelKey`, `diary.ChannelsKey`, `diary.ComponentsKey` | The configuration keys `log.default`, `log.level`, `log.channels` and `log.components`. |
## Configuration
Without a `config/log.yaml` an application uses these defaults:
```yaml
# config/log.yaml
default: stack
level: info
channels:
stack:
driver: stack
channels: [console, daily]
console:
driver: console
format: auto
daily:
driver: daily
path: storage/logs/summer.log
days: 14
format: json
level: warn
components:
river: warn
```
| Key | Default | Meaning |
|-----|---------|---------|
| `log.default` | `stack` | The channel every record goes to. |
| `log.level` | `info` | The minimum level of every component without its own level. |
| `log.channels.<name>.driver` | | `console`, `daily` or `stack`. |
| `log.channels.<name>.level` | none | The channel's own minimum level. |
| `log.channels.<name>.format` | per driver | `auto` (console only), `json` or `logfmt`; console defaults to `auto`, daily to `json`. |
| `log.channels.<name>.path` | | The daily file path, relative to the application root. |
| `log.channels.<name>.days` | `0` | Daily retention in days; `0` keeps every file. |
| `log.channels.<name>.channels` | | A stack's children. |
## Dependencies
- SummerCMS modules: `bonfire` (palette and color detection), `compass` (configuration), `sunscreen` (redaction).
- Third-party: `charm.land/lipgloss/v2`, `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`).
- Standard library: `context`, `errors`, `fmt`, `io`, `io/fs`, `log`, `log/slog`, `maps`, `os`, `path/filepath`, `runtime`, `slices`, `strconv`, `strings`, `sync`, `sync/atomic`, `time`, `unicode`.
## Testing
```sh
go test ./modules/diary/...
```
`TestDiaryTracerSmoke` loads a `config/log.yaml` through `compass`, logs one record from a component logger and checks that it reaches the console and the daily file redacted. The tests write to temporary directories and need no external services.

157
modules/diary/channels.go Normal file
View File

@@ -0,0 +1,157 @@
package diary
import (
"context"
"fmt"
"io"
"log/slog"
"path/filepath"
"sync/atomic"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/sunscreen"
"github.com/charmbracelet/colorprofile"
)
// noFloor is the level of a channel without its own level: every record the
// component floors let through reaches it.
const noFloor = slog.Level(-1 << 30)
// consoleLevel is a console channel's runtime level: base is the configured
// level, which quiet raises to warn.
type consoleLevel struct {
lv *slog.LevelVar
base slog.Level
}
func (c consoleLevel) apply(quiet bool) {
l := c.base
if quiet && l < slog.LevelWarn {
l = slog.LevelWarn
}
c.lv.Set(l)
}
// channels is the built handler tree of one configuration.
type channels struct {
root slog.Handler
consoles []consoleLevel
closers []io.Closer
source *atomic.Bool
}
type channelBuilder struct {
cfg Config
opts Options
out *channels
visiting map[string]bool
warnTo slog.Handler
}
// buildChannels builds the channel named by cfg.Default. Every leaf is
// wrapped by sunscreen.Wrap and gated by its own level; a stack fans out
// through slog.NewMultiHandler.
func buildChannels(cfg Config, opts Options) (*channels, error) {
b := &channelBuilder{
cfg: cfg,
opts: opts,
out: &channels{source: new(atomic.Bool)},
visiting: map[string]bool{},
}
b.out.source.Store(opts.Verbosity >= 3)
root, err := b.build(cfg.Default)
if err != nil {
for _, c := range b.out.closers {
_ = c.Close()
}
return nil, err
}
b.out.root = root
for _, c := range b.out.consoles {
c.apply(opts.Quiet)
}
return b.out, nil
}
func (b *channelBuilder) build(name string) (slog.Handler, error) {
ch, ok := b.cfg.Channels[name]
if !ok {
return nil, fmt.Errorf("diary: channel %q is not defined in %s", name, ChannelsKey)
}
if b.visiting[name] {
return nil, fmt.Errorf("diary: %s.%s.channels includes itself", ChannelsKey, name)
}
b.visiting[name] = true
defer delete(b.visiting, name)
lv := new(slog.LevelVar)
base := noFloor
if ch.HasLevel {
base = ch.Level
}
lv.Set(base)
hopts := &slog.HandlerOptions{Level: lv}
switch ch.Driver {
case DriverConsole:
w := b.opts.Stderr
var leaf slog.Handler
switch ch.Format {
case FormatJSON:
leaf = slog.NewJSONHandler(w, hopts)
case FormatLogfmt:
leaf = slog.NewTextHandler(w, hopts)
default:
profile := bonfire.DetectProfile(w, b.opts.Color, b.opts.Environ)
if profile > colorprofile.NoTTY {
leaf = newConsoleHandler(w, ConsoleOptions{Level: lv, Profile: profile}, b.out.source)
} else {
leaf = slog.NewTextHandler(w, hopts)
}
}
b.out.consoles = append(b.out.consoles, consoleLevel{lv: lv, base: base})
wrapped := sunscreen.Wrap(leaf)
if b.warnTo == nil {
b.warnTo = wrapped
}
return wrapped, nil
case DriverDaily:
path := ch.Path
if !filepath.IsAbs(path) {
path = filepath.Join(b.opts.Root, filepath.Clean(path))
}
dw := newDailyWriter(path, ch.Days, b.opts.Now, b.warn)
b.out.closers = append(b.out.closers, dw)
var leaf slog.Handler
if ch.Format == FormatLogfmt {
leaf = slog.NewTextHandler(dw, hopts)
} else {
leaf = slog.NewJSONHandler(dw, hopts)
}
return sunscreen.Wrap(leaf), nil
case DriverStack:
children := make([]slog.Handler, 0, len(ch.Channels))
for _, child := range ch.Channels {
h, err := b.build(child)
if err != nil {
return nil, err
}
children = append(children, h)
}
return slog.NewMultiHandler(children...), nil
}
return nil, fmt.Errorf("diary: %s.%s.driver %q is not supported", ChannelsKey, name, ch.Driver)
}
// warn reports a daily file that cannot be opened: one WARN record from
// component diary on the first console channel, or on a plain stderr
// handler when the configuration has no console channel.
func (b *channelBuilder) warn(path string, err error) {
h := b.warnTo
if h == nil {
h = sunscreen.Wrap(slog.NewTextHandler(b.opts.Stderr, nil))
}
r := slog.NewRecord(b.opts.Now(), slog.LevelWarn, "daily log file disabled", 0)
r.AddAttrs(slog.String("path", path), slog.String("error", err.Error()))
_ = h.WithAttrs([]slog.Attr{slog.String(ComponentKey, "diary")}).Handle(context.Background(), r)
}

138
modules/diary/component.go Normal file
View File

@@ -0,0 +1,138 @@
package diary
import (
"context"
"log/slog"
"os"
"slices"
"sync/atomic"
"git.golem15.com/golem15/summercms/modules/sunscreen"
)
// componentHandler is the handler behind every diary logger. It holds only a
// component name and the attributes and groups bound with With and
// WithGroup; the channels it writes to are looked up when a record is
// logged. A logger created in a package var block, before Setup, therefore
// routes through the channels Setup installs later, and through a new set
// after the next Setup or SetVerbosity.
type componentHandler struct {
name string
ops []handlerOp
cache *atomic.Pointer[boundHandler]
}
// handlerOp is one WithAttrs or WithGroup call, replayed in order onto the
// live root handler.
type handlerOp struct {
group string
attrs []slog.Attr
}
type boundHandler struct {
gen uint64
h slog.Handler
}
func newComponentHandler(name string) *componentHandler {
return &componentHandler{name: name, cache: new(atomic.Pointer[boundHandler])}
}
// fallbackHandler writes to stderr through sunscreen when there is no diary
// state and the process default is itself a diary logger.
var fallbackHandler = sunscreen.Wrap(slog.NewTextHandler(os.Stderr, nil))
// processDefault is the handler records go to before diary is set up: the
// process default logger's handler, unless that is a diary handler, which
// would recurse.
func processDefault() slog.Handler {
h := slog.Default().Handler()
if _, ok := h.(*componentHandler); ok {
return fallbackHandler
}
return h
}
func (h *componentHandler) Enabled(ctx context.Context, l slog.Level) bool {
st := current.Load()
if st == nil {
return processDefault().Enabled(ctx, l)
}
return l >= st.floor(h.name) && st.root.Enabled(ctx, l)
}
func (h *componentHandler) Handle(ctx context.Context, r slog.Record) error {
st := current.Load()
if st == nil {
return h.bind(processDefault()).Handle(ctx, r)
}
b := h.cache.Load()
if b == nil || b.gen != st.gen {
b = &boundHandler{gen: st.gen, h: h.bind(st.root)}
h.cache.Store(b)
}
return b.h.Handle(ctx, r)
}
// bind applies the component attribute, then the recorded operations, to
// base. The component goes first so it stays a top-level attribute even
// when groups are open.
func (h *componentHandler) bind(base slog.Handler) slog.Handler {
out := base
if h.name != "" {
out = out.WithAttrs([]slog.Attr{slog.String(ComponentKey, h.name)})
}
for _, op := range h.ops {
if op.group != "" {
out = out.WithGroup(op.group)
} else {
out = out.WithAttrs(op.attrs)
}
}
return out
}
func (h *componentHandler) hasGroups() bool {
for _, op := range h.ops {
if op.group != "" {
return true
}
}
return false
}
// WithAttrs records attrs. A top-level string attribute keyed "component"
// rebinds the component name instead, so slog.Default().With("component",
// "x") behaves like For("x").
func (h *componentHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return h
}
c := &componentHandler{name: h.name, ops: slices.Clip(h.ops), cache: new(atomic.Pointer[boundHandler])}
rest := attrs
if !h.hasGroups() {
rest = make([]slog.Attr, 0, len(attrs))
for _, a := range attrs {
if a.Key == ComponentKey && a.Value.Kind() == slog.KindString {
c.name = a.Value.String()
continue
}
rest = append(rest, a)
}
}
if len(rest) > 0 {
c.ops = append(c.ops, handlerOp{attrs: slices.Clone(rest)})
}
return c
}
func (h *componentHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
return &componentHandler{
name: h.name,
ops: append(slices.Clip(h.ops), handlerOp{group: name}),
cache: new(atomic.Pointer[boundHandler]),
}
}

296
modules/diary/config.go Normal file
View File

@@ -0,0 +1,296 @@
package diary
import (
"fmt"
"log/slog"
"maps"
"slices"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/modules/compass"
)
// Config keys read by ParseConfig.
const (
// DefaultKey names the channel that receives every record (log.default).
DefaultKey = "log.default"
// LevelKey is the global minimum level (log.level).
LevelKey = "log.level"
// ChannelsKey holds the named channels (log.channels.<name>).
ChannelsKey = "log.channels"
// ComponentsKey holds per-component levels (log.components.<name>).
ComponentsKey = "log.components"
)
// Channel drivers.
const (
// DriverConsole writes to the console (stderr): styled lines on a
// terminal, logfmt or JSON otherwise.
DriverConsole = "console"
// DriverStderr writes logfmt or JSON to stderr, never styled.
DriverStderr = "stderr"
// DriverDaily writes one file per day with retention.
DriverDaily = "daily"
// DriverStack fans a record out to the channels it lists.
DriverStack = "stack"
// DriverNull discards records.
DriverNull = "null"
)
// Channel formats.
const (
// FormatAuto styles console lines on a terminal and writes logfmt
// otherwise. Only the console driver accepts it.
FormatAuto = "auto"
// FormatJSON writes one JSON object per record.
FormatJSON = "json"
// FormatLogfmt writes key=value lines.
FormatLogfmt = "logfmt"
)
// Config is the parsed log.* configuration.
type Config struct {
// Default is the channel every record goes to (log.default).
Default string
// Level is the global minimum level (log.level).
Level slog.Level
// Channels holds the named channels (log.channels).
Channels map[string]ChannelConfig
// Components holds per-component minimum levels (log.components).
Components map[string]slog.Level
}
// ChannelConfig is one entry of log.channels.
type ChannelConfig struct {
// Driver is one of DriverConsole, DriverStderr, DriverDaily,
// DriverStack or DriverNull.
Driver string
// Format is FormatAuto, FormatJSON or FormatLogfmt; empty means the
// driver's default (auto for console, logfmt for stderr, json for daily).
Format string
// Path is the daily file path; relative paths resolve against the
// application root.
Path string
// Level is the channel's own minimum level, applied when HasLevel is set.
Level slog.Level
// HasLevel reports whether the channel sets a level.
HasLevel bool
// Days is the daily retention in days; 0 keeps every file.
Days int
// Channels lists a stack's children.
Channels []string
}
// DefaultConfig returns the framework defaults: a stack of a console channel
// and a daily file at storage/logs/summer.log (JSON, level warn, kept 14
// days), global level info, and River at warn.
func DefaultConfig() Config {
return Config{
Default: "stack",
Level: slog.LevelInfo,
Channels: map[string]ChannelConfig{
"stack": {Driver: DriverStack, Channels: []string{"console", "daily"}},
"console": {Driver: DriverConsole, Format: FormatAuto},
"daily": {
Driver: DriverDaily,
Path: "storage/logs/summer.log",
Days: 14,
Format: FormatJSON,
Level: slog.LevelWarn,
HasLevel: true,
},
},
Components: map[string]slog.Level{"river": slog.LevelWarn},
}
}
func (c Config) clone() Config {
out := c
out.Channels = make(map[string]ChannelConfig, len(c.Channels))
for name, ch := range c.Channels {
ch.Channels = slices.Clone(ch.Channels)
out.Channels[name] = ch
}
out.Components = maps.Clone(c.Components)
if out.Components == nil {
out.Components = map[string]slog.Level{}
}
return out
}
// ParseConfig reads log.* from cfg over DefaultConfig, key by key, so a
// single override keeps every other default. Values may arrive as strings
// from SUMMER_LOG__ environment variables. A nil cfg gives the defaults.
func ParseConfig(cfg *compass.Config) (Config, error) {
out := DefaultConfig().clone()
if cfg == nil {
return out, nil
}
if raw, ok := cfg.Lookup(DefaultKey); ok {
out.Default = strings.TrimSpace(fmt.Sprint(raw))
}
if raw, ok := cfg.Lookup(LevelKey); ok {
lvl, err := parseLevel(raw)
if err != nil {
return Config{}, keyError(LevelKey, err)
}
out.Level = lvl
}
if raw, ok := cfg.Lookup(ChannelsKey); ok {
channels, ok := raw.(map[string]any)
if !ok {
return Config{}, fmt.Errorf("diary: %s must be a map of channels", ChannelsKey)
}
for _, name := range slices.Sorted(maps.Keys(channels)) {
ch, err := overlayChannel(name, out.Channels[name], channels[name])
if err != nil {
return Config{}, err
}
out.Channels[name] = ch
}
}
if err := out.validate(); err != nil {
return Config{}, err
}
return out, nil
}
func overlayChannel(name string, ch ChannelConfig, raw any) (ChannelConfig, error) {
key := ChannelsKey + "." + name
fields, ok := raw.(map[string]any)
if !ok {
return ch, fmt.Errorf("diary: %s must be a map", key)
}
for _, field := range slices.Sorted(maps.Keys(fields)) {
v := fields[field]
fkey := key + "." + field
switch field {
case "driver":
ch.Driver = strings.ToLower(strings.TrimSpace(fmt.Sprint(v)))
case "format":
ch.Format = strings.ToLower(strings.TrimSpace(fmt.Sprint(v)))
case "path":
ch.Path = strings.TrimSpace(fmt.Sprint(v))
case "level":
lvl, err := parseLevel(v)
if err != nil {
return ch, keyError(fkey, err)
}
ch.Level, ch.HasLevel = lvl, true
case "days":
n, err := parseDays(v)
if err != nil {
return ch, fmt.Errorf("diary: %s must be a whole number of at least 0", fkey)
}
ch.Days = n
case "channels":
ch.Channels = parseList(v)
default:
return ch, fmt.Errorf("diary: %s is not a channel setting (driver, level, format, path, days, channels)", fkey)
}
}
return ch, nil
}
func (c Config) validate() error {
if c.Default == "" {
return fmt.Errorf("diary: %s must name a channel", DefaultKey)
}
if _, ok := c.Channels[c.Default]; !ok {
return fmt.Errorf("diary: %s names channel %q, which is not defined in %s", DefaultKey, c.Default, ChannelsKey)
}
for _, name := range slices.Sorted(maps.Keys(c.Channels)) {
ch := c.Channels[name]
key := ChannelsKey + "." + name
switch ch.Driver {
case DriverConsole, DriverDaily, DriverStack:
case "":
return fmt.Errorf("diary: %s.driver is required", key)
default:
return fmt.Errorf("diary: %s.driver %q is not one of console, daily, stack", key, ch.Driver)
}
switch ch.Format {
case "", FormatJSON, FormatLogfmt:
case FormatAuto:
if ch.Driver != DriverConsole {
return fmt.Errorf("diary: %s.format auto is only valid for the console driver", key)
}
default:
return fmt.Errorf("diary: %s.format %q is not one of auto, json, logfmt", key, ch.Format)
}
if ch.Driver == DriverDaily && ch.Path == "" {
return fmt.Errorf("diary: %s.path is required for the daily driver", key)
}
}
return nil
}
func keyError(key string, err error) error {
return fmt.Errorf("diary: %s: %w", key, err)
}
func parseLevel(raw any) (slog.Level, error) {
s := strings.ToLower(strings.TrimSpace(fmt.Sprint(raw)))
switch s {
case "debug":
return slog.LevelDebug, nil
case "info":
return slog.LevelInfo, nil
case "warn", "warning":
return slog.LevelWarn, nil
case "error":
return slog.LevelError, nil
}
return 0, fmt.Errorf("level %q is not one of debug, info, warn, error", s)
}
func parseDays(raw any) (int, error) {
var n int
switch v := raw.(type) {
case int:
n = v
case int64:
n = int(v)
case uint64:
n = int(v)
case float64:
if v != float64(int(v)) {
return 0, fmt.Errorf("not a whole number")
}
n = int(v)
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0, err
}
n = parsed
default:
return 0, fmt.Errorf("unsupported value %T", raw)
}
if n < 0 {
return 0, fmt.Errorf("negative")
}
return n, nil
}
func parseList(raw any) []string {
var parts []string
switch v := raw.(type) {
case []any:
for _, e := range v {
parts = append(parts, fmt.Sprint(e))
}
case []string:
parts = v
default:
parts = strings.Split(fmt.Sprint(v), ",")
}
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}

257
modules/diary/console.go Normal file
View File

@@ -0,0 +1,257 @@
package diary
import (
"context"
"fmt"
"io"
"log/slog"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"unicode"
"git.golem15.com/golem15/summercms/modules/bonfire"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/colorprofile"
)
// componentWidth is the column width of the component prefix.
const componentWidth = 10
// ConsoleOptions configures NewConsoleHandler.
type ConsoleOptions struct {
// Level is the minimum level; nil means info.
Level slog.Leveler
// Profile is the color profile applied to the output; NoTTY writes no
// escape codes at all.
Profile colorprofile.Profile
// AddSource appends the caller's file:line to each line.
AddSource bool
}
// NewConsoleHandler returns a slog.Handler that writes one styled line per
// record to w in bonfire's palette: time, level, the component attribute as
// a fixed-width prefix, the message, then key=value attributes with groups
// as dotted key prefixes. Control characters other than tab are removed from
// the message and attribute values, so logged data cannot inject terminal
// escape sequences. Each line is written with one Write call, serialized
// across the handler and its WithAttrs and WithGroup clones.
func NewConsoleHandler(w io.Writer, opts ConsoleOptions) slog.Handler {
source := new(atomic.Bool)
source.Store(opts.AddSource)
return newConsoleHandler(w, opts, source)
}
func newConsoleHandler(w io.Writer, opts ConsoleOptions, source *atomic.Bool) *consoleHandler {
level := opts.Level
if level == nil {
level = slog.LevelInfo
}
return &consoleHandler{
out: &colorprofile.Writer{Forward: w, Profile: opts.Profile},
mu: new(sync.Mutex),
level: level,
source: source,
palette: bonfire.DefaultPalette(),
}
}
type consoleAttr struct {
key string
value string
}
type consoleHandler struct {
out io.Writer
mu *sync.Mutex
level slog.Leveler
source *atomic.Bool
palette bonfire.Palette
component string
attrs []consoleAttr
groups []string
}
func (h *consoleHandler) Enabled(_ context.Context, l slog.Level) bool {
return l >= h.level.Level()
}
func (h *consoleHandler) clone() *consoleHandler {
c := *h
c.attrs = h.attrs[:len(h.attrs):len(h.attrs)]
c.groups = h.groups[:len(h.groups):len(h.groups)]
return &c
}
func (h *consoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return h
}
c := h.clone()
prefix := groupPrefix(c.groups)
for _, a := range attrs {
if len(c.groups) == 0 && a.Key == ComponentKey && a.Value.Kind() == slog.KindString {
c.component = a.Value.String()
continue
}
c.attrs = appendAttr(c.attrs, prefix, a)
}
return c
}
func (h *consoleHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
c := h.clone()
c.groups = append(c.groups, name)
return c
}
func (h *consoleHandler) Handle(_ context.Context, r slog.Record) error {
component := h.component
prefix := groupPrefix(h.groups)
var recAttrs []consoleAttr
r.Attrs(func(a slog.Attr) bool {
if len(h.groups) == 0 && a.Key == ComponentKey && a.Value.Kind() == slog.KindString {
component = a.Value.String()
return true
}
recAttrs = appendAttr(recAttrs, prefix, a)
return true
})
p := h.palette
var b strings.Builder
if !r.Time.IsZero() {
b.WriteString(p.Muted.Render(r.Time.Format(time.TimeOnly)))
b.WriteByte(' ')
}
label, style := levelLabel(r.Level, p)
b.WriteString(style.Render(label))
b.WriteString(strings.Repeat(" ", max(0, 5-len(label))+1))
name := sanitize(component)
if name != "" {
b.WriteString(p.Header.Render(name))
}
b.WriteString(strings.Repeat(" ", max(0, componentWidth-len(name))+1))
b.WriteString(sanitize(r.Message))
for _, list := range [][]consoleAttr{h.attrs, recAttrs} {
for _, a := range list {
b.WriteByte(' ')
b.WriteString(p.Muted.Render(a.key + "="))
b.WriteString(a.value)
}
}
if h.source != nil && h.source.Load() && r.PC != 0 {
frame, _ := runtime.CallersFrames([]uintptr{r.PC}).Next()
if frame.File != "" {
b.WriteByte(' ')
b.WriteString(p.Muted.Render(filepath.Base(frame.File) + ":" + strconv.Itoa(frame.Line)))
}
}
b.WriteByte('\n')
h.mu.Lock()
defer h.mu.Unlock()
_, err := io.WriteString(h.out, b.String())
return err
}
func levelLabel(l slog.Level, p bonfire.Palette) (string, lipgloss.Style) {
switch {
case l < slog.LevelInfo:
return l.String(), p.Muted
case l < slog.LevelWarn:
return l.String(), p.Current
case l < slog.LevelError:
return l.String(), p.Warning
default:
return l.String(), p.ErrorBold
}
}
func groupPrefix(groups []string) string {
if len(groups) == 0 {
return ""
}
return strings.Join(groups, ".") + "."
}
// appendAttr flattens a into key=value pairs: empty attributes are skipped,
// groups add their key as a dotted prefix, and groups with an empty key are
// inlined (testing/slogtest semantics).
func appendAttr(out []consoleAttr, prefix string, a slog.Attr) []consoleAttr {
a.Value = a.Value.Resolve()
if a.Equal(slog.Attr{}) {
return out
}
if a.Value.Kind() == slog.KindGroup {
group := a.Value.Group()
if len(group) == 0 {
return out
}
inner := prefix
if a.Key != "" {
inner = prefix + a.Key + "."
}
for _, g := range group {
out = appendAttr(out, inner, g)
}
return out
}
return append(out, consoleAttr{key: sanitize(prefix + a.Key), value: formatValue(a.Value)})
}
func formatValue(v slog.Value) string {
var s string
switch v.Kind() {
case slog.KindString:
s = v.String()
case slog.KindTime:
s = v.Time().Format(time.RFC3339)
case slog.KindAny:
if err, ok := v.Any().(error); ok {
s = err.Error()
} else {
s = fmt.Sprintf("%+v", v.Any())
}
default:
s = v.String()
}
s = sanitize(s)
if s == "" || strings.ContainsAny(s, " \t\"=") {
return strconv.Quote(s)
}
return s
}
// sanitize removes control characters other than tab; line breaks become
// spaces so a multi-line value stays on one line.
func sanitize(s string) string {
clean := true
for _, r := range s {
if r != '\t' && unicode.IsControl(r) {
clean = false
break
}
}
if clean {
return s
}
var b strings.Builder
for _, r := range s {
switch {
case r == '\n' || r == '\r':
b.WriteByte(' ')
case r != '\t' && unicode.IsControl(r):
default:
b.WriteRune(r)
}
}
return b.String()
}

152
modules/diary/daily.go Normal file
View File

@@ -0,0 +1,152 @@
package diary
import (
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const dateLayout = "2006-01-02"
// dailyWriter writes to one file per local date: path storage/logs/summer.log
// gives storage/logs/summer-2026-10-21.log. Files open lazily on the first
// write (no empty files), are created 0o640 in directories created 0o750,
// and on every open the files of the same base older than the retention are
// deleted. If a file cannot be opened the writer reports once through warn
// and is disabled: later writes succeed without writing anything.
type dailyWriter struct {
mu sync.Mutex
dir string
base string
ext string
days int
now func() time.Time
warn func(path string, err error)
file *os.File
date string
disabled bool
}
func newDailyWriter(path string, days int, now func() time.Time, warn func(string, error)) *dailyWriter {
dir, name := filepath.Split(path)
ext := filepath.Ext(name)
if dir == "" {
dir = "."
}
return &dailyWriter{
dir: filepath.Clean(dir),
base: strings.TrimSuffix(name, ext),
ext: ext,
days: days,
now: now,
warn: warn,
}
}
func (d *dailyWriter) fileName(date string) string {
return filepath.Join(d.dir, d.base+"-"+date+d.ext)
}
// Write appends p to the current day's file in one write call.
func (d *dailyWriter) Write(p []byte) (int, error) {
d.mu.Lock()
if d.disabled {
d.mu.Unlock()
return len(p), nil
}
date := d.now().Local().Format(dateLayout)
if d.file == nil || date != d.date {
if err := d.open(date); err != nil {
d.disabled = true
warn := d.warn
name := d.fileName(date)
d.mu.Unlock()
if warn != nil {
warn(name, err)
}
return len(p), nil
}
}
n, err := d.file.Write(p)
d.mu.Unlock()
return n, err
}
func (d *dailyWriter) open(date string) error {
if d.file != nil {
_ = d.file.Close()
d.file = nil
}
if err := os.MkdirAll(d.dir, 0o750); err != nil {
return err
}
name := d.fileName(date)
_, statErr := os.Stat(name)
f, err := os.OpenFile(name, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
if err != nil {
return err
}
if errors.Is(statErr, fs.ErrNotExist) {
// The process umask may strip bits; new log files are 0o640 exactly.
_ = f.Chmod(0o640)
}
d.file, d.date = f, date
d.prune(date)
return nil
}
// prune deletes files named base-YYYY-MM-DD+ext whose date falls outside the
// retention window: with days N the files of today and the N-1 days before
// it are kept. Other files in the directory are never touched.
func (d *dailyWriter) prune(today string) {
if d.days <= 0 {
return
}
now, err := time.ParseInLocation(dateLayout, today, time.Local)
if err != nil {
return
}
cutoff := now.AddDate(0, 0, -d.days)
entries, err := os.ReadDir(d.dir)
if err != nil {
return
}
prefix := d.base + "-"
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
rest, ok := strings.CutPrefix(name, prefix)
if !ok {
continue
}
stamp, ok := strings.CutSuffix(rest, d.ext)
if !ok || len(stamp) != len(dateLayout) {
continue
}
day, err := time.ParseInLocation(dateLayout, stamp, time.Local)
if err != nil {
continue
}
if !day.After(cutoff) {
_ = os.Remove(filepath.Join(d.dir, name))
}
}
}
// Close closes the open file. A later write opens it again.
func (d *dailyWriter) Close() error {
d.mu.Lock()
defer d.mu.Unlock()
if d.file == nil {
return nil
}
err := d.file.Close()
d.file, d.date = nil, ""
return err
}

233
modules/diary/diary.go Normal file
View File

@@ -0,0 +1,233 @@
// Package diary is the application logging pipeline, modelled on Laravel's
// logging config (D-08): config/log.yaml names a default channel, a global
// level and named channels (console, stderr, daily files, stacks, null), and
// every channel redacts through sunscreen. Code logs through named component
// loggers (D-09): For("journal") returns a logger whose records carry the
// component and pass its level from log.components, and which is safe to
// create in a package var before Setup runs.
package diary
import (
"errors"
"io"
"log"
"log/slog"
"os"
"sync"
"sync/atomic"
"time"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/compass"
)
// ComponentKey is the attribute that carries a record's component.
const ComponentKey = "component"
// Options configures Bootstrap and Setup.
type Options struct {
// Verbosity lowers every component floor: 1 to at most info, 2 or more
// to debug; 3 or more also adds source locations on the console.
Verbosity int
// Quiet raises the console channels to warn; files keep their levels.
Quiet bool
// Color decides on styled console output (bonfire.ColorAuto by default).
Color bonfire.ColorMode
// Root is the application root that relative daily paths resolve
// against; empty means the working directory.
Root string
// Stderr is where console channels write; nil means os.Stderr.
Stderr io.Writer
// Environ is used to detect color support; nil means os.Environ().
Environ []string
// Now is the clock of the daily writers; nil means time.Now.
Now func() time.Time
}
func (o Options) normalize() Options {
if o.Stderr == nil {
o.Stderr = os.Stderr
}
if o.Root == "" {
o.Root = "."
}
if o.Now == nil {
o.Now = time.Now
}
if o.Environ == nil {
o.Environ = os.Environ()
}
return o
}
// state is one installed configuration. It is replaced as a whole; only the
// console level vars and the source switch change in place.
type state struct {
gen uint64
root slog.Handler
level slog.Level
verbosity int
quiet bool
channels *channels
}
// floor is the minimum level for records of component name.
func (s *state) floor(name string) slog.Level {
f := s.level
switch {
case s.verbosity >= 2:
f = min(f, slog.LevelDebug)
case s.verbosity >= 1:
f = min(f, slog.LevelInfo)
}
return f
}
type savedDefault struct {
logger *slog.Logger
writer io.Writer
flags int
}
var (
mu sync.Mutex
current atomic.Pointer[state]
gens atomic.Uint64
verbosity atomic.Int64
saved *savedDefault
)
// For returns the logger of a component, normally a plugin or module code
// without dots (koanf splits config keys on dots). Its records carry the
// attribute component=<name> and pass the component's level. Before Bootstrap
// or Setup they go to the process default logger's handler.
func For(component string) *slog.Logger {
return slog.New(newComponentHandler(component))
}
// Bootstrap installs a console channel on w, before configuration is read,
// and makes diary the process default logger. The generated main calls it
// first; Setup replaces it once config/log.yaml is loaded.
func Bootstrap(w io.Writer, opts Options) {
if w == nil {
w = os.Stderr
}
opts.Stderr = w
opts = opts.normalize()
cfg := DefaultConfig()
cfg.Default = "console"
cfg.Channels = map[string]ChannelConfig{"console": {Driver: DriverConsole, Format: FormatAuto}}
ch, err := buildChannels(cfg, opts)
if err != nil {
// A console-only configuration has nothing that can fail; keep the
// previous state if it ever does.
return
}
install(cfg, ch, opts)
}
// Setup parses log.* from cfg (ParseConfig), builds the channels and makes
// them the target of every diary logger and of the process default logger.
// A configuration error is returned and leaves the previous setup in place;
// a daily file that cannot be opened never fails Setup.
func Setup(cfg *compass.Config, opts Options) error {
conf, err := ParseConfig(cfg)
if err != nil {
return err
}
opts = opts.normalize()
ch, err := buildChannels(conf, opts)
if err != nil {
return err
}
install(conf, ch, opts)
return nil
}
func install(conf Config, ch *channels, opts Options) {
mu.Lock()
defer mu.Unlock()
if saved == nil {
saved = &savedDefault{logger: slog.Default(), writer: log.Writer(), flags: log.Flags()}
}
verbosity.Store(int64(opts.Verbosity))
st := &state{
gen: gens.Add(1),
root: ch.root,
level: conf.Level,
verbosity: opts.Verbosity,
quiet: opts.Quiet,
channels: ch,
}
old := current.Swap(st)
slog.SetDefault(slog.New(newComponentHandler("")))
if old != nil {
closeChannels(old.channels)
}
}
// SetVerbosity changes the verbosity and quiet switches at runtime: 1 lets
// info through for every component, 2 or more debug, 3 or more adds source
// locations on styled console lines. Quiet raises only the console channels
// to warn. Before Bootstrap or Setup it only changes what Verbosity returns;
// Options.Verbosity and Options.Quiet set the values of a new setup.
func SetVerbosity(v int, quiet bool) {
mu.Lock()
defer mu.Unlock()
verbosity.Store(int64(v))
st := current.Load()
if st == nil {
return
}
next := *st
next.verbosity, next.quiet = v, quiet
for _, c := range st.channels.consoles {
c.apply(quiet)
}
st.channels.source.Store(v >= 3)
current.Store(&next)
}
// Verbosity returns the current verbosity (0 by default).
func Verbosity() int {
return int(verbosity.Load())
}
// Close closes the open daily files. Later records open them again.
func Close() error {
st := current.Load()
if st == nil {
return nil
}
return closeChannels(st.channels)
}
func closeChannels(ch *channels) error {
if ch == nil {
return nil
}
var errs []error
for _, c := range ch.closers {
errs = append(errs, c.Close())
}
return errors.Join(errs...)
}
// Reset drops the installed setup, closes its files and restores the process
// default logger (and the standard log package's output) captured at the
// first Bootstrap or Setup. Tests call it in t.Cleanup.
func Reset() {
mu.Lock()
defer mu.Unlock()
old := current.Swap(nil)
verbosity.Store(0)
if saved != nil {
slog.SetDefault(saved.logger)
log.SetOutput(saved.writer)
log.SetFlags(saved.flags)
saved = nil
}
if old != nil {
closeChannels(old.channels)
}
}

View File

@@ -0,0 +1,103 @@
package diary_test
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/diary"
)
// earlyLogger is created before any Setup, the way modules create their
// component loggers in package var blocks.
var earlyLogger = diary.For("x")
// TestDiaryTracerSmoke: one record from a component logger flows from
// config/log.yaml through compass into a redacting stack of a console and a
// daily file.
func TestDiaryTracerSmoke(t *testing.T) {
tmp := t.TempDir()
configDir := filepath.Join(tmp, "config")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatal(err)
}
logYAML := `default: stack
level: info
channels:
stack:
driver: stack
channels: [console, daily]
console:
driver: console
format: auto
daily:
driver: daily
path: storage/logs/summer.log
days: 14
format: json
level: info
`
if err := os.WriteFile(filepath.Join(configDir, "log.yaml"), []byte(logYAML), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: configDir, Environ: []string{}})
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
now := func() time.Time { return time.Date(2026, 10, 21, 12, 0, 0, 0, time.Local) }
t.Cleanup(diary.Reset)
if err := diary.Setup(cfg, diary.Options{Root: tmp, Stderr: &buf, Color: bonfire.ColorNever, Now: now}); err != nil {
t.Fatalf("Setup: %v", err)
}
diary.For("journal").Info("post saved", "password", "hunter2", "id", 7)
earlyLogger.Info("early logger reached")
console := buf.String()
for _, want := range []string{"component=journal", "post saved", "[REDACTED]", "component=x", "early logger reached"} {
if !strings.Contains(console, want) {
t.Errorf("console output lacks %q:\n%s", want, console)
}
}
if strings.Contains(console, "hunter2") {
t.Errorf("console output leaks the password:\n%s", console)
}
if err := diary.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
file := filepath.Join(tmp, "storage", "logs", "summer-2026-10-21.log")
info, err := os.Stat(file)
if err != nil {
t.Fatalf("daily file: %v", err)
}
if mode := info.Mode().Perm(); mode != 0o640 {
t.Errorf("daily file mode = %o, want 640", mode)
}
raw, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(raw, []byte("hunter2")) {
t.Errorf("daily file leaks the password:\n%s", raw)
}
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
if len(lines) != 2 {
t.Fatalf("daily file has %d lines, want 2:\n%s", len(lines), raw)
}
var rec map[string]any
if err := json.Unmarshal([]byte(lines[0]), &rec); err != nil {
t.Fatalf("daily line is not JSON: %v\n%s", err, lines[0])
}
if rec["component"] != "journal" || rec["password"] != "[REDACTED]" || rec["msg"] != "post saved" {
t.Errorf("daily record = %v, want component journal, password [REDACTED], msg post saved", rec)
}
}