// 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 } } // 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, FormatConsole) 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 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 { if attr.Key == slog.TimeKey { return slog.String(slog.TimeKey, attr.Value.Time().UTC().Format(time.RFC3339)) } return attr }, } 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) } 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 { 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 } target[key] = attributeValue(attr.Value) } 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 }