Files
memby/server/internal/logging/logging.go
T

440 lines
12 KiB
Go
Raw Normal View History

2026-07-29 15:26:40 +12:00
// Package logging configures the server's human-readable, structured log output.
package logging
import (
2026-08-12 09:57:56 +12:00
"bufio"
2026-07-29 15:26:40 +12:00
"context"
2026-08-12 09:57:56 +12:00
"encoding/json"
2026-07-29 15:26:40 +12:00
"io"
"log/slog"
2026-08-14 11:47:32 +12:00
"net/url"
2026-08-12 09:57:56 +12:00
"os"
"path/filepath"
2026-07-29 15:26:40 +12:00
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
2026-08-14 11:47:32 +12:00
// LevelTrace is deliberately below DEBUG. It is disabled unless MEMBY_LOG_LEVEL=TRACE,
// so tight diagnostic loops can be instrumented without making ordinary DEBUG unusable.
const LevelTrace slog.Level = slog.LevelDebug - 4
// LevelName presents custom levels consistently in every configured output format.
func LevelName(level slog.Level) string {
if level == LevelTrace {
return "TRACE"
}
return level.String()
}
2026-07-29 15:26:40 +12:00
// 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)) {
2026-08-14 11:47:32 +12:00
case "TRACE":
return LevelTrace
2026-07-29 15:26:40 +12:00
case "DEBUG":
return slog.LevelDebug
case "WARN", "WARNING":
return slog.LevelWarn
case "ERROR":
return slog.LevelError
default:
return slog.LevelInfo
}
}
2026-08-06 22:33:56 +12:00
// 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.
2026-07-29 15:26:40 +12:00
func New(w io.Writer, level slog.Leveler) *slog.Logger {
2026-08-06 22:33:56 +12:00
logger, _ := NewBuffered(w, level, 0, FormatConsole)
2026-07-29 15:26:40 +12:00
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.
2026-08-02 22:10:19 +12:00
// 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.
2026-07-29 15:26:40 +12:00
type Buffer struct {
mu sync.RWMutex
capacity int
events []Event
2026-08-02 22:10:19 +12:00
start int
2026-07-29 15:26:40 +12:00
next atomic.Int64
2026-08-12 09:57:56 +12:00
history *historyFile
}
// historyFile is an append-only JSONL archive of the same structured events the admin
// console reads. It is compacted to the ring's retained tail at startup and after each
// further ringful, so persistence cannot become an unbounded disk cost.
type historyFile struct {
mu sync.Mutex
path string
file *os.File
capacity int
lastCompacted int64
2026-07-29 15:26:40 +12:00
}
2026-08-02 22:10:19 +12:00
// 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
}
2026-08-06 22:33:56 +12:00
// 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) {
2026-07-29 15:26:40 +12:00
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))
}
2026-08-14 11:47:32 +12:00
if attr.Key == slog.LevelKey {
if level, ok := attr.Value.Any().(slog.Level); ok {
return slog.String(slog.LevelKey, LevelName(level))
}
}
2026-07-29 15:26:40 +12:00
return attr
},
}
2026-08-06 22:33:56 +12:00
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)
2026-07-29 15:26:40 +12:00
}
2026-08-06 22:33:56 +12:00
buffer := &Buffer{capacity: capacity}
if capacity <= 0 {
return slog.New(written), buffer
}
return slog.New(&captureHandler{next: written, buffer: buffer, level: level}), buffer
2026-07-29 15:26:40 +12:00
}
2026-08-12 09:57:56 +12:00
// NewPersistentBuffered restores retained events from path before accepting new ones and
// appends every subsequent accepted record. The caller should close the returned Buffer.
// An empty path keeps the in-memory behaviour used by unit tests and small embeddings.
func NewPersistentBuffered(
w io.Writer, level slog.Leveler, capacity int, format Format, path string,
) (*slog.Logger, *Buffer, error) {
logger, buffer := NewBuffered(w, level, capacity, format)
path = strings.TrimSpace(path)
if capacity <= 0 || path == "" {
return logger, buffer, nil
}
history, restored, err := openHistory(path, capacity)
if err != nil {
return nil, nil, err
}
buffer.history = history
buffer.events = restored
if len(restored) > 0 {
buffer.next.Store(restored[len(restored)-1].Sequence)
}
return logger, buffer, nil
}
func openHistory(path string, capacity int) (*historyFile, []Event, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return nil, nil, err
}
input, err := os.OpenFile(path, os.O_CREATE|os.O_RDONLY, 0o640)
if err != nil {
return nil, nil, err
}
restored := make([]Event, 0, capacity)
scanner := bufio.NewScanner(input)
// Error attributes are capped upstream, but allow headroom for structured records.
scanner.Buffer(make([]byte, 64<<10), 1<<20)
for scanner.Scan() {
var event Event
if json.Unmarshal(scanner.Bytes(), &event) != nil || event.Sequence <= 0 {
continue
}
restored = append(restored, event)
if len(restored) > capacity {
copy(restored, restored[len(restored)-capacity:])
restored = restored[:capacity]
}
}
closeErr := input.Close()
if err := scanner.Err(); err != nil {
return nil, nil, err
}
if closeErr != nil {
return nil, nil, closeErr
}
history := &historyFile{path: path, capacity: capacity}
if len(restored) > 0 {
history.lastCompacted = restored[len(restored)-1].Sequence
}
if err := history.rewrite(restored); err != nil {
return nil, nil, err
}
return history, restored, nil
}
2026-07-29 15:26:40 +12:00
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(),
2026-08-14 11:47:32 +12:00
Level: LevelName(record.Level),
2026-07-29 15:26:40 +12:00
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
}
2026-08-14 11:47:32 +12:00
target[key] = safeAttribute(key, attributeValue(attr.Value))
}
// safeAttribute is the final guard before a structured record reaches the browser-visible
// buffer and optional on-disk history. Call sites should never log credentials, but this
// makes one accidental token-bearing URL or header non-disclosive by construction.
func safeAttribute(key, value string) string {
lower := strings.ToLower(key)
if strings.Contains(lower, "password") || strings.Contains(lower, "token") ||
strings.Contains(lower, "secret") || strings.Contains(lower, "cookie") ||
strings.Contains(lower, "authorization") || strings.Contains(lower, "api_key") || strings.Contains(lower, "apikey") {
return "[redacted]"
}
if parsed, err := url.Parse(value); err == nil && parsed.RawQuery != "" {
query := parsed.Query()
changed := false
for key := range query {
lowerKey := strings.ToLower(key)
if strings.Contains(lowerKey, "token") || strings.Contains(lowerKey, "key") || strings.Contains(lowerKey, "auth") || lowerKey == "t" {
query.Set(key, "[redacted]")
changed = true
}
}
if changed {
parsed.RawQuery = query.Encode()
return parsed.String()
}
}
return value
2026-07-29 15:26:40 +12:00
}
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 {
2026-08-02 22:10:19 +12:00
b.events[b.start] = event
b.start = (b.start + 1) % b.capacity
2026-08-12 09:57:56 +12:00
} else {
b.events = append(b.events, event)
2026-07-29 15:26:40 +12:00
}
2026-08-12 09:57:56 +12:00
if b.history != nil {
ordered := b.orderedEventsLocked()
_ = b.history.append(event, ordered)
}
}
func (b *Buffer) orderedEventsLocked() []Event {
ordered := make([]Event, len(b.events))
for i := range b.events {
ordered[i] = b.events[(b.start+i)%len(b.events)]
}
return ordered
}
func (h *historyFile) append(event Event, retained []Event) error {
h.mu.Lock()
defer h.mu.Unlock()
if event.Sequence-h.lastCompacted >= int64(h.capacity) {
if err := h.rewriteLocked(retained); err != nil {
return err
}
h.lastCompacted = event.Sequence
return nil
}
return json.NewEncoder(h.file).Encode(event)
}
func (h *historyFile) rewrite(events []Event) error {
h.mu.Lock()
defer h.mu.Unlock()
return h.rewriteLocked(events)
}
func (h *historyFile) rewriteLocked(events []Event) error {
if h.file != nil {
if err := h.file.Close(); err != nil {
return err
}
}
file, err := os.OpenFile(h.path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
if err != nil {
return err
}
encoder := json.NewEncoder(file)
for _, event := range events {
if err := encoder.Encode(event); err != nil {
_ = file.Close()
return err
}
}
if err := file.Close(); err != nil {
return err
}
h.file, err = os.OpenFile(h.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640)
return err
}
// Close flushes the persistent archive. It is safe for an in-memory Buffer.
func (b *Buffer) Close() error {
if b == nil || b.history == nil {
return nil
}
b.history.mu.Lock()
defer b.history.mu.Unlock()
if b.history.file == nil {
return nil
}
return b.history.file.Close()
2026-07-29 15:26:40 +12:00
}
// 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
}
2026-08-02 22:10:19 +12:00
page.Oldest = b.events[b.start].Sequence
2026-07-29 15:26:40 +12:00
if after < page.Oldest-1 {
page.Dropped = page.Oldest - after - 1
after = page.Oldest - 1
}
start := len(b.events)
for i := range b.events {
2026-08-02 22:10:19 +12:00
event := b.events[(b.start+i)%len(b.events)]
if event.Sequence > after {
2026-07-29 15:26:40 +12:00
start = i
break
}
}
end := min(start+limit, len(b.events))
2026-08-02 22:10:19 +12:00
for i := start; i < end; i++ {
page.Events = append(page.Events, b.events[(b.start+i)%len(b.events)])
}
2026-07-29 15:26:40 +12:00
if len(page.Events) > 0 {
page.Next = page.Events[len(page.Events)-1].Sequence
}
page.HasMore = page.Next < latest
return page
}