From 44291adccaf711cf1a32fd7fdb203a7a375081b9 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 21 Oct 2026 11:58:22 +0200 Subject: [PATCH] refactor: framework modules log through diary components - Each module logs through diary.For(): cabana, lagoon, attach, cooler, postcard, beachcomber, lighthouse, centrifugo, flare, phrasebook, wristband, bouncer - App-published *slog.Logger overrides stay first; messages and attributes unchanged - sunscreen: InstallDefault documented as the compatibility entry point; diary wraps every channel - READMEs list diary as a dependency; logging docs: plugins use diary.For --- docs/services/logging.md | 10 +++++++++- modules/beachcomber/README.md | 2 +- modules/beachcomber/beachcomber.go | 12 +++++++++--- modules/bouncer/README.md | 4 ++-- modules/bouncer/turnstile.go | 11 ++++++++--- modules/cabana/README.md | 2 +- modules/cabana/account.go | 23 +++++++++++------------ modules/cabana/account_preferences.go | 5 ++--- modules/cabana/account_tokens.go | 13 ++++++------- modules/cabana/actions.go | 9 ++++----- modules/cabana/api_tokens.go | 3 +-- modules/cabana/auth.go | 3 +-- modules/cabana/contracts.go | 3 +-- modules/cabana/crud.go | 3 +-- modules/cabana/field_dependent.go | 3 +-- modules/cabana/field_file.go | 17 ++++++++--------- modules/cabana/field_type.go | 3 +-- modules/cabana/http.go | 7 +++---- modules/cabana/log.go | 7 +++++++ modules/cabana/partial_render.go | 3 +-- modules/cabana/query.go | 9 ++++----- modules/cabana/relation_field.go | 3 +-- modules/cabana/reorder.go | 3 +-- modules/cabana/singleton.go | 3 +-- modules/cabana/token_guard.go | 3 +-- modules/cooler/README.md | 2 +- modules/cooler/cooler.go | 9 +++++++-- modules/cooler/memory.go | 2 +- modules/cooler/ratelimit.go | 3 +-- modules/cooler/rediscache.go | 2 +- modules/cooler/sitecache.go | 2 +- modules/diary/README.md | 2 +- modules/flare/README.md | 2 +- modules/flare/flare.go | 12 +++++++++--- modules/lagoon/README.md | 2 +- modules/lagoon/attach/bucket.go | 7 ++++++- modules/lagoon/attach/thumb.go | 2 +- modules/lagoon/connection.go | 4 ++++ modules/lagoon/fill.go | 3 +-- modules/lagoon/purge.go | 6 +++--- modules/lagoon/transaction.go | 5 ++--- modules/lighthouse/README.md | 4 ++-- modules/lighthouse/centrifugo/client.go | 7 ++++++- modules/lighthouse/lighthouse.go | 12 +++++++++--- modules/phrasebook/README.md | 2 +- modules/phrasebook/translator.go | 8 ++++++-- modules/postcard/README.md | 4 ++-- modules/postcard/drivers.go | 5 +++-- modules/postcard/mailer.go | 7 ++++++- modules/sunscreen/README.md | 11 +++++------ modules/sunscreen/sunscreen.go | 13 +++++++++---- modules/wristband/README.md | 4 ++-- modules/wristband/oidc_token.go | 9 +++++++-- 53 files changed, 185 insertions(+), 130 deletions(-) create mode 100644 modules/cabana/log.go diff --git a/docs/services/logging.md b/docs/services/logging.md index 2f91c8d9..75f672b3 100644 --- a/docs/services/logging.md +++ b/docs/services/logging.md @@ -130,7 +130,15 @@ logger.Info("vendor call failed", ## Logging from a plugin -A plugin resolves a `*slog.Logger` from the application with `backpack.Lookup` and falls back to `slog.Default` when none is published, as the framework modules do. Either way the record goes through the redacting handler. +A plugin logs through its own component logger, named after its plugin code without dots, instead of the process default logger. The logger is safe to create in a package `var` block, before diary is set up: + +```go src=modules/diary/example_test.go#blogLog +// blogLog is the component logger of a blog plugin, created once in a +// package var block, before diary is set up. +var blogLog = diary.For("blog") +``` + +Its records carry `component=blog`, follow `log.components.blog` and go through the redacting channels. Redaction is a safety net for mistakes, not a way to log secrets on purpose. Log identifiers and outcomes: the record id, the vendor, the status code, how long it took. Never log request arguments, tokens, credentials or request and response bodies, which can carry personal data the patterns do not recognise. diff --git a/modules/beachcomber/README.md b/modules/beachcomber/README.md index 4af5baaf..f7b40ec4 100644 --- a/modules/beachcomber/README.md +++ b/modules/beachcomber/README.md @@ -183,7 +183,7 @@ ids, err := svc.Engine().SearchIDs(ctx, svc.IndexName(&models.Post{}), beachcomb ## Dependencies -- `backpack`, `compass` and `lagoon` (callback installation and `lagoon.AfterCommit`) from this repository. +- `backpack`, `compass`, `diary` (component logger `beachcomber` when the app publishes no logger) and `lagoon` (callback installation and `lagoon.AfterCommit`) from this repository. - `gorm.io/gorm` (sync callbacks and reloads). - The Typesense client is plain `net/http`; no Typesense SDK is used. diff --git a/modules/beachcomber/beachcomber.go b/modules/beachcomber/beachcomber.go index 800bcbf0..6a14aa10 100644 --- a/modules/beachcomber/beachcomber.go +++ b/modules/beachcomber/beachcomber.go @@ -16,10 +16,15 @@ import ( "sync" "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/diary" "git.golem15.com/golem15/summercms/modules/lagoon" "gorm.io/gorm" ) +// moduleLog is beachcomber's logger: records carry component=beachcomber and follow +// log.components.beachcomber (diary). +var moduleLog = diary.For("beachcomber") + // DefaultDriver is the engine used when search.driver is empty. const DefaultDriver = NullEngine @@ -130,10 +135,11 @@ func (s *Service) IndexName(m Searchable) string { return s.Prefix() + m.SearchableAs() } -// Logger returns the app logger sync failures are logged through. +// Logger returns the app logger sync failures are logged through: the +// logger the application published, else the beachcomber component logger. func (s *Service) Logger() *slog.Logger { if s == nil || s.log == nil { - return slog.Default() + return moduleLog } return s.log } @@ -179,5 +185,5 @@ func loggerFromApp(app *backpack.App) *slog.Logger { return log } } - return slog.Default() + return moduleLog } diff --git a/modules/bouncer/README.md b/modules/bouncer/README.md index 250cab4f..fd52fc4c 100644 --- a/modules/bouncer/README.md +++ b/modules/bouncer/README.md @@ -12,7 +12,7 @@ bouncer decides who is making a request. Guards (`bouncer.Guard`, `bouncer.Crede ## Role in the framework -A dependency-free security primitive. `surf` uses it for request guards, `cabana` for admin sign-in, and `conga` and `lighthouse` for authenticated workers and realtime tokens. It is separate so authentication code can be reviewed and tested on its own, without the HTTP or admin layers around it. +A security primitive whose only framework dependency is `diary`, for the turnstile log lines. `surf` uses it for request guards, `cabana` for admin sign-in, and `conga` and `lighthouse` for authenticated workers and realtime tokens. It is separate so authentication code can be reviewed and tested on its own, without the HTTP or admin layers around it. Use cases: @@ -198,7 +198,7 @@ Environment variables follow the compass mapping, for example `SUMMER_AUTH__COOK ## Dependencies -- SummerCMS modules: none. +- SummerCMS modules: [diary](../diary/README.md) (component logger `bouncer` for the turnstile verification lines). - Third-party: `github.com/golang-jwt/jwt/v5`, `golang.org/x/crypto/bcrypt`. - Standard library: `bytes`, `context`, `crypto/rand`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `io`, `log/slog`, `math`, `mime`, `net`, `net/http`, `net/url`, `reflect`, `regexp`, `strconv`, `strings`, `sync`, `time`. - Tests additionally use `github.com/testcontainers/testcontainers-go` with its `modules/postgres` package, and `github.com/jackc/pgx/v5/stdlib`. diff --git a/modules/bouncer/turnstile.go b/modules/bouncer/turnstile.go index 3b072cf2..30b58259 100644 --- a/modules/bouncer/turnstile.go +++ b/modules/bouncer/turnstile.go @@ -7,15 +7,20 @@ import ( "errors" "fmt" "io" - "log/slog" "mime" "net" "net/http" "net/url" "strings" "time" + + "git.golem15.com/golem15/summercms/modules/diary" ) +// moduleLog is bouncer's logger: records carry component=bouncer and follow +// log.components.bouncer (diary). +var moduleLog = diary.For("bouncer") + const ( // TurnstileSiteverifyURL is Cloudflare's server-side validation endpoint. TurnstileSiteverifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" @@ -230,9 +235,9 @@ func (t *Turnstile) Middleware(failClosed bool, field string, clientIP func(*htt verdict, verr := t.Verify(r.Context(), token, clientIP(r), failClosed) if verr != nil { if errors.Is(verr, errTurnstileRefused) { - slog.DebugContext(r.Context(), "bouncer: turnstile token refused", "error", verr.Error()) + moduleLog.DebugContext(r.Context(), "bouncer: turnstile token refused", "error", verr.Error()) } else { - slog.WarnContext(r.Context(), "bouncer: turnstile verification failed", "error", verr.Error()) + moduleLog.WarnContext(r.Context(), "bouncer: turnstile verification failed", "error", verr.Error()) } } switch verdict { diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 8e630b1a..3b144b2a 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -370,7 +370,7 @@ TOKEN=$(./bin/acme admin:token:create admin@example.com --name agent --scope blo ## Dependencies -- SummerCMS modules: [backpack](../backpack/README.md), [boardwalk](../boardwalk/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [lagoon](../lagoon/README.md), [pact](../pact/README.md), [party](../party/README.md), [phrasebook](../phrasebook/README.md), [seaglass](../seaglass/README.md) (sanitizes `richeditor` values), [towel](../towel/README.md). +- SummerCMS modules: [backpack](../backpack/README.md), [boardwalk](../boardwalk/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [diary](../diary/README.md) (component logger `cabana`; the `admin.auth` audit record keeps its message), [lagoon](../lagoon/README.md), [pact](../pact/README.md), [party](../party/README.md), [phrasebook](../phrasebook/README.md), [seaglass](../seaglass/README.md) (sanitizes `richeditor` values), [towel](../towel/README.md). - Third-party: `gorm.io/gorm` (with `gorm.io/gorm/clause`), `github.com/goccy/go-yaml` (with its `ast` package), `golang.org/x/net/html` (with its `atom` package; parses rendered partials for the allowlist walk). - Standard library: `bytes`, `context`, `crypto/sha256`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `html/template`, `io`, `io/fs`, `log/slog`, `math`, `net`, `net/http`, `path`, `reflect`, `regexp`, `sort`, `strconv`, `strings`, `time`, `unicode/utf8`. - Tests additionally use `github.com/testcontainers/testcontainers-go` and its `modules/postgres` package, and [signpost](../signpost/README.md) for the dependent dropdown fixture. diff --git a/modules/cabana/account.go b/modules/cabana/account.go index 12c1b6b8..8f714606 100644 --- a/modules/cabana/account.go +++ b/modules/cabana/account.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "io" - "log/slog" "net/http" "slices" "strconv" @@ -170,7 +169,7 @@ func (s *service) profileFor(ctx context.Context, db *gorm.DB, user BackendUser) func (s *service) avatarOf(ctx context.Context, db *gorm.DB, userID uint) *AdminAvatar { f, err := BackendUserAvatar(ctx, db, userID) if err != nil { - slog.Default().WarnContext(ctx, "cabana: avatar lookup failed", "admin_id", userID, "error", err) + moduleLog.WarnContext(ctx, "cabana: avatar lookup failed", "admin_id", userID, "error", err) return nil } if f == nil || !f.Public() { @@ -180,7 +179,7 @@ func (s *service) avatarOf(ctx context.Context, db *gorm.DB, userID uint) *Admin if bucket := s.bucket(); bucket != nil && slices.Contains(attach.AllowedImageMIMEs, f.ContentType) { thumb, err := f.Thumb(ctx, bucket, avatarThumbEdge, avatarThumbEdge, "crop") if err != nil { - slog.Default().WarnContext(ctx, "cabana: avatar thumbnail failed", "admin_id", userID, "file_id", f.ID, "error", err) + moduleLog.WarnContext(ctx, "cabana: avatar thumbnail failed", "admin_id", userID, "file_id", f.ID, "error", err) } else { out.ThumbURL = thumb } @@ -304,7 +303,7 @@ func (s *service) accountUpdate(w http.ResponseWriter, r *http.Request) { } db, err := s.db() if err != nil { - slog.Default().ErrorContext(r.Context(), "cabana: account update without a database", "error", err) + moduleLog.ErrorContext(r.Context(), "cabana: account update without a database", "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } @@ -411,7 +410,7 @@ func (s *service) accountUpdate(w http.ResponseWriter, r *http.Request) { if _, fromCookie := sessionToken(r); fromCookie { token, _, err := bouncer.MintAudience(s.secret, uitoa(principal.ID), s.issuer, s.ttl, bouncer.AudienceBackend) if err != nil { - slog.Default().ErrorContext(r.Context(), "cabana: minting the session after a password change failed", "admin_id", principal.ID, "error", err) + moduleLog.ErrorContext(r.Context(), "cabana: minting the session after a password change failed", "admin_id", principal.ID, "error", err) } else { s.writeSessionCookie(w, token) } @@ -437,7 +436,7 @@ func (s *service) revokePresentedToken(r *http.Request) { expiresAt = until } if err := s.bl.Add(r.Context(), jti, expiresAt, time.Now()); err != nil { - slog.Default().WarnContext(r.Context(), "cabana: revoking the token after a password change failed", "error", err) + moduleLog.WarnContext(r.Context(), "cabana: revoking the token after a password change failed", "error", err) } } @@ -501,7 +500,7 @@ func (s *service) writeAccountError(w http.ResponseWriter, r *http.Request, err var ve *ValidationError var tooBig *http.MaxBytesError if !errors.As(err, &ve) && !errors.As(err, &tooBig) { - slog.Default().ErrorContext(r.Context(), "cabana: account route failed", "path", r.URL.Path, "error", err) + moduleLog.ErrorContext(r.Context(), "cabana: account route failed", "path", r.URL.Path, "error", err) } writeCRUDError(w, err) } @@ -548,11 +547,11 @@ func deleteAvatarBlobs(ctx context.Context, tx *gorm.DB, bucket *blob.Bucket, us } lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) { if bucket == nil { - slog.Default().WarnContext(ctx, "cabana: no storage bucket; blobs of a removed avatar were kept", "admin_id", userID) + moduleLog.WarnContext(ctx, "cabana: no storage bucket; blobs of a removed avatar were kept", "admin_id", userID) return } if err := attach.DeleteKeys(context.WithoutCancel(ctx), bucket, keys); err != nil { - slog.Default().WarnContext(ctx, "cabana: deleting a removed avatar's blobs failed", "admin_id", userID, "error", err) + moduleLog.WarnContext(ctx, "cabana: deleting a removed avatar's blobs failed", "admin_id", userID, "error", err) } }) } @@ -569,13 +568,13 @@ func (s *service) avatarUpload(w http.ResponseWriter, r *http.Request) { } db, err := s.db() if err != nil { - slog.Default().ErrorContext(r.Context(), "cabana: avatar upload without a database", "error", err) + moduleLog.ErrorContext(r.Context(), "cabana: avatar upload without a database", "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } bucket := s.bucket() if bucket == nil { - slog.Default().ErrorContext(r.Context(), "cabana: avatar upload without a storage bucket") + moduleLog.ErrorContext(r.Context(), "cabana: avatar upload without a storage bucket") WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } @@ -655,7 +654,7 @@ func (s *service) avatarRemove(w http.ResponseWriter, r *http.Request) { } db, err := s.db() if err != nil { - slog.Default().ErrorContext(r.Context(), "cabana: avatar removal without a database", "error", err) + moduleLog.ErrorContext(r.Context(), "cabana: avatar removal without a database", "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } diff --git a/modules/cabana/account_preferences.go b/modules/cabana/account_preferences.go index e4caa222..ca90cea3 100644 --- a/modules/cabana/account_preferences.go +++ b/modules/cabana/account_preferences.go @@ -3,7 +3,6 @@ package cabana import ( "context" "errors" - "log/slog" "net/http" "slices" "sort" @@ -60,7 +59,7 @@ func (s *service) accountPreferencesGet(w http.ResponseWriter, r *http.Request) } db, err := s.db() if err != nil { - slog.Default().ErrorContext(r.Context(), "cabana: account preferences without a database", "error", err) + moduleLog.ErrorContext(r.Context(), "cabana: account preferences without a database", "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } @@ -104,7 +103,7 @@ func (s *service) accountPreferencesUpdate(w http.ResponseWriter, r *http.Reques } db, err := s.db() if err != nil { - slog.Default().ErrorContext(ctx, "cabana: account preferences without a database", "error", err) + moduleLog.ErrorContext(ctx, "cabana: account preferences without a database", "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } diff --git a/modules/cabana/account_tokens.go b/modules/cabana/account_tokens.go index bcfbf16a..36d1cc3c 100644 --- a/modules/cabana/account_tokens.go +++ b/modules/cabana/account_tokens.go @@ -2,7 +2,6 @@ package cabana import ( "context" - "log/slog" "net/http" "strconv" "strings" @@ -56,7 +55,7 @@ func (s *service) accountTokensList(w http.ResponseWriter, r *http.Request) { } db, err := s.db() if err != nil { - slog.Default().ErrorContext(r.Context(), "cabana: account tokens list without a database", "error", err) + moduleLog.ErrorContext(r.Context(), "cabana: account tokens list without a database", "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } @@ -71,7 +70,7 @@ func (s *service) accountTokensList(w http.ResponseWriter, r *http.Request) { } var rows []BackendAPIToken if err := q.Order("created_at DESC").Order("id DESC").Find(&rows).Error; err != nil { - slog.Default().ErrorContext(r.Context(), "cabana: account tokens list", "error", err) + moduleLog.ErrorContext(r.Context(), "cabana: account tokens list", "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } @@ -114,14 +113,14 @@ func (s *service) accountTokenCreate(w http.ResponseWriter, r *http.Request) { } db, err := s.db() if err != nil { - slog.Default().ErrorContext(r.Context(), "cabana: account token create without a database", "error", err) + moduleLog.ErrorContext(r.Context(), "cabana: account token create without a database", "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } now := time.Now().UTC() plain, row, details, err := issueAPIToken(db, s.reg, principal, name, expires, scopes, now, false) if err != nil { - slog.Default().ErrorContext(r.Context(), "cabana: account token create", "error", err) + moduleLog.ErrorContext(r.Context(), "cabana: account token create", "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } @@ -148,14 +147,14 @@ func (s *service) accountTokenRevoke(w http.ResponseWriter, r *http.Request) { } db, err := s.db() if err != nil { - slog.Default().ErrorContext(r.Context(), "cabana: account token revoke without a database", "error", err) + moduleLog.ErrorContext(r.Context(), "cabana: account token revoke without a database", "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } revokedBy := principal.ID row, notFound, err := revokeAPIToken(db, principal.ID, uint(id), &revokedBy) if err != nil { - slog.Default().ErrorContext(r.Context(), "cabana: account token revoke", "error", err) + moduleLog.ErrorContext(r.Context(), "cabana: account token revoke", "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } diff --git a/modules/cabana/actions.go b/modules/cabana/actions.go index 326fe1ac..0b22b7ea 100644 --- a/modules/cabana/actions.go +++ b/modules/cabana/actions.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "io" - "log/slog" "net/http" "git.golem15.com/golem15/summercms/modules/bouncer" @@ -163,7 +162,7 @@ func (s *service) bulkAction(w http.ResponseWriter, r *http.Request) { if principal, _ := bouncer.User(r.Context()); principal != nil { adminID = principal.ID } - slog.Info("cabana: admin bulk action", "controller", controllerID(cc), "action", name, "admin_id", adminID, "affected", result.Affected) + moduleLog.Info("cabana: admin bulk action", "controller", controllerID(cc), "action", name, "admin_id", adminID, "affected", result.Affected) WriteData(w, http.StatusOK, result, nil) }) } @@ -229,7 +228,7 @@ func (s *service) recordAction(w http.ResponseWriter, r *http.Request) { if principal, _ := bouncer.User(r.Context()); principal != nil { adminID = principal.ID } - slog.Info("cabana: admin record action", "controller", controllerID(cc), "action", name, "admin_id", adminID, "record_id", id) + moduleLog.Info("cabana: admin record action", "controller", controllerID(cc), "action", name, "admin_id", adminID, "record_id", id) WriteData(w, http.StatusOK, result, nil) }) } @@ -288,7 +287,7 @@ func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *Compiled writeCRUDError(w, localizeControllerError(ctx, tr, err)) return } - slog.Error("cabana: admin action failed", "controller", controllerID(cc), "action", action.Name, "field", input.Field, "error", err) + moduleLog.Error("cabana: admin action failed", "controller", controllerID(cc), "action", action.Name, "field", input.Field, "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } @@ -299,7 +298,7 @@ func (s *service) runAction(w http.ResponseWriter, r *http.Request, cc *Compiled if result.Data != nil { raw, err := json.Marshal(result.Data) if err != nil || len(raw) > maxActionDataBytes { - slog.Error("cabana: admin action data rejected", "controller", controllerID(cc), "action", action.Name, "field", input.Field, "bytes", len(raw), "error", err) + moduleLog.Error("cabana: admin action data rejected", "controller", controllerID(cc), "action", action.Name, "field", input.Field, "bytes", len(raw), "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } diff --git a/modules/cabana/api_tokens.go b/modules/cabana/api_tokens.go index f6c6b7b5..46566c65 100644 --- a/modules/cabana/api_tokens.go +++ b/modules/cabana/api_tokens.go @@ -6,7 +6,6 @@ import ( "crypto/sha256" "encoding/hex" "fmt" - "log/slog" "strings" "sync" "time" @@ -301,6 +300,6 @@ func stampLastUsed(ctx context.Context, stamps *stampThrottle, store tokenStore, return } if err := store.StampLastUsed(ctx, id, now); err != nil { - slog.Default().WarnContext(ctx, "cabana: token last_used_at stamp failed", "token_id", id, "error", err) + moduleLog.WarnContext(ctx, "cabana: token last_used_at stamp failed", "token_id", id, "error", err) } } diff --git a/modules/cabana/auth.go b/modules/cabana/auth.go index 18fe5f8e..408597ac 100644 --- a/modules/cabana/auth.go +++ b/modules/cabana/auth.go @@ -5,7 +5,6 @@ import ( "database/sql" "encoding/json" "errors" - "log/slog" "net" "net/http" "strconv" @@ -392,7 +391,7 @@ func (s *service) logAuth(r *http.Request, outcome string, adminID uint) { if adminID != 0 { args = append(args, "admin_id", adminID) } - slog.Default().Info("admin.auth", args...) + moduleLog.Info("admin.auth", args...) } func adminBlacklist(app *backpack.App) bouncer.BlacklistStore { diff --git a/modules/cabana/contracts.go b/modules/cabana/contracts.go index 6df2f331..cb10cc08 100644 --- a/modules/cabana/contracts.go +++ b/modules/cabana/contracts.go @@ -3,7 +3,6 @@ package cabana import ( "bytes" "encoding/json" - "log/slog" "net/http" "strings" "time" @@ -288,7 +287,7 @@ func WriteErrorDetails(w http.ResponseWriter, status int, code, message string, func writeJSON(w http.ResponseWriter, status int, body any) { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(body); err != nil { - slog.Error("cabana: response could not be encoded", "status", status, "error", err) + moduleLog.Error("cabana: response could not be encoded", "status", status, "error", err) buf.Reset() _ = json.NewEncoder(&buf).Encode(map[string]any{ "error": map[string]any{"code": "error", "message": msgServerError, "details": map[string]any{}}, diff --git a/modules/cabana/crud.go b/modules/cabana/crud.go index d949ffef..7d279c9b 100644 --- a/modules/cabana/crud.go +++ b/modules/cabana/crud.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "log/slog" "math" "net/http" "reflect" @@ -485,7 +484,7 @@ func actionFailure(cc *CompiledController, kind, name string, err error) error { out := lifecycleFailure(cc, err) var life *lifecycleError if errors.As(out, &life) && !errors.As(err, &life) { - slog.Error("cabana: admin "+kind+" failed", "controller", controllerID(cc), "action", name, "error", err) + moduleLog.Error("cabana: admin "+kind+" failed", "controller", controllerID(cc), "action", name, "error", err) } return out } diff --git a/modules/cabana/field_dependent.go b/modules/cabana/field_dependent.go index d6b5a0d4..aaebb45b 100644 --- a/modules/cabana/field_dependent.go +++ b/modules/cabana/field_dependent.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "log/slog" "net/http" "reflect" "strconv" @@ -247,7 +246,7 @@ func (s *service) dependentOptionsRoute(w http.ResponseWriter, r *http.Request) } set, err := dependentOptions(r.Context(), cc, field.Name, field.DependsOn, value) if err != nil { - slog.Error("cabana: dependent options failed", "controller", controllerID(cc), "field", field.Name, "error", err) + moduleLog.Error("cabana: dependent options failed", "controller", controllerID(cc), "field", field.Name, "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } diff --git a/modules/cabana/field_file.go b/modules/cabana/field_file.go index 29368e19..195d911f 100644 --- a/modules/cabana/field_file.go +++ b/modules/cabana/field_file.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "log/slog" "math" "net/http" "regexp" @@ -640,7 +639,7 @@ func fileItem(ctx context.Context, bucket *blob.Bucket, cf *compiledFile, f *att if bucket != nil && slices.Contains(attach.AllowedImageMIMEs, f.ContentType) { thumb, err := f.Thumb(ctx, bucket, cf.thumbW, cf.thumbH, cf.thumbMode) if err != nil { - slog.Default().WarnContext(ctx, "cabana: file thumbnail failed", "file_id", f.ID, "error", err) + moduleLog.WarnContext(ctx, "cabana: file thumbnail failed", "file_id", f.ID, "error", err) } else { item.ThumbURL = thumb } @@ -707,7 +706,7 @@ func (s *service) fileUploadOn(w http.ResponseWriter, r *http.Request, cc *Compi } bucket := s.bucket() if bucket == nil { - slog.Default().ErrorContext(r.Context(), "cabana: file upload without a storage bucket", "controller", controllerID(cc)) + moduleLog.ErrorContext(r.Context(), "cabana: file upload without a storage bucket", "controller", controllerID(cc)) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } @@ -854,7 +853,7 @@ func logFileFailure(r *http.Request, err error) { return } controller := r.PathValue("vendor") + "." + r.PathValue("plugin") + "." + r.PathValue("controller") - slog.Default().ErrorContext(r.Context(), "cabana: file route failed", "controller", controller, "field", r.PathValue("field"), "error", err) + moduleLog.ErrorContext(r.Context(), "cabana: file route failed", "controller", controller, "field", r.PathValue("field"), "error", err) } // bodyReader remembers the first read error of the request body other than @@ -1163,11 +1162,11 @@ func deleteBlobsAfterCommit(ctx context.Context, tx *gorm.DB, bucket *blob.Bucke keys := attach.BlobKeys(f) lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) { if bucket == nil { - slog.Default().WarnContext(ctx, "cabana: no storage bucket; blobs of a deleted file were kept", "file_id", f.ID) + moduleLog.WarnContext(ctx, "cabana: no storage bucket; blobs of a deleted file were kept", "file_id", f.ID) return } if err := attach.DeleteKeys(ctx, bucket, keys); err != nil { - slog.Default().WarnContext(ctx, "cabana: deleting a file's blobs failed", "file_id", f.ID, "error", err) + moduleLog.WarnContext(ctx, "cabana: deleting a file's blobs failed", "file_id", f.ID, "error", err) } }) } @@ -1409,7 +1408,7 @@ func (s *service) serveProtectedFileOn(w http.ResponseWriter, r *http.Request, c } bucket := s.bucket() if bucket == nil { - slog.Default().ErrorContext(r.Context(), "cabana: protected file route without a storage bucket", "controller", controllerID(cc)) + moduleLog.ErrorContext(r.Context(), "cabana: protected file route without a storage bucket", "controller", controllerID(cc)) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } @@ -1438,7 +1437,7 @@ func (s *service) serveProtectedFileOn(w http.ResponseWriter, r *http.Request, c } key, err = f.ThumbKey(ctx, bucket, cf.thumbW, cf.thumbH, cf.thumbMode) if err != nil { - slog.Default().ErrorContext(ctx, "cabana: protected thumbnail failed", "file_id", f.ID, "error", err) + moduleLog.ErrorContext(ctx, "cabana: protected thumbnail failed", "file_id", f.ID, "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } @@ -1450,7 +1449,7 @@ func (s *service) serveProtectedFileOn(w http.ResponseWriter, r *http.Request, c writeNotFound(w, r) return } - slog.Default().ErrorContext(ctx, "cabana: protected file read failed", "file_id", f.ID, "error", err) + moduleLog.ErrorContext(ctx, "cabana: protected file read failed", "file_id", f.ID, "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } diff --git a/modules/cabana/field_type.go b/modules/cabana/field_type.go index b393c15b..56b98527 100644 --- a/modules/cabana/field_type.go +++ b/modules/cabana/field_type.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "log/slog" "net/http" "reflect" "regexp" @@ -686,7 +685,7 @@ func fieldTypeFailure(ctx context.Context, tr *phrasebook.Translator, cc *Compil // opaqueFieldTypeFailure logs a plugin field type's error and returns the // opaque lifecycle error (500 without the plugin's text). func opaqueFieldTypeFailure(cc *CompiledController, field FormField, stage string, err error) error { - slog.Error("cabana: field type "+stage+" failed", "controller", controllerID(cc), "field", field.Name, "type", field.Type, "error", err) + moduleLog.Error("cabana: field type "+stage+" failed", "controller", controllerID(cc), "field", field.Name, "type", field.Type, "error", err) return &lifecycleError{ControllerID: controllerID(cc)} } diff --git a/modules/cabana/http.go b/modules/cabana/http.go index 9909ad3a..4e65cddf 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "io" - "log/slog" "net/http" "reflect" "slices" @@ -779,7 +778,7 @@ func (s *service) formSchema(w http.ResponseWriter, r *http.Request) { // A permission editor's options are the controller's answer for this // administrator (D-16); kept is this request's own slice. if err := localizePermissionOptions(r.Context(), s.translator(), cc, kept); err != nil { - slog.Error("cabana: permission options failed", "controller", controllerID(cc), "error", err) + moduleLog.Error("cabana: permission options failed", "controller", controllerID(cc), "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) return } @@ -1046,7 +1045,7 @@ func (s *service) enabledContentLocales(ctx context.Context) []string { } codes, err := writer.EnabledLocales(ctx, db) if err != nil { - slog.Error("cabana: enabled locales", "error", err) + moduleLog.Error("cabana: enabled locales", "error", err) return nil } // The SPA opens every ML control on the first code, and the default @@ -1054,7 +1053,7 @@ func (s *service) enabledContentLocales(ctx context.Context) []string { // and the other codes keep the writer's order. def, err := writer.DefaultLocale(ctx, db) if err != nil { - slog.Error("cabana: default locale", "error", err) + moduleLog.Error("cabana: default locale", "error", err) return codes } if i := slices.Index(codes, def); def != "" && i > 0 { diff --git a/modules/cabana/log.go b/modules/cabana/log.go new file mode 100644 index 00000000..b6ead62e --- /dev/null +++ b/modules/cabana/log.go @@ -0,0 +1,7 @@ +package cabana + +import "git.golem15.com/golem15/summercms/modules/diary" + +// moduleLog is cabana's logger: records carry component=cabana and follow +// log.components.cabana (diary). +var moduleLog = diary.For("cabana") diff --git a/modules/cabana/partial_render.go b/modules/cabana/partial_render.go index 7c808ebe..1697460b 100644 --- a/modules/cabana/partial_render.go +++ b/modules/cabana/partial_render.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "html/template" - "log/slog" "net/http" "reflect" "strconv" @@ -528,7 +527,7 @@ func (s *service) partial(w http.ResponseWriter, r *http.Request) { tr := s.translator() ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), tr)) fail := func(reason string, err error) { - slog.Error("cabana: admin partial failed", "controller", controllerID(cc), "partial", name, "reason", reason, "error", err) + moduleLog.Error("cabana: admin partial failed", "controller", controllerID(cc), "partial", name, "reason", reason, "error", err) WriteError(w, http.StatusInternalServerError, "error", msgServerError) } vm, err := provider.PartialData(ctx, name, record) diff --git a/modules/cabana/query.go b/modules/cabana/query.go index 77b8610f..01119c71 100644 --- a/modules/cabana/query.go +++ b/modules/cabana/query.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "log/slog" "net/http" "reflect" "slices" @@ -154,7 +153,7 @@ func ExecuteList(ctx context.Context, db *gorm.DB, cc *CompiledController, in Li } if hook, ok := cc.Controller.(pact.ListExtendRecords); ok && hook != nil && len(records) > 0 { if err := hook.ListExtendRecords(ctx, db.WithContext(ctx), records); err != nil { - slog.Error("cabana: list extend records failed", "controller", controllerID(cc), "error", err) + moduleLog.Error("cabana: list extend records failed", "controller", controllerID(cc), "error", err) return nil, err } } @@ -195,11 +194,11 @@ func listRowStates(ctx context.Context, db *gorm.DB, cc *CompiledController, rec } result, err := hook.ListRowStates(ctx, db.WithContext(ctx), records) if err != nil { - slog.Error("cabana: list row states failed", "controller", controllerID(cc), "error", err) + moduleLog.Error("cabana: list row states failed", "controller", controllerID(cc), "error", err) return nil, err } if len(result) != len(records) { - slog.Error("cabana: list row states are not aligned with the page", "controller", controllerID(cc), "records", len(records), "states", len(result)) + moduleLog.Error("cabana: list row states are not aligned with the page", "controller", controllerID(cc), "records", len(records), "states", len(result)) return nil, fmt.Errorf("cabana: controller %s returned %d row states for %d records", controllerID(cc), len(result), len(records)) } var out map[string][]string @@ -210,7 +209,7 @@ func listRowStates(ctx context.Context, db *gorm.DB, cc *CompiledController, rec has := map[pact.RowState]bool{} for _, state := range states { if !slices.Contains(rowStateOrder, state) { - slog.Warn("cabana: unknown list row state dropped", "controller", controllerID(cc), "state", string(state)) + moduleLog.Warn("cabana: unknown list row state dropped", "controller", controllerID(cc), "state", string(state)) continue } has[state] = true diff --git a/modules/cabana/relation_field.go b/modules/cabana/relation_field.go index 8d2e0499..0882d8e8 100644 --- a/modules/cabana/relation_field.go +++ b/modules/cabana/relation_field.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "log/slog" "math" "net/http" "reflect" @@ -412,7 +411,7 @@ func relationLocks(ctx context.Context, cc *CompiledController, field string) (m } lock, err := provider.AdminRelationLocks(ctx, field) if err != nil { - slog.Error("cabana: relation locks failed", "controller", controllerID(cc), "field", field, "error", err) + moduleLog.Error("cabana: relation locks failed", "controller", controllerID(cc), "field", field, "error", err) return nil, "", lifecycleFailure(cc, err) } if len(lock.IDs) == 0 { diff --git a/modules/cabana/reorder.go b/modules/cabana/reorder.go index 3b726488..c3edd34b 100644 --- a/modules/cabana/reorder.go +++ b/modules/cabana/reorder.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "io/fs" - "log/slog" "net/http" "path" "reflect" @@ -381,7 +380,7 @@ func (s *service) writeReorderError(w http.ResponseWriter, r *http.Request, cc * var ve *ValidationError var missing recordNotFound if !errors.As(err, &ve) && !errors.As(err, &missing) { - slog.Error("cabana: reorder failed", "controller", controllerID(cc), "error", err) + moduleLog.Error("cabana: reorder failed", "controller", controllerID(cc), "error", err) } writeCRUDError(w, err) } diff --git a/modules/cabana/singleton.go b/modules/cabana/singleton.go index 89f1dad6..7e09cd8a 100644 --- a/modules/cabana/singleton.go +++ b/modules/cabana/singleton.go @@ -2,7 +2,6 @@ package cabana import ( "errors" - "log/slog" "net/http" "git.golem15.com/golem15/summercms/modules/pact" @@ -101,7 +100,7 @@ func (s *service) singletonRoute(w http.ResponseWriter, r *http.Request) { if err != nil { var missing recordNotFound if !errors.As(err, &missing) { - slog.Error("cabana: singleton record failed", "controller", controllerID(cc), "error", err) + moduleLog.Error("cabana: singleton record failed", "controller", controllerID(cc), "error", err) } // A refusal from SingletonRecord carries plugin phrase keys. writeCRUDError(w, localizeControllerError(ctx, s.translator(), err)) diff --git a/modules/cabana/token_guard.go b/modules/cabana/token_guard.go index 2e4070f8..e8df7def 100644 --- a/modules/cabana/token_guard.go +++ b/modules/cabana/token_guard.go @@ -3,7 +3,6 @@ package cabana import ( "crypto/subtle" "errors" - "log/slog" "net" "net/http" "strings" @@ -248,5 +247,5 @@ func logTokenAuth(r *http.Request, outcome string, adminID, tokenID uint) { if tokenID != 0 { args = append(args, "token_id", tokenID) } - slog.Default().Info("admin.auth", args...) + moduleLog.Info("admin.auth", args...) } diff --git a/modules/cooler/README.md b/modules/cooler/README.md index 5497ca06..651268f6 100644 --- a/modules/cooler/README.md +++ b/modules/cooler/README.md @@ -211,7 +211,7 @@ func AllowExport(ctx context.Context, app *backpack.App, fallback cooler.Attempt ## Dependencies -- SummerCMS modules: [backpack](../backpack/README.md), [compass](../compass/README.md), [lagoon](../lagoon/README.md) (`lagoon.AfterCommit` for the after-commit purge). +- SummerCMS modules: [backpack](../backpack/README.md), [compass](../compass/README.md), [diary](../diary/README.md) (component logger `cooler` when the app publishes no logger), [lagoon](../lagoon/README.md) (`lagoon.AfterCommit` for the after-commit purge). - Third-party: `github.com/redis/go-redis/v9` (Redis client), `github.com/maypok86/otter/v2` (memory driver). - Third-party (purge): `gorm.io/gorm` (the save transaction handle). - Standard library: `bytes`, `context`, `encoding/json`, `errors`, `fmt`, `io`, `log/slog`, `net`, `net/http`, `net/url`, `strconv`, `strings`, `sync`, `time`. diff --git a/modules/cooler/cooler.go b/modules/cooler/cooler.go index be860383..33e5a3ff 100644 --- a/modules/cooler/cooler.go +++ b/modules/cooler/cooler.go @@ -22,9 +22,14 @@ import ( "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/diary" "github.com/redis/go-redis/v9" ) +// moduleLog is cooler's logger: records carry component=cooler and follow +// log.components.cooler (diary). +var moduleLog = diary.For("cooler") + // Config keys and defaults cooler reads. const ( // RedisURLKey is the shared Redis URL (redis://, rediss:// or unix://), @@ -387,7 +392,7 @@ func loggerFromApp(app *backpack.App) *slog.Logger { return log } } - return slog.Default() + return moduleLog } // errNilClient is returned when a Redis driver is built without a client. @@ -428,7 +433,7 @@ func (w *warner) warn(msg string, args ...any) { log := w.log w.mu.Unlock() if log == nil { - log = slog.Default() + log = moduleLog } log.Warn(msg, args...) } diff --git a/modules/cooler/memory.go b/modules/cooler/memory.go index 49181e83..147eaca7 100644 --- a/modules/cooler/memory.go +++ b/modules/cooler/memory.go @@ -56,7 +56,7 @@ func NewMemoryCache(prefix string, maxEntries int) (Cache, error) { if err != nil { return nil, fmt.Errorf("cooler: memory cache: %w", err) } - return &memoryCache{c: c, prefix: prefix, warn: newWarner(slog.Default())}, nil + return &memoryCache{c: c, prefix: prefix, warn: newWarner(moduleLog)}, nil } func (m *memoryCache) key(k string) string { return m.prefix + "cache:" + k } diff --git a/modules/cooler/ratelimit.go b/modules/cooler/ratelimit.go index d9234a9c..e2a1e9d6 100644 --- a/modules/cooler/ratelimit.go +++ b/modules/cooler/ratelimit.go @@ -3,7 +3,6 @@ package cooler import ( "context" "fmt" - "log/slog" "time" "github.com/redis/go-redis/v9" @@ -49,7 +48,7 @@ type RateLimitStore struct { // rl: on client. fallback answers while Redis fails; surf passes // a surf.MemoryStore. A nil fallback admits every request during an outage. func NewRateLimitStore(client *redis.Client, prefix string, fallback Attempter) *RateLimitStore { - return &RateLimitStore{client: client, prefix: prefix, fallback: fallback, warn: newWarner(slog.Default())} + return &RateLimitStore{client: client, prefix: prefix, fallback: fallback, warn: newWarner(moduleLog)} } // Attempt admits and counts one request when key is below max, in one diff --git a/modules/cooler/rediscache.go b/modules/cooler/rediscache.go index ce38f756..dcae3565 100644 --- a/modules/cooler/rediscache.go +++ b/modules/cooler/rediscache.go @@ -24,7 +24,7 @@ type redisCache struct { // NewRedisCache returns a Cache on client with keys cache:. // It does not dial; the first call does. func NewRedisCache(client *redis.Client, prefix string) Cache { - return &redisCache{client: client, prefix: prefix, warn: newWarner(slog.Default())} + return &redisCache{client: client, prefix: prefix, warn: newWarner(moduleLog)} } func (r *redisCache) key(k string) string { return r.prefix + "cache:" + k } diff --git a/modules/cooler/sitecache.go b/modules/cooler/sitecache.go index 7cf9d520..51ca3d11 100644 --- a/modules/cooler/sitecache.go +++ b/modules/cooler/sitecache.go @@ -158,7 +158,7 @@ func (p *SiteCachePurger) send(ctx context.Context, kind, slug string, coalesced } ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), SiteCachePurgeTimeout) defer cancel() - log := slog.Default() + log := moduleLog failed := func(attrs ...any) { attrs = append([]any{"kind", kind, "slug", slug}, attrs...) if coalesced > 0 { diff --git a/modules/diary/README.md b/modules/diary/README.md index 4ba98dfb..7f675af8 100644 --- a/modules/diary/README.md +++ b/modules/diary/README.md @@ -14,7 +14,7 @@ The application `main` that `summer build` generates calls `diary.Bootstrap` as ## 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. +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. River logs as component `river` (warn by default), GORM as `db` (through `lagoon`), and each framework module under its package name: `cabana`, `lagoon`, `attach`, `conga`, `cooler`, `postcard`, `beachcomber`, `lighthouse`, `centrifugo`, `flare`, `phrasebook`, `wristband` and `bouncer`. Modules that let the application publish its own `*slog.Logger` (`conga`, `cooler`, `postcard`, `beachcomber`, `lighthouse`, `flare`) still prefer it. Use cases: diff --git a/modules/flare/README.md b/modules/flare/README.md index 59c94104..08d31bcf 100644 --- a/modules/flare/README.md +++ b/modules/flare/README.md @@ -138,7 +138,7 @@ if err := app.Publish[flare.SubscriptionSource](blogSubscriptions{db: gdb}); err ## Dependencies -- `backpack`, `bonfire` and `compass` from this repository. +- `backpack`, `bonfire`, `compass` and `diary` (component logger `flare` when the app publishes no logger) from this repository. - `github.com/golang-jwt/jwt/v5` (the ES256 VAPID token). - Everything else is the standard library: `crypto/ecdh`, `crypto/ecdsa`, `crypto/hkdf`, `crypto/aes`, `crypto/cipher` and `net/http`. No Web Push library is used. diff --git a/modules/flare/flare.go b/modules/flare/flare.go index 2590e4f3..cbc04fc8 100644 --- a/modules/flare/flare.go +++ b/modules/flare/flare.go @@ -21,8 +21,13 @@ import ( "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/diary" ) +// moduleLog is flare's logger: records carry component=flare and follow +// log.components.flare (diary). +var moduleLog = diary.For("flare") + // DefaultTTL is how long a push service keeps an undelivered message when // push.ttl is not set: four weeks. const DefaultTTL = 2419200 * time.Second @@ -267,10 +272,11 @@ func (s *Service) SetHTTPClient(hc *http.Client) { s.pusher = NewVAPIDPusher(s.cfg, hc) } -// Logger returns the app logger. +// Logger returns the app logger: the logger the application published, else +// the flare component logger. func (s *Service) Logger() *slog.Logger { if s == nil || s.log == nil { - return slog.Default() + return moduleLog } return s.log } @@ -419,5 +425,5 @@ func loggerFromApp(app *backpack.App) *slog.Logger { return log } } - return slog.Default() + return moduleLog } diff --git a/modules/lagoon/README.md b/modules/lagoon/README.md index addf9f5a..65fd47f4 100644 --- a/modules/lagoon/README.md +++ b/modules/lagoon/README.md @@ -37,7 +37,7 @@ Use cases: - Column types: `lagoon.Encrypted` stores AES-256-GCM ciphertext under a key derived from `app.key`, decrypts with previous keys during rotation, and always redacts itself in JSON and string output; `lagoon.Jsonable` stores JSON as TEXT and keeps SQL NULL distinct from an empty value. - Lifecycle and relations: hook interfaces matching GORM's native method names (`lagoon.HasBeforeCreate`, `lagoon.HasBeforeSave`, `lagoon.HasBeforeDelete`, `lagoon.HasAfterDelete`) plus `lagoon.HasBeforeValidate`; `lagoon.WithSoftDeleteCascade` runs a cascade inside the parent delete; `lagoon.RegisterJoinTable` wires pivot models with business columns. - Deferred binding: WinterCMS's `deferred_bindings` table holds the uploads and related-record changes of a form whose record is not saved yet. Every operation takes a `lagoon.DeferredKey` (the form's session key, the owning backend admin's id and the master record's morph type from `lagoon.MorphType`) and never reads or changes another admin's rows, since each row stores `backend_user_id`. `lagoon.DeferredBind` and `lagoon.DeferredUnbind` port WinterCMS's duplicate and cancel rules: a repeated bind writes nothing, and an unbind of a slave with a pending bind deletes that bind and returns it so the caller can remove what it created. `lagoon.DeferredBindings` reads and locks a session's bindings for the save that commits them, `lagoon.DeferredForget` deletes them once applied, and `lagoon.DeferredSlaves` is the subquery a list uses to include pending rows. A child created under deferral carries the `lagoon.DeferredEnvelope` (`{"created":true,"pivot":{...},"upload_id":"..."}`) in `pivot_data`. `lagoon.PurgeDeferred` removes expired bindings: it deletes an unattached `system_files` row a bind points at, and its blobs only after the commit, deletes a child only when its binding carries the created envelope, keeps records that were only linked, and locks each batch with `FOR UPDATE SKIP LOCKED`. -- Logging: GORM's lines no longer go to stdout through GORM's own logger. They arrive through [diary](../diary/README.md) as component `db`: slow queries (over 200 ms) and errors at warn, while a missing record (`gorm.ErrRecordNotFound`) is no longer logged. At verbosity 3 (`-vvv`) every query is traced at info, with placeholders instead of the bound values. +- Logging: GORM's lines no longer go to stdout through GORM's own logger. They arrive through [diary](../diary/README.md) as component `db`: slow queries (over 200 ms) and errors at warn, while a missing record (`gorm.ErrRecordNotFound`) is no longer logged. At verbosity 3 (`-vvv`) every query is traced at info, with placeholders instead of the bound values. lagoon's own warnings log as component `lagoon`, and `attach` as component `attach`. - Imports from Laravel: `lagoon.DecryptLaravelPayload` decrypts Laravel `encrypted` payloads with the old application key, for one-off data imports. - Attachments (`attach`): the `attach.File` model for `system_files` rows, WinterCMS-compatible partitioned storage keys (`attach.BlobKey`, `attach.PartitionDirectory`), an S3 bucket option behind the `attach.S3BuildTag` build tag (an untagged binary refuses an `s3://` bucket URL at boot with an error naming the tag), public URLs (`attach.PublicURL` for any key, under `storage.uploads.public_url` when a public bucket or CDN serves the files (`attach.PublicURLBase`), `attach.File.URL` for an original, matching WinterCMS's `File::getPath()` under the WinterCMS layout), on-demand thumbnails through `attach.File.Thumb` for JPEG, PNG, GIF and WebP originals (a WebP original's thumbnail is JPEG bytes under its `.webp` name, since WebP cannot be encoded; a missing, undecodable or oversized original gets WinterCMS's broken-image picture, `attach.BrokenImagePNG`, as its thumbnail, as `File::makeThumb` does), storing uploads through `attach.Store` (a server-generated disk name whose extension follows the sniffed content through `attach.DiskExtension`, so a PNG named `x.html` is stored as `.png`, an extension allow-list with `attach.DefaultImageExtensions` and `attach.DefaultFileExtensions` as defaults, a MIME filter, a size limit enforced while streaming, in image mode the `attach.IsAllowedImage` content guard, and the refusal of protected uploads while `storage.uploads.public_url` serves the bucket publicly, `attach.ErrProtectedNeedsPrivateBucket`), attachment relation declarations (`attach.Relation`, `attach.HasRelations`), static serving with an optional `is_public` gate (`attach.StaticHandlerPublic`), and a two-phase delete that removes blobs only after the database transaction commits (`attach.DeleteForOwner`, `attach.DeleteKeys`). diff --git a/modules/lagoon/attach/bucket.go b/modules/lagoon/attach/bucket.go index e473fded..7499f831 100644 --- a/modules/lagoon/attach/bucket.go +++ b/modules/lagoon/attach/bucket.go @@ -10,11 +10,16 @@ import ( "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/diary" "gocloud.dev/blob" _ "gocloud.dev/blob/fileblob" _ "gocloud.dev/blob/memblob" ) +// moduleLog is attach's logger: records carry component=attach and follow +// log.components.attach (diary). +var moduleLog = diary.For("attach") + const defaultPublicPathPrefix = "/storage/uploads" // S3BuildTag is the Go build tag that links the gocloud S3 driver @@ -150,7 +155,7 @@ func OpenBucket(ctx context.Context, cfg *compass.Config) (*blob.Bucket, error) return bucket, nil } setPublicURLBase(publicURL.String()) - slog.Default().WarnContext(ctx, "attach: storage.uploads.public_url is set; every file and thumbnail URL points at this host, "+ + moduleLog.WarnContext(ctx, "attach: storage.uploads.public_url is set; every file and thumbnail URL points at this host, "+ "which serves any key without the is_public check, so the bucket behind it must hold only public files: "+ "protected (is_public false) uploads are refused", slog.String("host", publicURL.Host)) diff --git a/modules/lagoon/attach/thumb.go b/modules/lagoon/attach/thumb.go index 132a199a..5c39aeec 100644 --- a/modules/lagoon/attach/thumb.go +++ b/modules/lagoon/attach/thumb.go @@ -259,7 +259,7 @@ var BrokenImagePNG = func() []byte { // brokenThumb is the catch branch of WinterCMS's File::makeThumb: log the // reason, store BrokenImagePNG under the thumbnail key and return that key. func brokenThumb(ctx context.Context, bucket *blob.Bucket, f *File, thumbKey string, reason error) (string, error) { - slog.Default().WarnContext(ctx, "attach: thumbnail original is unusable, storing the broken-image picture", + moduleLog.WarnContext(ctx, "attach: thumbnail original is unusable, storing the broken-image picture", slog.Uint64("file_id", uint64(f.ID)), slog.String("error", reason.Error())) if err := bucket.WriteAll(ctx, thumbKey, BrokenImagePNG, &blob.WriterOptions{ContentType: "image/png"}); err != nil { return "", fmt.Errorf("attach: broken-image thumb: %w", err) diff --git a/modules/lagoon/connection.go b/modules/lagoon/connection.go index 41ce5ae5..69749c4f 100644 --- a/modules/lagoon/connection.go +++ b/modules/lagoon/connection.go @@ -16,6 +16,10 @@ import ( "gorm.io/gorm/logger" ) +// moduleLog is lagoon's logger: records carry component=lagoon and follow +// log.components.lagoon (diary). +var moduleLog = diary.For("lagoon") + // Open pings dsn through pgx stdlib and returns that exact *sql.DB plus a // GORM handle opened on it. // diff --git a/modules/lagoon/fill.go b/modules/lagoon/fill.go index c3e1dbbd..93ab1a17 100644 --- a/modules/lagoon/fill.go +++ b/modules/lagoon/fill.go @@ -5,7 +5,6 @@ import ( "encoding" "encoding/json" "fmt" - "log/slog" "reflect" "strconv" "strings" @@ -84,7 +83,7 @@ func logDroppedKeyOnce(production bool, typeName, key string) { if _, loaded := droppedKeys.LoadOrStore(k, struct{}{}); loaded { return } - slog.Warn("lagoon: dropped non-fillable key", "type", typeName, "key", key) + moduleLog.Warn("lagoon: dropped non-fillable key", "type", typeName, "key", key) } func fieldByColumn(rv reflect.Value, rt reflect.Type, column string) (reflect.Value, bool) { diff --git a/modules/lagoon/purge.go b/modules/lagoon/purge.go index c0670957..7e2329a7 100644 --- a/modules/lagoon/purge.go +++ b/modules/lagoon/purge.go @@ -125,7 +125,7 @@ func PurgeDeferred(ctx context.Context, db *gorm.DB, bucket *blob.Bucket, opts P if len(keys) > 0 { AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) { if err := attach.DeleteKeys(context.WithoutCancel(ctx), bucket, keys); err != nil { - slog.Default().WarnContext(ctx, "lagoon: purge could not delete file blobs", slog.String("error", err.Error())) + moduleLog.WarnContext(ctx, "lagoon: purge could not delete file blobs", slog.String("error", err.Error())) } }) } @@ -178,7 +178,7 @@ func purgeBinding(ctx context.Context, tx *gorm.DB, bucket *blob.Bucket, opts Pu } env, err := row.Envelope() if err != nil { - slog.Default().WarnContext(ctx, "lagoon: purge treats an unreadable deferred binding as a plain link", + moduleLog.WarnContext(ctx, "lagoon: purge treats an unreadable deferred binding as a plain link", slog.Uint64("binding_id", uint64(row.ID)), slog.String("error", err.Error())) return true, nil } @@ -193,7 +193,7 @@ func purgeBinding(ctx context.Context, tx *gorm.DB, bucket *blob.Bucket, opts Pu if !ok || model == nil { if !warned[row.SlaveType] { warned[row.SlaveType] = true - slog.Default().WarnContext(ctx, "lagoon: purge cannot resolve the model of a deferred child; its bindings are kept", + moduleLog.WarnContext(ctx, "lagoon: purge cannot resolve the model of a deferred child; its bindings are kept", slog.String("slave_type", row.SlaveType)) } res.Skipped++ diff --git a/modules/lagoon/transaction.go b/modules/lagoon/transaction.go index 0e892b19..4f9fb313 100644 --- a/modules/lagoon/transaction.go +++ b/modules/lagoon/transaction.go @@ -3,7 +3,6 @@ package lagoon import ( "context" "fmt" - "log/slog" "sync" "gorm.io/gorm" @@ -127,7 +126,7 @@ func AfterCommit(ctx context.Context, db *gorm.DB, fn func(ctx context.Context, return } if transactionalHandle(db) { - slog.Default().Warn("lagoon: after-commit callback skipped inside unmanaged transaction") + moduleLog.Warn("lagoon: after-commit callback skipped inside unmanaged transaction") return } } @@ -196,7 +195,7 @@ func runAfterCommit(ctx context.Context, db *gorm.DB, fns []func(context.Context func() { defer func() { if r := recover(); r != nil { - slog.Default().Warn("lagoon: after-commit callback panicked", "panic", fmt.Sprint(r)) + moduleLog.Warn("lagoon: after-commit callback panicked", "panic", fmt.Sprint(r)) } }() fn(ctx, db) diff --git a/modules/lighthouse/README.md b/modules/lighthouse/README.md index 7a6a28de..976ec4e6 100644 --- a/modules/lighthouse/README.md +++ b/modules/lighthouse/README.md @@ -24,7 +24,7 @@ The `centrifugo` sub-package is the Centrifugo driver. It has a hand-rolled `net ## Role in the framework -A service module high in the stack: it depends on `backpack`, `bouncer`, `compass`, `conga`, `lagoon`, `pact` and `wire`, and only the summer CLI imports it. The publisher interface is transport-neutral, with the Centrifugo driver in the centrifugo subpackage. It is separate from `festival` (in-process) and `flare` (Web Push) because it needs the database and jobs to send broadcasts only for committed writes. +A service module high in the stack: it depends on `backpack`, `bouncer`, `compass`, `conga`, `diary`, `lagoon`, `pact` and `wire`, and only the summer CLI imports it. The publisher interface is transport-neutral, with the Centrifugo driver in the centrifugo subpackage. It is separate from `festival` (in-process) and `flare` (Web Push) because it needs the database and jobs to send broadcasts only for committed writes. Use cases: @@ -241,7 +241,7 @@ for _, pub := range mem.Publications() { ## Dependencies -- `backpack`, `bouncer`, `compass`, `conga` (the broadcast job), `lagoon` (callback installation), `pact` and `wire` from this repository; the centrifugo driver also uses `surf` for the client IP and `bonfire` for its command. +- `backpack`, `bouncer`, `compass`, `conga` (the broadcast job), `diary` (component loggers `lighthouse` and `centrifugo`), `lagoon` (callback installation), `pact` and `wire` from this repository; the centrifugo driver also uses `surf` for the client IP and `bonfire` for its command. - `gorm.io/gorm` (broadcast callbacks). - `github.com/golang-jwt/jwt/v5` (centrifugo token signing). - The Centrifugo client is plain `net/http`; no Centrifugo SDK is used. diff --git a/modules/lighthouse/centrifugo/client.go b/modules/lighthouse/centrifugo/client.go index 2bbd8c9f..1319f340 100644 --- a/modules/lighthouse/centrifugo/client.go +++ b/modules/lighthouse/centrifugo/client.go @@ -12,9 +12,14 @@ import ( "strconv" "time" + "git.golem15.com/golem15/summercms/modules/diary" "git.golem15.com/golem15/summercms/modules/wire" ) +// moduleLog is centrifugo's logger: records carry component=centrifugo and follow +// log.components.centrifugo (diary). +var moduleLog = diary.For("centrifugo") + // requestTimeout bounds every Centrifugo API call, as the WinterCMS client // does. const requestTimeout = 5 * time.Second @@ -54,7 +59,7 @@ func NewClient(cfg Config, hc *http.Client) *Client { apiURL: cfg.APIURL, apiKey: cfg.APIKey, hc: hc, - log: slog.Default(), + log: moduleLog, now: time.Now, } } diff --git a/modules/lighthouse/lighthouse.go b/modules/lighthouse/lighthouse.go index 4bcf7c65..d276789e 100644 --- a/modules/lighthouse/lighthouse.go +++ b/modules/lighthouse/lighthouse.go @@ -18,10 +18,15 @@ import ( "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/diary" "git.golem15.com/golem15/summercms/modules/lagoon" "gorm.io/gorm" ) +// moduleLog is lighthouse's logger: records carry component=lighthouse and follow +// log.components.lighthouse (diary). +var moduleLog = diary.For("lighthouse") + const ( // DefaultDriver is the driver used when realtime.driver is empty. DefaultDriver = "null" @@ -141,10 +146,11 @@ func (s *Service) Registry() *Registry { return s.registry } -// Logger returns the app logger the service and its driver log through. +// Logger returns the app logger the service and its driver log through: the +// logger the application published, else the lighthouse component logger. func (s *Service) Logger() *slog.Logger { if s == nil || s.log == nil { - return slog.Default() + return moduleLog } return s.log } @@ -198,5 +204,5 @@ func loggerFromApp(app *backpack.App) *slog.Logger { return log } } - return slog.Default() + return moduleLog } diff --git a/modules/phrasebook/README.md b/modules/phrasebook/README.md index 904d5256..13ce1048 100644 --- a/modules/phrasebook/README.md +++ b/modules/phrasebook/README.md @@ -119,7 +119,7 @@ When the compass environment is `production`, missing-key warnings are not logge ## Dependencies -- SummerCMS modules: [backpack](../backpack/README.md), [pact](../pact/README.md), [towel](../towel/README.md). +- SummerCMS modules: [backpack](../backpack/README.md), [diary](../diary/README.md) (component logger `phrasebook` for missing translation keys), [pact](../pact/README.md), [towel](../towel/README.md). - Third-party: `github.com/goccy/go-yaml`, `github.com/nicksnyder/go-i18n/v2` (CLDR plural rules), `golang.org/x/text/language`. - Standard library: `context`, `embed`, `fmt`, `io/fs`, `log/slog`, `path`, `regexp`, `sort`, `strconv`, `strings`, `sync`, `unicode`, `unicode/utf8`. diff --git a/modules/phrasebook/translator.go b/modules/phrasebook/translator.go index 9d9c0079..e9a65908 100644 --- a/modules/phrasebook/translator.go +++ b/modules/phrasebook/translator.go @@ -3,7 +3,6 @@ package phrasebook import ( "context" "fmt" - "log/slog" "slices" "strconv" "strings" @@ -12,6 +11,7 @@ import ( "unicode/utf8" "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/diary" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/towel" "github.com/nicksnyder/go-i18n/v2/i18n" @@ -19,6 +19,10 @@ import ( "golang.org/x/text/language" ) +// moduleLog is phrasebook's logger: records carry component=phrasebook and follow +// log.components.phrasebook (diary). +var moduleLog = diary.For("phrasebook") + const ( defaultLocale = "en" defaultFallback = "en" @@ -213,7 +217,7 @@ func (t *Translator) logMissing(key string) { if _, loaded := t.missing.LoadOrStore(key, struct{}{}); loaded { return } - slog.Warn("phrasebook: missing translation key", "key", key) + moduleLog.Warn("phrasebook: missing translation key", "key", key) } func fallbackChain(requested, fallback string) []string { diff --git a/modules/postcard/README.md b/modules/postcard/README.md index 678e22bf..f2bb4b60 100644 --- a/modules/postcard/README.md +++ b/modules/postcard/README.md @@ -114,7 +114,7 @@ func TestWelcomeMail(t *testing.T) { | `postcard.Driver` | Delivers a `postcard.RenderedMessage`. | | `postcard.RenderedMessage` | The validated payload a driver transmits: headers, HTML part and text part. | | `postcard.NewMemoryDriver` | In-memory driver; `postcard.MemoryDriver.Messages` returns what was sent. | -| `postcard.NewLogDriver` | Driver that logs through a `*slog.Logger` (the default logger when nil). | +| `postcard.NewLogDriver` | Driver that logs through a `*slog.Logger` (the postcard component logger when nil). | | `postcard.NewSMTPDriver` | SMTP driver built from a `postcard.SMTPConfig`. | | `postcard.SMTPConfig` | Host, port, credentials, TLS policy and timeout for the SMTP driver. | | `postcard.FailDriver` | Driver whose `postcard.FailDriver.Send` always returns `postcard.FailDriver.Err`. | @@ -150,7 +150,7 @@ mail: ## Dependencies -- SummerCMS modules: [backpack](../backpack/README.md), [compass](../compass/README.md), [pact](../pact/README.md). +- SummerCMS modules: [backpack](../backpack/README.md), [compass](../compass/README.md), [diary](../diary/README.md) (component logger `postcard` when the app publishes no logger), [pact](../pact/README.md). - Third-party: `github.com/wneessen/go-mail` (SMTP), `github.com/yuin/goldmark` (Markdown). - Standard library: `bytes`, `context`, `embed`, `fmt`, `html/template`, `io/fs`, `log/slog`, `net/mail`, `regexp`, `strings`, `sync`, `text/template`, `time`. - Tests additionally use `github.com/testcontainers/testcontainers-go` (Mailpit container). diff --git a/modules/postcard/drivers.go b/modules/postcard/drivers.go index e9e90a10..d954af8b 100644 --- a/modules/postcard/drivers.go +++ b/modules/postcard/drivers.go @@ -80,10 +80,11 @@ type LogDriver struct { log *slog.Logger } -// NewLogDriver logs through log. slog.Default is used when log is nil. +// NewLogDriver logs through log. The postcard component logger (diary) is +// used when log is nil. func NewLogDriver(log *slog.Logger) *LogDriver { if log == nil { - log = slog.Default() + log = moduleLog } return &LogDriver{log: log} } diff --git a/modules/postcard/mailer.go b/modules/postcard/mailer.go index 5328f683..05d9d9ae 100644 --- a/modules/postcard/mailer.go +++ b/modules/postcard/mailer.go @@ -9,9 +9,14 @@ import ( "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/diary" "git.golem15.com/golem15/summercms/modules/pact" ) +// moduleLog is postcard's logger: records carry component=postcard and follow +// log.components.postcard (diary). +var moduleLog = diary.For("postcard") + // Message is the Send contract for plugin callers. type Message struct { Template string @@ -158,7 +163,7 @@ func loggerFromApp(app *backpack.App) *slog.Logger { return log } } - return slog.Default() + return moduleLog } // BootPlugin registers a plugin's declared mail templates and layouts. diff --git a/modules/sunscreen/README.md b/modules/sunscreen/README.md index db7942e1..9b65c973 100644 --- a/modules/sunscreen/README.md +++ b/modules/sunscreen/README.md @@ -8,11 +8,11 @@ Credential-redacting slog handler that keeps API keys, tokens and passwords out ## Overview -`sunscreen` wraps any `log/slog` handler and rewrites every record before it is written: values of sensitive attribute keys become `[REDACTED]`, and credential shapes inside messages and string values are scrubbed. It is the SummerCMS counterpart of a Monolog tap that a WinterCMS application wires into its log channels to scrub contexts and messages. Every application `main` that `summer build` generates calls `sunscreen.InstallDefault(os.Stderr)` as its first statement, so the default logger, the standard `log` package and every plugin that falls back to `slog.Default` log through it from the start. +`sunscreen` wraps any `log/slog` handler and rewrites every record before it is written: values of sensitive attribute keys become `[REDACTED]`, and credential shapes inside messages and string values are scrubbed. It is the SummerCMS counterpart of a Monolog tap that a WinterCMS application wires into its log channels to scrub contexts and messages. Every application `main` that `summer build` generates installs [diary](../diary/README.md), which wraps each of its log channels (console, stderr, daily files, null) with `sunscreen.Wrap`, so component loggers, the default logger and the standard `log` package all log through it from the start. `sunscreen.InstallDefault` stays as the compatibility entry point for programs that install their own default logger; there is no other install function. ## Role in the framework -A dependency-free leaf the application wires into its logger at startup. It is separate because it wraps the standard slog handler and applies to all log output, whichever module writes it. +A dependency-free leaf that diary wraps around every log channel. It is separate because it wraps the standard slog handler and applies to all log output, whichever module writes it. Use cases: @@ -30,12 +30,11 @@ Use cases: ## Usage -The generated `main` already installs it: +A program that does not use diary installs it as the default logger: ```go -func run(args []string, out io.Writer) error { +func main() { sunscreen.InstallDefault(os.Stderr) - cfg, err := compass.Load("config") // ... } ``` @@ -55,7 +54,7 @@ Redaction is a safety net, not a licence: log ids and outcomes, never request bo | Identifier | Description | |------------|-------------| | `sunscreen.Wrap` | Returns a `slog.Handler` that redacts every record and every `WithAttrs` attribute before the wrapped handler sees it. Wrapping a sunscreen handler again returns it unchanged. | -| `sunscreen.InstallDefault` | Makes a redacting text handler writing to the given writer the process default logger. | +| `sunscreen.InstallDefault` | Makes a redacting text handler writing to the given writer the process default logger; the compatibility entry point for programs that do not install diary. | | `sunscreen.Scrub` | Replaces the Bearer, `sk-` and `x-api-key:` credential shapes in a string. | | `sunscreen.Redacted` | The replacement text, `[REDACTED]`. | | `sunscreen.RedactedKeys` | Returns a copy of the attribute keys whose values are always redacted. | diff --git a/modules/sunscreen/sunscreen.go b/modules/sunscreen/sunscreen.go index cd4997c7..d17b085f 100644 --- a/modules/sunscreen/sunscreen.go +++ b/modules/sunscreen/sunscreen.go @@ -87,10 +87,15 @@ func Wrap(next slog.Handler) slog.Handler { } // InstallDefault makes a redacting text handler writing to w the process -// default logger (slog.SetDefault), so plugins that fall back to -// slog.Default and the standard log package both log through it. It builds -// a fresh slog.TextHandler instead of wrapping the existing default, whose -// output goes through the log package that SetDefault redirects back here. +// default logger (slog.SetDefault), so code that logs through slog.Default +// and the standard log package both log through it. It builds a fresh +// slog.TextHandler instead of wrapping the existing default, whose output +// goes through the log package that SetDefault redirects back here. +// +// Generated applications no longer call it: they install the diary module, +// which wraps every log channel with Wrap. InstallDefault stays as the +// compatibility entry point for programs that install their own default +// logger. func InstallDefault(w io.Writer) { slog.SetDefault(slog.New(Wrap(slog.NewTextHandler(w, nil)))) } diff --git a/modules/wristband/README.md b/modules/wristband/README.md index b1027e15..f46f6da4 100644 --- a/modules/wristband/README.md +++ b/modules/wristband/README.md @@ -14,7 +14,7 @@ Both flavours reuse the same primitives: PKCE, SHA-256 hashing of every secret, ## Role in the framework -A standalone protocol leaf: it imports no framework module and no ORM. The application (or a shared plugin) supplies storage, keys and pages through interfaces. It is separate because OAuth and OpenID Connect are security code that should be reviewed on its own, and only applications that serve MCP clients or relying parties need it. +A standalone protocol leaf: it imports no ORM and no framework module except `diary`, which its log lines go through. The application (or a shared plugin) supplies storage, keys and pages through interfaces. It is separate because OAuth and OpenID Connect are security code that should be reviewed on its own, and only applications that serve MCP clients or relying parties need it. Use cases: @@ -221,7 +221,7 @@ Always set `wristband.Options.Issuer` and `wristband.Options.Resource` for your ## Dependencies -- SummerCMS modules: none. +- SummerCMS modules: [diary](../diary/README.md) (component logger `wristband` for rejected token exchanges). - Third-party: `github.com/golang-jwt/jwt/v5` (id_token signing). - Standard library: `bytes`, `context`, `crypto`, `crypto/ecdsa`, `crypto/elliptic`, `crypto/rand`, `crypto/rsa`, `crypto/sha256`, `crypto/subtle`, `crypto/x509`, `encoding/base64`, `encoding/hex`, `encoding/json`, `encoding/pem`, `errors`, `fmt`, `log/slog`, `math/big`, `net/http`, `net/url`, `slices`, `strconv`, `strings`, `time`, `unicode/utf8`, `uuid`. diff --git a/modules/wristband/oidc_token.go b/modules/wristband/oidc_token.go index 90cabb71..962d02a4 100644 --- a/modules/wristband/oidc_token.go +++ b/modules/wristband/oidc_token.go @@ -9,11 +9,16 @@ package wristband import ( "context" "encoding/base64" - "log/slog" "net/http" "strings" + + "git.golem15.com/golem15/summercms/modules/diary" ) +// moduleLog is wristband's logger: records carry component=wristband and follow +// log.components.wristband (diary). +var moduleLog = diary.For("wristband") + const ( oidcBasicRealm = `Basic realm="oauth"` msgClientAuthFailed = "Client authentication failed." @@ -509,5 +514,5 @@ func (s *Server) logTokenFailure(r *http.Request, reason, clientID string) { if clientID == "" { clientID = "(absent)" } - slog.Warn("wristband: oidc token exchange rejected", "reason", reason, "client_id", clientID, "ip", clientIP(r)) + moduleLog.Warn("wristband: oidc token exchange rejected", "reason", reason, "client_id", clientID, "ip", clientIP(r)) }