From e903cc32be3408519e1ab101c10a67e22335cf52 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 21 Oct 2026 11:41:48 +0200 Subject: [PATCH] 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/ --- .gitignore | 4 + README.md | 1 + examples/hello/main.go | 7 +- go.mod | 14 + go.sum | 30 +++ internal/build/build.go | 7 +- internal/build/build_test.go | 41 +-- internal/build/testdata/new/main.go.golden | 7 +- modules/bonfire/README.md | 8 +- modules/bonfire/styles.go | 100 +++++++ modules/diary/README.md | 144 ++++++++++ modules/diary/channels.go | 157 +++++++++++ modules/diary/component.go | 138 ++++++++++ modules/diary/config.go | 296 +++++++++++++++++++++ modules/diary/console.go | 257 ++++++++++++++++++ modules/diary/daily.go | 152 +++++++++++ modules/diary/diary.go | 233 ++++++++++++++++ modules/diary/tracer_smoke_test.go | 103 +++++++ 18 files changed, 1676 insertions(+), 23 deletions(-) create mode 100644 modules/bonfire/styles.go create mode 100644 modules/diary/README.md create mode 100644 modules/diary/channels.go create mode 100644 modules/diary/component.go create mode 100644 modules/diary/config.go create mode 100644 modules/diary/console.go create mode 100644 modules/diary/daily.go create mode 100644 modules/diary/diary.go create mode 100644 modules/diary/tracer_smoke_test.go diff --git a/.gitignore b/.gitignore index 8523819c..a6b3a261 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # Go /bin/ /examples/hello/bin/ + +# Application log files (diary daily channel) +/storage/ +/examples/hello/storage/ /dist/ /site/ *.test diff --git a/README.md b/README.md index b119c137..8b922b43 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/examples/hello/main.go b/examples/hello/main.go index db53f1c9..dcf5f402 100644 --- a/examples/hello/main.go +++ b/examples/hello/main.go @@ -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 { diff --git a/go.mod b/go.mod index 0c95a73d..1f317746 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index e10725ba..14ba9efd 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/build/build.go b/internal/build/build.go index 145702f9..70ddd911 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -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") diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 924d6841..33563703 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -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) } } diff --git a/internal/build/testdata/new/main.go.golden b/internal/build/testdata/new/main.go.golden index 2dfcd67c..321653ca 100644 --- a/internal/build/testdata/new/main.go.golden +++ b/internal/build/testdata/new/main.go.golden @@ -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 { diff --git a/modules/bonfire/README.md b/modules/bonfire/README.md index b525c11b..67146150 100644 --- a/modules/bonfire/README.md +++ b/modules/bonfire/README.md @@ -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 diff --git a/modules/bonfire/styles.go b/modules/bonfire/styles.go new file mode 100644 index 00000000..dfbb5415 --- /dev/null +++ b/modules/bonfire/styles.go @@ -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 "" +} diff --git a/modules/diary/README.md b/modules/diary/README.md new file mode 100644 index 00000000..126c8d86 --- /dev/null +++ b/modules/diary/README.md @@ -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=`. 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..driver` | | `console`, `daily` or `stack`. | +| `log.channels..level` | none | The channel's own minimum level. | +| `log.channels..format` | per driver | `auto` (console only), `json` or `logfmt`; console defaults to `auto`, daily to `json`. | +| `log.channels..path` | | The daily file path, relative to the application root. | +| `log.channels..days` | `0` | Daily retention in days; `0` keeps every file. | +| `log.channels..channels` | | A stack's children. | + +## 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. diff --git a/modules/diary/channels.go b/modules/diary/channels.go new file mode 100644 index 00000000..d30ad4b3 --- /dev/null +++ b/modules/diary/channels.go @@ -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) +} diff --git a/modules/diary/component.go b/modules/diary/component.go new file mode 100644 index 00000000..c8d728e8 --- /dev/null +++ b/modules/diary/component.go @@ -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]), + } +} diff --git a/modules/diary/config.go b/modules/diary/config.go new file mode 100644 index 00000000..d23f3ab2 --- /dev/null +++ b/modules/diary/config.go @@ -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.). + ChannelsKey = "log.channels" + // ComponentsKey holds per-component levels (log.components.). + 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 +} diff --git a/modules/diary/console.go b/modules/diary/console.go new file mode 100644 index 00000000..3e001af9 --- /dev/null +++ b/modules/diary/console.go @@ -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() +} diff --git a/modules/diary/daily.go b/modules/diary/daily.go new file mode 100644 index 00000000..f51a6d58 --- /dev/null +++ b/modules/diary/daily.go @@ -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 +} diff --git a/modules/diary/diary.go b/modules/diary/diary.go new file mode 100644 index 00000000..c282764b --- /dev/null +++ b/modules/diary/diary.go @@ -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= 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) + } +} diff --git a/modules/diary/tracer_smoke_test.go b/modules/diary/tracer_smoke_test.go new file mode 100644 index 00000000..e66972f8 --- /dev/null +++ b/modules/diary/tracer_smoke_test.go @@ -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) + } +}