- stderr and null drivers; per-component levels from log.components - SUMMER_LOG__ overrides parse as strings and overlay the defaults key by key - Validation names the key: driver, format, level, days, stack children and cycles, daily path outside the root - summer new writes config/log.yaml (stack of console and daily, river warn, http info) - docs/services/logging.md: channels, config/log.yaml, component loggers, daily files, redaction - Log keys in the configuration docs; ExampleFor backs the docs snippet
258 lines
6.2 KiB
Go
258 lines
6.2 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"
|
|
)
|
|
|
|
// 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.
|
|
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(_ context.Context, r slog.Record) error {
|
|
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)))
|
|
}
|
|
}
|
|
b.WriteByte('\n')
|
|
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
_, err := io.WriteString(h.out, b.String())
|
|
return err
|
|
}
|
|
|
|
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()
|
|
}
|