218 lines
6.0 KiB
Go
218 lines
6.0 KiB
Go
// Package logging configures the server's human-readable, structured log output.
|
|
package logging
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// ParseLevel returns a supported slog level, defaulting to INFO for empty or invalid
|
|
// values. Keeping this forgiving prevents a typo in Docker configuration from stopping
|
|
// the gateway.
|
|
func ParseLevel(value string) slog.Level {
|
|
switch strings.ToUpper(strings.TrimSpace(value)) {
|
|
case "DEBUG":
|
|
return slog.LevelDebug
|
|
case "WARN", "WARNING":
|
|
return slog.LevelWarn
|
|
case "ERROR":
|
|
return slog.LevelError
|
|
default:
|
|
return slog.LevelInfo
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
func New(w io.Writer, level slog.Leveler) *slog.Logger {
|
|
logger, _ := NewBuffered(w, level, 0)
|
|
return logger
|
|
}
|
|
|
|
// Event is the browser-safe representation of one structured server log record.
|
|
type Event struct {
|
|
Sequence int64 `json:"sequence"`
|
|
OccurredAt time.Time `json:"occurredAt"`
|
|
Level string `json:"level"`
|
|
Message string `json:"message"`
|
|
Attributes map[string]string `json:"attributes,omitempty"`
|
|
}
|
|
|
|
// EventPage is cursor based so an admin browser can drain bursts without repeatedly
|
|
// downloading records it has already rendered.
|
|
type EventPage struct {
|
|
Events []Event `json:"events"`
|
|
Next int64 `json:"next"`
|
|
Oldest int64 `json:"oldest"`
|
|
Latest int64 `json:"latest"`
|
|
Dropped int64 `json:"dropped"`
|
|
HasMore bool `json:"hasMore"`
|
|
}
|
|
|
|
// Buffer is a bounded, concurrency-safe ring of recent structured log records.
|
|
// Bounding is important: a broken TV can generate traffic indefinitely. The configured
|
|
// ring retains enough context for an operator without growing or shifting on every write.
|
|
type Buffer struct {
|
|
mu sync.RWMutex
|
|
capacity int
|
|
events []Event
|
|
start int
|
|
next atomic.Int64
|
|
}
|
|
|
|
// ParseCapacity returns a non-negative log buffer capacity from configuration.
|
|
func ParseCapacity(value string, fallback int) int {
|
|
capacity, err := strconv.Atoi(strings.TrimSpace(value))
|
|
if err != nil || capacity < 0 {
|
|
return fallback
|
|
}
|
|
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) {
|
|
options := &slog.HandlerOptions{
|
|
Level: level,
|
|
ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr {
|
|
if attr.Key == slog.TimeKey {
|
|
return slog.String(slog.TimeKey, attr.Value.Time().UTC().Format(time.RFC3339))
|
|
}
|
|
return attr
|
|
},
|
|
}
|
|
buffer := &Buffer{capacity: capacity}
|
|
text := slog.NewTextHandler(w, options)
|
|
if capacity <= 0 {
|
|
return slog.New(text), buffer
|
|
}
|
|
return slog.New(&captureHandler{next: text, buffer: buffer, level: level}), buffer
|
|
}
|
|
|
|
type captureHandler struct {
|
|
next slog.Handler
|
|
buffer *Buffer
|
|
level slog.Leveler
|
|
attrs []slog.Attr
|
|
groups []string
|
|
}
|
|
|
|
func (h *captureHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
|
return level >= h.level.Level() && h.next.Enabled(ctx, level)
|
|
}
|
|
|
|
func (h *captureHandler) Handle(ctx context.Context, record slog.Record) error {
|
|
attributes := make(map[string]string, record.NumAttrs()+len(h.attrs))
|
|
for _, attr := range h.attrs {
|
|
addAttribute(attributes, h.groups, attr)
|
|
}
|
|
record.Attrs(func(attr slog.Attr) bool {
|
|
addAttribute(attributes, h.groups, attr)
|
|
return true
|
|
})
|
|
h.buffer.append(Event{
|
|
OccurredAt: record.Time.UTC(),
|
|
Level: record.Level.String(),
|
|
Message: record.Message,
|
|
Attributes: attributes,
|
|
})
|
|
return h.next.Handle(ctx, record)
|
|
}
|
|
|
|
func (h *captureHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
|
clone := *h
|
|
clone.next = h.next.WithAttrs(attrs)
|
|
clone.attrs = append(append([]slog.Attr{}, h.attrs...), attrs...)
|
|
return &clone
|
|
}
|
|
|
|
func (h *captureHandler) WithGroup(name string) slog.Handler {
|
|
clone := *h
|
|
clone.next = h.next.WithGroup(name)
|
|
clone.groups = append(append([]string{}, h.groups...), name)
|
|
return &clone
|
|
}
|
|
|
|
func addAttribute(target map[string]string, groups []string, attr slog.Attr) {
|
|
attr.Value = attr.Value.Resolve()
|
|
if attr.Equal(slog.Attr{}) {
|
|
return
|
|
}
|
|
key := strings.Join(append(append([]string{}, groups...), attr.Key), ".")
|
|
if attr.Value.Kind() == slog.KindGroup {
|
|
for _, child := range attr.Value.Group() {
|
|
addAttribute(target, append(groups, attr.Key), child)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *Buffer) append(event Event) {
|
|
event.Sequence = b.next.Add(1)
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if len(b.events) == b.capacity {
|
|
b.events[b.start] = event
|
|
b.start = (b.start + 1) % b.capacity
|
|
return
|
|
}
|
|
b.events = append(b.events, event)
|
|
}
|
|
|
|
// Events returns records strictly newer than after, up to limit. If the caller fell
|
|
// behind the ring, Dropped reports the gap and delivery resumes at the oldest record.
|
|
func (b *Buffer) Events(after int64, limit int) EventPage {
|
|
if limit < 1 {
|
|
limit = 250
|
|
}
|
|
if limit > 1000 {
|
|
limit = 1000
|
|
}
|
|
b.mu.RLock()
|
|
defer b.mu.RUnlock()
|
|
|
|
latest := b.next.Load()
|
|
page := EventPage{Next: after, Latest: latest, Events: []Event{}}
|
|
if len(b.events) == 0 {
|
|
return page
|
|
}
|
|
page.Oldest = b.events[b.start].Sequence
|
|
if after < page.Oldest-1 {
|
|
page.Dropped = page.Oldest - after - 1
|
|
after = page.Oldest - 1
|
|
}
|
|
start := len(b.events)
|
|
for i := range b.events {
|
|
event := b.events[(b.start+i)%len(b.events)]
|
|
if event.Sequence > after {
|
|
start = i
|
|
break
|
|
}
|
|
}
|
|
end := min(start+limit, len(b.events))
|
|
for i := start; i < end; i++ {
|
|
page.Events = append(page.Events, b.events[(b.start+i)%len(b.events)])
|
|
}
|
|
if len(page.Events) > 0 {
|
|
page.Next = page.Events[len(page.Events)-1].Sequence
|
|
}
|
|
page.HasMore = page.Next < latest
|
|
return page
|
|
}
|