App v0.2.26 and gateway 0.1.20
Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
@@ -0,0 +1,171 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// levelWidth and messageWidth keep the fields of consecutive lines in the same columns,
|
||||
// which is what makes a scrolling log readable at a glance. Both are minimums: a longer
|
||||
// message pushes its fields right rather than being truncated.
|
||||
const (
|
||||
levelWidth = 5
|
||||
messageWidth = 30
|
||||
)
|
||||
|
||||
// consoleHandler writes one aligned line per event — timestamp, level, message, fields.
|
||||
//
|
||||
// The timestamp leads the line rather than arriving as a `time=` field. Everything that
|
||||
// reads these lines (docker compose logs, the admin page, a NAS console) already treats
|
||||
// the start of a line as when it happened, so a labelled key there is noise sitting in
|
||||
// front of the message.
|
||||
type consoleHandler struct {
|
||||
// mu is shared by every derived handler: WithAttrs clones this struct, and two
|
||||
// clones writing to the same file must still produce whole lines.
|
||||
mu *sync.Mutex
|
||||
w io.Writer
|
||||
level slog.Leveler
|
||||
attrs []field
|
||||
groups []string
|
||||
}
|
||||
|
||||
type field struct {
|
||||
key string
|
||||
value string
|
||||
}
|
||||
|
||||
func newConsoleHandler(w io.Writer, level slog.Leveler) *consoleHandler {
|
||||
return &consoleHandler{mu: &sync.Mutex{}, w: w, level: level}
|
||||
}
|
||||
|
||||
func (h *consoleHandler) Enabled(_ context.Context, level slog.Level) bool {
|
||||
return level >= h.level.Level()
|
||||
}
|
||||
|
||||
func (h *consoleHandler) Handle(_ context.Context, record slog.Record) error {
|
||||
fields := slices.Clone(h.attrs)
|
||||
record.Attrs(func(attr slog.Attr) bool {
|
||||
fields = appendField(fields, h.groups, attr)
|
||||
return true
|
||||
})
|
||||
orderFields(fields)
|
||||
|
||||
var line strings.Builder
|
||||
line.WriteString(record.Time.UTC().Format(time.DateTime))
|
||||
line.WriteByte(' ')
|
||||
line.WriteString(pad(record.Level.String(), levelWidth))
|
||||
line.WriteByte(' ')
|
||||
line.WriteString(pad(record.Message, messageWidth))
|
||||
for _, f := range fields {
|
||||
line.WriteByte(' ')
|
||||
line.WriteString(f.key)
|
||||
line.WriteByte('=')
|
||||
line.WriteString(quote(f.value))
|
||||
}
|
||||
line.WriteByte('\n')
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
_, err := io.WriteString(h.w, line.String())
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *consoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
clone := *h
|
||||
clone.attrs = slices.Clone(h.attrs)
|
||||
for _, attr := range attrs {
|
||||
clone.attrs = appendField(clone.attrs, h.groups, attr)
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
func (h *consoleHandler) WithGroup(name string) slog.Handler {
|
||||
clone := *h
|
||||
clone.groups = append(slices.Clone(h.groups), name)
|
||||
return &clone
|
||||
}
|
||||
|
||||
func appendField(target []field, groups []string, attr slog.Attr) []field {
|
||||
attr.Value = attr.Value.Resolve()
|
||||
if attr.Equal(slog.Attr{}) {
|
||||
return target
|
||||
}
|
||||
if attr.Value.Kind() == slog.KindGroup {
|
||||
for _, child := range attr.Value.Group() {
|
||||
target = appendField(target, append(groups, attr.Key), child)
|
||||
}
|
||||
return target
|
||||
}
|
||||
key := strings.Join(append(append([]string{}, groups...), attr.Key), ".")
|
||||
return append(target, field{key: key, value: attributeValue(attr.Value)})
|
||||
}
|
||||
|
||||
// fieldRank puts the fields that identify *who and where* first, in the same order on
|
||||
// every line, so scanning a column answers "which TV, which user" without reading.
|
||||
// Anything unranked keeps the order the caller wrote it in, which is usually the order
|
||||
// that reads best for that particular event.
|
||||
var fieldRank = map[string]int{
|
||||
"component": 1,
|
||||
"user": 2,
|
||||
"device": 3,
|
||||
"client": 4,
|
||||
"protocol": 5,
|
||||
"method": 6,
|
||||
"path": 7,
|
||||
"status": 8,
|
||||
"duration": 9,
|
||||
// Constant per process, so it belongs at the end of the line rather than in front
|
||||
// of the fields that differ between events.
|
||||
"version": 900,
|
||||
// An error is the reason for the line; keeping it last means it is never buried
|
||||
// between routine fields.
|
||||
"error": 1000,
|
||||
}
|
||||
|
||||
func orderFields(fields []field) {
|
||||
rank := func(f field) int {
|
||||
if r, ok := fieldRank[f.key]; ok {
|
||||
return r
|
||||
}
|
||||
return 100
|
||||
}
|
||||
slices.SortStableFunc(fields, func(a, b field) int { return rank(a) - rank(b) })
|
||||
}
|
||||
|
||||
func pad(value string, width int) string {
|
||||
if len(value) >= width {
|
||||
return value
|
||||
}
|
||||
return value + strings.Repeat(" ", width-len(value))
|
||||
}
|
||||
|
||||
// quote only intervenes when a bare value would be ambiguous. Quoting everything would
|
||||
// double the punctuation on a line whose whole purpose is to be read.
|
||||
func quote(value string) string {
|
||||
if value == "" {
|
||||
return `""`
|
||||
}
|
||||
if strings.ContainsAny(value, " \t\"=\n") {
|
||||
return strconv.Quote(value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func attributeValue(value slog.Value) string {
|
||||
switch value.Kind() {
|
||||
case slog.KindDuration:
|
||||
return value.Duration().String()
|
||||
case slog.KindTime:
|
||||
return value.Time().UTC().Format(time.RFC3339)
|
||||
case slog.KindInt64:
|
||||
return strconv.FormatInt(value.Int64(), 10)
|
||||
default:
|
||||
return value.String()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user