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

@@ -89,6 +89,7 @@ Tests that set diary up restore the previous process logger with `t.Cleanup(diar
| `diary.FromGlobals` | Returns the `diary.Options` (verbosity, quiet, color mode) of the console global options in a `bonfire.Globals`. |
| `diary.ApplyGlobals` | Applies parsed `bonfire.Globals` to the running setup: verbosity and quiet, and the console color mode when it changed. |
| `diary.Verbosity` | Returns the current verbosity. |
| `diary.HideOnTTY` | Marks a context so a console channel writing to a terminal skips the records logged with it; files, stderr channels and a console that is not a terminal still write them. Used for facts a command has just printed as a block, such as `serve`'s startup summary. |
| `diary.Reset` | Drops the setup, closes its files and restores the process default logger captured at the first setup. |
| `diary.Close` | Closes the open daily files; later records open them again. |
| `diary.Options` | Setup options: `Verbosity`, `Quiet`, `Color`, `Root`, `Stderr`, `Environ` and `Now`. |
@@ -147,7 +148,7 @@ Environment variables use the `SUMMER_LOG__` prefix with a double underscore bet
## Dependencies
- SummerCMS modules: `bonfire` (palette and color detection), `compass` (configuration), `sunscreen` (redaction).
- Third-party: `charm.land/lipgloss/v2`, `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`).
- Third-party: `charm.land/lipgloss/v2`, `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`), `golang.org/x/term` (whether a console channel writes to a terminal, for `diary.HideOnTTY`).
- Standard library: `context`, `errors`, `fmt`, `io`, `io/fs`, `log`, `log/slog`, `maps`, `os`, `path/filepath`, `runtime`, `slices`, `strconv`, `strings`, `sync`, `sync/atomic`, `time`, `unicode`.
## Testing

View File

@@ -110,6 +110,9 @@ func (b *channelBuilder) build(name string) (slog.Handler, error) {
leaf = slog.NewTextHandler(w, hopts)
}
}
if _, styled := leaf.(*consoleHandler); !styled && isTerminal(w) {
leaf = ttyFilter{leaf}
}
b.out.consoles = append(b.out.consoles, consoleLevel{lv: lv, base: base})
wrapped := sunscreen.Wrap(leaf)
if b.warnTo == nil {

View File

@@ -17,6 +17,7 @@ import (
"charm.land/lipgloss/v2"
"git.golem15.com/golem15/summercms/modules/bonfire"
"github.com/charmbracelet/colorprofile"
"golang.org/x/term"
)
// componentWidth is the column width of the component prefix.
@@ -113,7 +114,12 @@ func (h *consoleHandler) WithGroup(name string) slog.Handler {
return c
}
func (h *consoleHandler) Handle(_ context.Context, r slog.Record) error {
func (h *consoleHandler) Handle(ctx context.Context, r slog.Record) error {
// The styled console is read by a person; facts already shown as a
// block (HideOnTTY) are not repeated as log lines.
if hiddenOnTTY(ctx) {
return nil
}
component := h.component
prefix := groupPrefix(h.groups)
var recAttrs []consoleAttr
@@ -168,6 +174,37 @@ func (h *consoleHandler) Handle(_ context.Context, r slog.Record) error {
return err
}
// ttyFilter drops records marked with HideOnTTY; it wraps a plain (JSON
// or logfmt) console channel whose writer is a terminal.
type ttyFilter struct {
slog.Handler
}
func (f ttyFilter) Enabled(ctx context.Context, l slog.Level) bool {
return !hiddenOnTTY(ctx) && f.Handler.Enabled(ctx, l)
}
func (f ttyFilter) Handle(ctx context.Context, r slog.Record) error {
if hiddenOnTTY(ctx) {
return nil
}
return f.Handler.Handle(ctx, r)
}
func (f ttyFilter) WithAttrs(attrs []slog.Attr) slog.Handler {
return ttyFilter{f.Handler.WithAttrs(attrs)}
}
func (f ttyFilter) WithGroup(name string) slog.Handler {
return ttyFilter{f.Handler.WithGroup(name)}
}
// isTerminal reports whether w is a terminal.
func isTerminal(w io.Writer) bool {
f, ok := w.(interface{ Fd() uintptr })
return ok && term.IsTerminal(int(f.Fd()))
}
func levelLabel(l slog.Level, p bonfire.Palette) (string, lipgloss.Style) {
switch {
case l < slog.LevelInfo:

View File

@@ -8,6 +8,7 @@
package diary
import (
"context"
"errors"
"io"
"log"
@@ -225,6 +226,30 @@ func ApplyGlobals(g bonfire.Globals) {
SetVerbosity(g.Verbosity, g.Quiet)
}
type hideOnTTYKey struct{}
// HideOnTTY marks ctx so that a console channel writing to a terminal skips
// the records logged with it (Logger.InfoContext and the like); daily files,
// stderr channels and a console that is not a terminal still write them. A
// command that has just printed the same facts as a styled block (serve's
// startup summary) logs them with it, so a person does not read them twice
// while journald and the log files keep them.
func HideOnTTY(ctx context.Context) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, hideOnTTYKey{}, true)
}
// hiddenOnTTY reports whether ctx carries the HideOnTTY mark.
func hiddenOnTTY(ctx context.Context) bool {
if ctx == nil {
return false
}
v, _ := ctx.Value(hideOnTTYKey{}).(bool)
return v
}
// Verbosity returns the current verbosity (0 by default).
func Verbosity() int {
return int(verbosity.Load())