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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user