Files
summercms/docs/services/logging.md
Jakub Zych c30145ed25 fix(14.2): WR-02 499 only for a 5xx caused by the client going away
Every 5xx became 499 once the request context was cancelled, so a panic or
a real database error on a request the browser had abandoned passed the
phase gate's 5xx scan. Now a 5xx is logged as 499 only when the client had
already gone when the status was written and the handler did not panic;
recoverJSON and recoverBare mark the access log entry. Every record of an
abandoned request carries client_gone=true. The gate self-test plants a
5xx with client_gone=true and must refuse it.
2026-10-12 11:19:12 +02:00

4.8 KiB

title, description, section, order
title description section order
Logging Log through log/slog with sunscreen, the redacting handler every generated main installs, and keep tokens, keys and request bodies out of log records. services 105

Logging

SummerCMS logs through the standard library's log/slog. Where a WinterCMS plugin calls Log::error and relies on a Monolog tap to scrub credentials, a SummerCMS plugin logs through a *slog.Logger, and sunscreen does the scrubbing.

The redacting default logger

Every application main that summer build generates calls sunscreen.InstallDefault(os.Stderr) as the first statement of run, before the configuration is loaded and before any plugin is activated. From then on slog.Default, the package-level slog functions and the standard log package all write through a text handler wrapped by sunscreen.Wrap.

The handler rewrites every record before it is written:

  • The value of an attribute whose key is one of sunscreen.RedactedKeys becomes [REDACTED], at any group depth and whatever the letter case: api_key, apikey, authorization, bearer, password, secret, token, webhook_secret, admin_password and the openai_api_key, anthropic_api_key and perplexity_api_key variants.
  • The message, string values, error values and the formatted text of other values are passed through sunscreen.Scrub, which replaces Bearer <token>, sk- style API keys and x-api-key: <value>.
  • slog.LogValuer values are resolved first, and maps such as http.Header are redacted key by key.
// Drop the time so the output is stable; a real application keeps it.
plain := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
	ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
		if len(groups) == 0 && a.Key == slog.TimeKey {
			return slog.Attr{}
		}
		return a
	},
})
logger := slog.New(sunscreen.Wrap(plain))

logger.Info("vendor call failed",
	"api_key", "live-key-123",
	slog.Group("request", slog.String("Authorization", "Bearer abc.def")),
	"err", errors.New("401 for key sk-abcdefghijklmnopqrstuvwx"),
)
// Output:
// level=INFO msg="vendor call failed" api_key=[REDACTED] request.Authorization=[REDACTED] err="401 for key sk-[REDACTED]"

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.

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.

Request log

The router can log every request. It is off by default; turn it on with the http.access_log key (environment SUMMER_HTTP__ACCESS_LOG=true):

# config/http.yaml
access_log: true

Each request then writes one Info record with the message http request once it has been served:

Attribute Value
method The request method.
pattern The matched route, method and path pattern, for example GET /posts/{id}. Empty when no route matched.
status The response status; a handler that writes without setting one counts as 200. A 5xx is logged as 499 (client closed request) only when the client had already gone away when the status was written and the handler did not panic: the error came from the cancellation, and no client received it. A 5xx written before the client went away keeps its status, and a recovered panic is always 500, so a search for 5xx records finds every real server error.
duration_ms Time spent serving the request, in whole milliseconds.
client_gone true when the client went away before the request was served (its context ended with context.Canceled); absent otherwise.

The record never carries the URL path, the query string, request or response headers or the client address, because tokens often travel in paths and query strings. That holds for a request that reached no route too: a token route requested with another method gets a 405 from the router, and its trailing-slash variant a 404, before any route handler runs, so the path would carry the token. It goes to the published *slog.Logger or slog.Default, so the redacting handler still applies. Grep for the empty pattern to find requests that reached no route; the client's own log (a browser, a proxy) shows which path it asked for.

Panics

The router recovers a panicking handler and answers with an opaque 500: the JSON groups send {"error":true,"message":"Internal server error"} and the raw groups an empty body. The panic value never reaches the client. See Routing.