- 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
301 lines
7.5 KiB
Go
301 lines
7.5 KiB
Go
package diary
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
"unicode"
|
|
|
|
"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.
|
|
const componentWidth = 10
|
|
|
|
// ConsoleOptions configures NewConsoleHandler.
|
|
type ConsoleOptions struct {
|
|
// Level is the minimum level; nil means info.
|
|
Level slog.Leveler
|
|
// Profile is the color profile applied to the output; NoTTY writes no
|
|
// escape codes at all.
|
|
Profile colorprofile.Profile
|
|
// AddSource appends the caller's file:line to each line.
|
|
AddSource bool
|
|
}
|
|
|
|
// NewConsoleHandler returns a slog.Handler that writes one styled line per
|
|
// record to w in bonfire's palette: time, level, the component attribute as
|
|
// a fixed-width prefix, the message, then key=value attributes with groups
|
|
// as dotted key prefixes. Control characters other than tab are removed from
|
|
// the message and attribute values, so logged data cannot inject terminal
|
|
// escape sequences. Each line is written with one Write call, serialized
|
|
// across the handler and its WithAttrs and WithGroup clones; while a bonfire
|
|
// live view runs, lines print above it through bonfire.PrintAbove.
|
|
func NewConsoleHandler(w io.Writer, opts ConsoleOptions) slog.Handler {
|
|
source := new(atomic.Bool)
|
|
source.Store(opts.AddSource)
|
|
return newConsoleHandler(w, opts, source)
|
|
}
|
|
|
|
func newConsoleHandler(w io.Writer, opts ConsoleOptions, source *atomic.Bool) *consoleHandler {
|
|
level := opts.Level
|
|
if level == nil {
|
|
level = slog.LevelInfo
|
|
}
|
|
return &consoleHandler{
|
|
out: &colorprofile.Writer{Forward: w, Profile: opts.Profile},
|
|
mu: new(sync.Mutex),
|
|
level: level,
|
|
source: source,
|
|
palette: bonfire.DefaultPalette(),
|
|
}
|
|
}
|
|
|
|
type consoleAttr struct {
|
|
key string
|
|
value string
|
|
}
|
|
|
|
type consoleHandler struct {
|
|
out io.Writer
|
|
mu *sync.Mutex
|
|
level slog.Leveler
|
|
source *atomic.Bool
|
|
palette bonfire.Palette
|
|
component string
|
|
attrs []consoleAttr
|
|
groups []string
|
|
}
|
|
|
|
func (h *consoleHandler) Enabled(_ context.Context, l slog.Level) bool {
|
|
return l >= h.level.Level()
|
|
}
|
|
|
|
func (h *consoleHandler) clone() *consoleHandler {
|
|
c := *h
|
|
c.attrs = h.attrs[:len(h.attrs):len(h.attrs)]
|
|
c.groups = h.groups[:len(h.groups):len(h.groups)]
|
|
return &c
|
|
}
|
|
|
|
func (h *consoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
|
if len(attrs) == 0 {
|
|
return h
|
|
}
|
|
c := h.clone()
|
|
prefix := groupPrefix(c.groups)
|
|
for _, a := range attrs {
|
|
if len(c.groups) == 0 && a.Key == ComponentKey && a.Value.Kind() == slog.KindString {
|
|
c.component = a.Value.String()
|
|
continue
|
|
}
|
|
c.attrs = appendAttr(c.attrs, prefix, a)
|
|
}
|
|
return c
|
|
}
|
|
|
|
func (h *consoleHandler) WithGroup(name string) slog.Handler {
|
|
if name == "" {
|
|
return h
|
|
}
|
|
c := h.clone()
|
|
c.groups = append(c.groups, name)
|
|
return c
|
|
}
|
|
|
|
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
|
|
r.Attrs(func(a slog.Attr) bool {
|
|
if len(h.groups) == 0 && a.Key == ComponentKey && a.Value.Kind() == slog.KindString {
|
|
component = a.Value.String()
|
|
return true
|
|
}
|
|
recAttrs = appendAttr(recAttrs, prefix, a)
|
|
return true
|
|
})
|
|
|
|
p := h.palette
|
|
var b strings.Builder
|
|
if !r.Time.IsZero() {
|
|
b.WriteString(p.Muted.Render(r.Time.Format(time.TimeOnly)))
|
|
b.WriteByte(' ')
|
|
}
|
|
label, style := levelLabel(r.Level, p)
|
|
b.WriteString(style.Render(label))
|
|
b.WriteString(strings.Repeat(" ", max(0, 5-len(label))+1))
|
|
name := sanitize(component)
|
|
if name != "" {
|
|
b.WriteString(p.Header.Render(name))
|
|
}
|
|
b.WriteString(strings.Repeat(" ", max(0, componentWidth-len(name))+1))
|
|
b.WriteString(sanitize(r.Message))
|
|
for _, list := range [][]consoleAttr{h.attrs, recAttrs} {
|
|
for _, a := range list {
|
|
b.WriteByte(' ')
|
|
b.WriteString(p.Muted.Render(a.key + "="))
|
|
b.WriteString(a.value)
|
|
}
|
|
}
|
|
if h.source != nil && h.source.Load() && r.PC != 0 {
|
|
frame, _ := runtime.CallersFrames([]uintptr{r.PC}).Next()
|
|
if frame.File != "" {
|
|
b.WriteByte(' ')
|
|
b.WriteString(p.Muted.Render(filepath.Base(frame.File) + ":" + strconv.Itoa(frame.Line)))
|
|
}
|
|
}
|
|
// While a bonfire live view (spinner, progress, process view) runs,
|
|
// the line prints above it instead of tearing it.
|
|
if bonfire.PrintAbove(b.String()) {
|
|
return nil
|
|
}
|
|
b.WriteByte('\n')
|
|
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
_, err := io.WriteString(h.out, b.String())
|
|
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:
|
|
return l.String(), p.Muted
|
|
case l < slog.LevelWarn:
|
|
return l.String(), p.Current
|
|
case l < slog.LevelError:
|
|
return l.String(), p.Warning
|
|
default:
|
|
return l.String(), p.ErrorBold
|
|
}
|
|
}
|
|
|
|
func groupPrefix(groups []string) string {
|
|
if len(groups) == 0 {
|
|
return ""
|
|
}
|
|
return strings.Join(groups, ".") + "."
|
|
}
|
|
|
|
// appendAttr flattens a into key=value pairs: empty attributes are skipped,
|
|
// groups add their key as a dotted prefix, and groups with an empty key are
|
|
// inlined (testing/slogtest semantics).
|
|
func appendAttr(out []consoleAttr, prefix string, a slog.Attr) []consoleAttr {
|
|
a.Value = a.Value.Resolve()
|
|
if a.Equal(slog.Attr{}) {
|
|
return out
|
|
}
|
|
if a.Value.Kind() == slog.KindGroup {
|
|
group := a.Value.Group()
|
|
if len(group) == 0 {
|
|
return out
|
|
}
|
|
inner := prefix
|
|
if a.Key != "" {
|
|
inner = prefix + a.Key + "."
|
|
}
|
|
for _, g := range group {
|
|
out = appendAttr(out, inner, g)
|
|
}
|
|
return out
|
|
}
|
|
return append(out, consoleAttr{key: sanitize(prefix + a.Key), value: formatValue(a.Value)})
|
|
}
|
|
|
|
func formatValue(v slog.Value) string {
|
|
var s string
|
|
switch v.Kind() {
|
|
case slog.KindString:
|
|
s = v.String()
|
|
case slog.KindTime:
|
|
s = v.Time().Format(time.RFC3339)
|
|
case slog.KindAny:
|
|
if err, ok := v.Any().(error); ok {
|
|
s = err.Error()
|
|
} else {
|
|
s = fmt.Sprintf("%+v", v.Any())
|
|
}
|
|
default:
|
|
s = v.String()
|
|
}
|
|
s = sanitize(s)
|
|
if s == "" || strings.ContainsAny(s, " \t\"=") {
|
|
return strconv.Quote(s)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// sanitize removes control characters other than tab; line breaks become
|
|
// spaces so a multi-line value stays on one line.
|
|
func sanitize(s string) string {
|
|
clean := true
|
|
for _, r := range s {
|
|
if r != '\t' && unicode.IsControl(r) {
|
|
clean = false
|
|
break
|
|
}
|
|
}
|
|
if clean {
|
|
return s
|
|
}
|
|
var b strings.Builder
|
|
for _, r := range s {
|
|
switch {
|
|
case r == '\n' || r == '\r':
|
|
b.WriteByte(' ')
|
|
case r != '\t' && unicode.IsControl(r):
|
|
default:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|