feat(surf): serve startup summary, shutdown report and http request log

- serve prints a startup summary on a terminal (version, environment, URL,
  admin, database host and name, pending migrations, plugins, drivers) and
  logs it as a 'startup summary' record plus 'listening on <addr>'
- on SIGINT/SIGTERM serve drains, prints and logs a shutdown report and
  returns nil (exit 0), or the drain errors (exit 1)
- the request log is on by default under diary component http with
  request_id; the route pattern, never the path, is logged
- lagoon.PendingMigrations, conga.Worker.Stats, diary.HideOnTTY,
  bonfire.IsTerminal and bonfire.Details back the summary and report
This commit is contained in:
Jakub Zych
2026-10-21 12:59:07 +02:00
parent f0ee26f39f
commit c38dc1fe48
23 changed files with 1333 additions and 94 deletions

View File

@@ -8,11 +8,16 @@ import (
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/diary"
)
// accessLogKey is the config key that turns the request log on.
// accessLogKey is the config key that turns the request log off (false).
const accessLogKey = "http.access_log"
// httpLog is the request log's component logger (D-23): silence it with
// log.components.http: warn or http.access_log: false.
var httpLog = diary.For("http")
// accessLogMessage is the message of every request log record.
const accessLogMessage = "http request"
@@ -34,14 +39,19 @@ type accessEntryKey struct{}
// had gone away, when the error came from that cancellation.
const statusClientClosedRequest = 499
// accessLogEnabled reports whether http.access_log is true.
// accessLogEnabled reports whether the request log is on: it is unless
// http.access_log is set to false.
func accessLogEnabled(app *backpack.App) bool {
return app != nil && app.Config != nil && app.Config.Bool(accessLogKey)
if app == nil || app.Config == nil || !app.Config.Has(accessLogKey) {
return true
}
return app.Config.Bool(accessLogKey)
}
// accessLog logs one Info record "http request" per request after it is
// served, with method, pattern (the matched route, "" when none matched),
// status and duration_ms (whole milliseconds). The URL path is never
// status, duration_ms (whole milliseconds) and request_id (the X-Request-Id
// header trimmed and cut to 128 bytes, left out when empty). The URL path is never
// logged, not even for a request that matched no route: a ServeMux 405 for
// a token route reached with another method, or a 404 for its trailing-slash
// variant, would otherwise carry the token, because share and invitation
@@ -55,9 +65,10 @@ func accessLogEnabled(app *backpack.App) bool {
// failed because of the cancellation, and no client received the error. A
// 5xx written before the client went away keeps its status, and a recovered
// panic is always 500, so a search for 5xx records still finds every real
// server error. The logger is the app's published *slog.Logger, else
// slog.Default (which the generated main wraps in the redacting sunscreen
// handler).
// server error. The logger is the app's published *slog.Logger, else the
// diary component logger http, whose channels all redact through sunscreen.
// The route pattern, never the raw path, is what D-23 settled on: the log
// is on by default, so it must be safe to keep in journald and daily files.
func accessLog(app *backpack.App, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
start := time.Now()
@@ -77,6 +88,9 @@ func accessLog(app *backpack.App, next http.Handler) http.Handler {
slog.Int("status", status),
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
}
if id := requestID(req); id != "" {
attrs = append(attrs, slog.String("request_id", id))
}
if clientGone(req.Context()) {
attrs = append(attrs, slog.Bool("client_gone", true))
}
@@ -97,14 +111,14 @@ func markPanicked(req *http.Request) {
}
}
// accessLogger is the app's published logger, else slog.Default.
// accessLogger is the app's published logger, else httpLog.
func accessLogger(app *backpack.App) *slog.Logger {
if app != nil {
if log, ok := app.Lookup[*slog.Logger](); ok && log != nil {
return log
}
}
return slog.Default()
return httpLog
}
// recordPattern stores pattern in the request's access log entry, when the