feat: the admin Overview shows who is listening, downloads, the library and its disk, and the server's version, uptime and recent errors; a download waiting on you, a client not answering or a full disk count in its verdict (M504 #5627 #5628 #5629 #5630)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m35s
release / go (push) Successful in 2m0s
release / integration (push) Successful in 5m40s
release / android (push) Successful in 6m15s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m35s
release / go (push) Successful in 2m0s
release / integration (push) Successful in 5m40s
release / android (push) Successful in 6m15s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
The version moves to internal/buildinfo, so /rest/ping reports the build instead of "dev". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,8 @@ func New(w io.Writer, level, format string) (*slog.Logger, error) {
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown log format %q (want text|json)", format)
|
||||
}
|
||||
return slog.New(h), nil
|
||||
// Errors are also kept for the admin Overview (M504).
|
||||
return slog.New(newRingHandler(h, Errors)), nil
|
||||
}
|
||||
|
||||
func parseLevel(s string) (slog.Level, error) {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The server's recent errors, kept in memory for the admin Overview (M504):
|
||||
// what has gone wrong since the process started, without reading the
|
||||
// container's logs. Only ERROR and above, and only the newest few; the full
|
||||
// record is still the log stream.
|
||||
|
||||
// RecentErrorsKept is how many recent errors are kept.
|
||||
const RecentErrorsKept = 50
|
||||
|
||||
// LoggedError is one error the server logged.
|
||||
type LoggedError struct {
|
||||
At time.Time
|
||||
Message string
|
||||
// Attrs is the record's attributes as key=value, the way the text log
|
||||
// writes them.
|
||||
Attrs string
|
||||
}
|
||||
|
||||
// ErrorRing keeps the newest errors and counts all of them.
|
||||
type ErrorRing struct {
|
||||
mu sync.Mutex
|
||||
buf []LoggedError
|
||||
next int
|
||||
total int64
|
||||
}
|
||||
|
||||
// Errors is the process's ring, filled by every logger New returns.
|
||||
var Errors = NewErrorRing(RecentErrorsKept)
|
||||
|
||||
// NewErrorRing returns a ring that keeps the newest n errors.
|
||||
func NewErrorRing(n int) *ErrorRing {
|
||||
return &ErrorRing{buf: make([]LoggedError, 0, n)}
|
||||
}
|
||||
|
||||
func (r *ErrorRing) add(e LoggedError) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.total++
|
||||
if len(r.buf) < cap(r.buf) {
|
||||
r.buf = append(r.buf, e)
|
||||
return
|
||||
}
|
||||
r.buf[r.next] = e
|
||||
r.next = (r.next + 1) % len(r.buf)
|
||||
}
|
||||
|
||||
// Snapshot returns the kept errors, newest first, and how many have been
|
||||
// logged in all since the process started.
|
||||
func (r *ErrorRing) Snapshot() ([]LoggedError, int64) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]LoggedError, 0, len(r.buf))
|
||||
for i := len(r.buf) - 1; i >= 0; i-- {
|
||||
out = append(out, r.buf[(r.next+i)%len(r.buf)])
|
||||
}
|
||||
return out, r.total
|
||||
}
|
||||
|
||||
// ringHandler passes every record on, and copies the errors into a ring.
|
||||
type ringHandler struct {
|
||||
inner slog.Handler
|
||||
ring *ErrorRing
|
||||
// attrs are the logger's own attributes (component=… and the like),
|
||||
// already rendered, group-qualified.
|
||||
attrs []string
|
||||
groups []string
|
||||
}
|
||||
|
||||
func newRingHandler(inner slog.Handler, ring *ErrorRing) *ringHandler {
|
||||
return &ringHandler{inner: inner, ring: ring}
|
||||
}
|
||||
|
||||
func (h *ringHandler) Enabled(ctx context.Context, l slog.Level) bool {
|
||||
return h.inner.Enabled(ctx, l)
|
||||
}
|
||||
|
||||
func (h *ringHandler) Handle(ctx context.Context, rec slog.Record) error {
|
||||
if rec.Level >= slog.LevelError {
|
||||
parts := append([]string(nil), h.attrs...)
|
||||
rec.Attrs(func(a slog.Attr) bool {
|
||||
parts = append(parts, h.render(a))
|
||||
return true
|
||||
})
|
||||
h.ring.add(LoggedError{At: rec.Time, Message: rec.Message, Attrs: strings.Join(parts, " ")})
|
||||
}
|
||||
return h.inner.Handle(ctx, rec)
|
||||
}
|
||||
|
||||
func (h *ringHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
next := *h
|
||||
next.inner = h.inner.WithAttrs(attrs)
|
||||
next.attrs = append([]string(nil), h.attrs...)
|
||||
for _, a := range attrs {
|
||||
next.attrs = append(next.attrs, h.render(a))
|
||||
}
|
||||
return &next
|
||||
}
|
||||
|
||||
func (h *ringHandler) WithGroup(name string) slog.Handler {
|
||||
next := *h
|
||||
next.inner = h.inner.WithGroup(name)
|
||||
next.groups = append(append([]string(nil), h.groups...), name)
|
||||
return &next
|
||||
}
|
||||
|
||||
func (h *ringHandler) render(a slog.Attr) string {
|
||||
key := a.Key
|
||||
if len(h.groups) > 0 {
|
||||
key = strings.Join(h.groups, ".") + "." + key
|
||||
}
|
||||
return fmt.Sprintf("%s=%v", key, a.Value.Resolve())
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRingHandler_KeepsErrorsNewestFirst(t *testing.T) {
|
||||
ring := NewErrorRing(3)
|
||||
var out bytes.Buffer
|
||||
log := slog.New(newRingHandler(slog.NewTextHandler(&out, nil), ring)).With("component", "scan")
|
||||
|
||||
log.Info("fine")
|
||||
log.Warn("odd")
|
||||
for _, msg := range []string{"one", "two", "three", "four"} {
|
||||
log.WithGroup("job").Error(msg, "n", len(msg))
|
||||
}
|
||||
|
||||
got, total := ring.Snapshot()
|
||||
if total != 4 {
|
||||
t.Errorf("total = %d, want 4 (info and warn are not errors)", total)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("kept %d, want 3", len(got))
|
||||
}
|
||||
for i, want := range []string{"four", "three", "two"} {
|
||||
if got[i].Message != want {
|
||||
t.Errorf("kept[%d] = %q, want %q", i, got[i].Message, want)
|
||||
}
|
||||
}
|
||||
if got[0].Attrs != "component=scan job.n=4" {
|
||||
t.Errorf("attrs = %q", got[0].Attrs)
|
||||
}
|
||||
// Every record still reaches the log stream.
|
||||
if n := bytes.Count(out.Bytes(), []byte("\n")); n != 6 {
|
||||
t.Errorf("log lines = %d, want 6", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorRing_BeforeItFills(t *testing.T) {
|
||||
ring := NewErrorRing(5)
|
||||
ring.add(LoggedError{Message: "a"})
|
||||
ring.add(LoggedError{Message: "b"})
|
||||
got, total := ring.Snapshot()
|
||||
if total != 2 || len(got) != 2 || got[0].Message != "b" || got[1].Message != "a" {
|
||||
t.Errorf("got %+v total %d, want b, a", got, total)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user