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()
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,36 @@ func ParseLevel(value string) slog.Level {
|
||||
}
|
||||
}
|
||||
|
||||
// New returns a text logger suited to `docker compose logs`. Fields remain structured
|
||||
// key=value pairs, but each event is one compact line rather than a JSON object.
|
||||
// Format selects how a record is rendered. The wire shape of a log line is operational
|
||||
// configuration, not a code decision: a person tailing `docker compose logs` and a
|
||||
// collector shipping the same stream elsewhere want different things from it.
|
||||
type Format int
|
||||
|
||||
const (
|
||||
// FormatConsole is the default: one aligned, human-readable line per event.
|
||||
FormatConsole Format = iota
|
||||
// FormatLogfmt is slog's own `time=… level=… msg=…` text output.
|
||||
FormatLogfmt
|
||||
// FormatJSON is one object per line, for a log collector.
|
||||
FormatJSON
|
||||
)
|
||||
|
||||
// ParseFormat is forgiving for the same reason [ParseLevel] is — a typo in Docker
|
||||
// configuration must not stop the gateway.
|
||||
func ParseFormat(value string) Format {
|
||||
switch strings.ToUpper(strings.TrimSpace(value)) {
|
||||
case "JSON":
|
||||
return FormatJSON
|
||||
case "LOGFMT", "TEXT":
|
||||
return FormatLogfmt
|
||||
default:
|
||||
return FormatConsole
|
||||
}
|
||||
}
|
||||
|
||||
// New returns a logger suited to `docker compose logs`: one compact line per event.
|
||||
func New(w io.Writer, level slog.Leveler) *slog.Logger {
|
||||
logger, _ := NewBuffered(w, level, 0)
|
||||
logger, _ := NewBuffered(w, level, 0, FormatConsole)
|
||||
return logger
|
||||
}
|
||||
|
||||
@@ -75,8 +101,11 @@ func ParseCapacity(value string, fallback int) int {
|
||||
return capacity
|
||||
}
|
||||
|
||||
// NewBuffered writes normal text logs and mirrors accepted records into a ring buffer.
|
||||
func NewBuffered(w io.Writer, level slog.Leveler, capacity int) (*slog.Logger, *Buffer) {
|
||||
// NewBuffered writes lines in the requested format and mirrors accepted records into a
|
||||
// ring buffer, which is what the admin page's live log reads.
|
||||
func NewBuffered(
|
||||
w io.Writer, level slog.Leveler, capacity int, format Format,
|
||||
) (*slog.Logger, *Buffer) {
|
||||
options := &slog.HandlerOptions{
|
||||
Level: level,
|
||||
ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr {
|
||||
@@ -86,12 +115,20 @@ func NewBuffered(w io.Writer, level slog.Leveler, capacity int) (*slog.Logger, *
|
||||
return attr
|
||||
},
|
||||
}
|
||||
buffer := &Buffer{capacity: capacity}
|
||||
text := slog.NewTextHandler(w, options)
|
||||
if capacity <= 0 {
|
||||
return slog.New(text), buffer
|
||||
var written slog.Handler
|
||||
switch format {
|
||||
case FormatJSON:
|
||||
written = slog.NewJSONHandler(w, options)
|
||||
case FormatLogfmt:
|
||||
written = slog.NewTextHandler(w, options)
|
||||
default:
|
||||
written = newConsoleHandler(w, level)
|
||||
}
|
||||
return slog.New(&captureHandler{next: text, buffer: buffer, level: level}), buffer
|
||||
buffer := &Buffer{capacity: capacity}
|
||||
if capacity <= 0 {
|
||||
return slog.New(written), buffer
|
||||
}
|
||||
return slog.New(&captureHandler{next: written, buffer: buffer, level: level}), buffer
|
||||
}
|
||||
|
||||
type captureHandler struct {
|
||||
@@ -150,17 +187,7 @@ func addAttribute(target map[string]string, groups []string, attr slog.Attr) {
|
||||
}
|
||||
return
|
||||
}
|
||||
switch attr.Value.Kind() {
|
||||
case slog.KindDuration:
|
||||
target[key] = attr.Value.Duration().String()
|
||||
case slog.KindTime:
|
||||
target[key] = attr.Value.Time().UTC().Format(time.RFC3339Nano)
|
||||
default:
|
||||
target[key] = attr.Value.String()
|
||||
if attr.Value.Kind() == slog.KindInt64 {
|
||||
target[key] = strconv.FormatInt(attr.Value.Int64(), 10)
|
||||
}
|
||||
}
|
||||
target[key] = attributeValue(attr.Value)
|
||||
}
|
||||
|
||||
func (b *Buffer) append(event Event) {
|
||||
|
||||
@@ -7,24 +7,96 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewWritesReadableStructuredText(t *testing.T) {
|
||||
func TestConsoleLineLeadsWithTimeLevelAndMessage(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
New(&output, slog.LevelInfo).Info("server ready", "listen", ":8080")
|
||||
New(&output, slog.LevelInfo).Info("gateway ready", "listen", ":8080")
|
||||
|
||||
line := strings.TrimRight(output.String(), "\n")
|
||||
if strings.HasPrefix(line, "{") {
|
||||
t.Fatalf("expected console output, got JSON: %s", line)
|
||||
}
|
||||
// The timestamp is a column, not a field: nothing labelled `time=` may appear in
|
||||
// front of — or anywhere in — the readable part of the line.
|
||||
if strings.Contains(line, "time=") || strings.Contains(line, "msg=") {
|
||||
t.Fatalf("console line still carries slog's own keys: %s", line)
|
||||
}
|
||||
if !strings.Contains(line, "INFO") || !strings.Contains(line, "gateway ready") {
|
||||
t.Fatalf("line is missing its level or message: %s", line)
|
||||
}
|
||||
if !strings.Contains(line, "listen=:8080") {
|
||||
t.Fatalf("line is missing its fields: %s", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleLineOrdersIdentityFirstAndErrorLast(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
New(&output, slog.LevelInfo).Info("playback requested",
|
||||
"error", "nope", "title", "Dune", "user", "matt", "component", "playback",
|
||||
)
|
||||
|
||||
line := output.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "{") {
|
||||
t.Fatalf("expected text output, got JSON: %s", line)
|
||||
}
|
||||
for _, want := range []string{`level=INFO`, `msg="server ready"`, `listen=:8080`} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Errorf("output %q does not contain %q", line, want)
|
||||
order := []string{"component=playback", "user=matt", "title=Dune", "error=nope"}
|
||||
previous := -1
|
||||
for _, want := range order {
|
||||
at := strings.Index(line, want)
|
||||
if at < 0 {
|
||||
t.Fatalf("output %q does not contain %q", line, want)
|
||||
}
|
||||
if at < previous {
|
||||
t.Fatalf("field %q is out of order in %q", want, line)
|
||||
}
|
||||
previous = at
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleQuotesOnlyAmbiguousValues(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
New(&output, slog.LevelInfo).Info("signed in", "device", "Living Room", "user", "matt")
|
||||
|
||||
line := output.String()
|
||||
if !strings.Contains(line, `device="Living Room"`) {
|
||||
t.Errorf("a value containing a space was not quoted: %s", line)
|
||||
}
|
||||
if !strings.Contains(line, `user=matt`) {
|
||||
t.Errorf("an unambiguous value was quoted: %s", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFormat(t *testing.T) {
|
||||
tests := map[string]Format{
|
||||
"": FormatConsole,
|
||||
"console": FormatConsole,
|
||||
"nonsense": FormatConsole,
|
||||
"json": FormatJSON,
|
||||
" LOGFMT": FormatLogfmt,
|
||||
"text": FormatLogfmt,
|
||||
}
|
||||
for input, want := range tests {
|
||||
if got := ParseFormat(input); got != want {
|
||||
t.Errorf("ParseFormat(%q) = %v, want %v", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedFormatIsWhatGetsWritten(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger, _ := NewBuffered(&output, slog.LevelInfo, 0, FormatJSON)
|
||||
logger.Info("gateway ready")
|
||||
if !strings.HasPrefix(strings.TrimSpace(output.String()), "{") {
|
||||
t.Fatalf("JSON format did not produce JSON: %s", output.String())
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
logger, _ = NewBuffered(&output, slog.LevelInfo, 0, FormatLogfmt)
|
||||
logger.Info("gateway ready")
|
||||
if !strings.Contains(output.String(), `msg="gateway ready"`) {
|
||||
t.Fatalf("logfmt format did not produce logfmt: %s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBufferedLoggerRetainsStructuredEventsWithCursorPagination(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger, buffer := NewBuffered(&output, slog.LevelDebug, 3)
|
||||
logger, buffer := NewBuffered(&output, slog.LevelDebug, 3, FormatConsole)
|
||||
for i := 1; i <= 5; i++ {
|
||||
logger.Info("request complete", "number", i)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user