refactor: framework modules log through diary components

- Each module logs through diary.For(<package>): 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
This commit is contained in:
Jakub Zych
2026-10-21 11:58:22 +02:00
parent 71b99317ad
commit 44291adcca
53 changed files with 185 additions and 130 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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`.

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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{}},

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)}
}

View File

@@ -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 {

7
modules/cabana/log.go Normal file
View File

@@ -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")

View File

@@ -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)

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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))

View File

@@ -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...)
}

View File

@@ -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`.

View File

@@ -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...)
}

View File

@@ -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 }

View File

@@ -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 {
// <prefix>rl:<key> 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

View File

@@ -24,7 +24,7 @@ type redisCache struct {
// NewRedisCache returns a Cache on client with keys <prefix>cache:<key>.
// 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 }

View File

@@ -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 {

View File

@@ -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:

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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`).

View File

@@ -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))

View File

@@ -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)

View File

@@ -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.
//

View File

@@ -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) {

View File

@@ -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++

View File

@@ -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)

View File

@@ -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.

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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`.

View File

@@ -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 {

View File

@@ -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).

View File

@@ -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}
}

View File

@@ -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.

View File

@@ -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. |

View File

@@ -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))))
}

View File

@@ -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`.

View File

@@ -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))
}