slogbox

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 25, 2026 License: GPL-3.0 Imports: 9 Imported by: 0

README

slogbox

CI Go Reference Go Report Card

img.png

A slog.Handler that keeps the last N log records in a fixed-size circular buffer. Zero external dependencies -- stdlib only.

Primary use case: exposing recent logs via health-check or admin HTTP endpoints. Inspired by runtime/trace.FlightRecorder, it can also act as a black box recorder that flushes context-rich logs on error.

Install

go get github.com/alexrios/slogbox

Quick start

package main

import (
	"log/slog"
	"net/http"

	"github.com/alexrios/slogbox"
)

func main() {
	rec := slogbox.New(500, nil)
	logger := slog.New(rec)
	slog.SetDefault(logger)

	http.Handle("GET /debug/logs", slogbox.HTTPHandler(rec, nil))

	slog.Info("server starting", "port", 8080)
	http.ListenAndServe(":8080", nil)
}

API overview

Function / Method Description
New(size, opts) Create a handler with buffer capacity size
Handle(ctx, record) Store a record (implements slog.Handler); triggers flush if FlushOn threshold is met
WithAttrs(attrs) Return a handler with additional attributes (shared buffer)
WithGroup(name) Return a handler with a group prefix (shared buffer)
Records() Snapshot of stored records, oldest to newest (respects MaxAge)
RecordsAbove(minLevel) Snapshot filtered to records >= minLevel (respects MaxAge)
All() iter.Seq[slog.Record] iterator over stored records (respects MaxAge)
JSON() Marshal records as a JSON array (respects MaxAge)
WriteTo(w) Stream records as JSON to an io.Writer (implements io.WriterTo)
HTTPHandler(h, onErr) Ready-made http.Handler serving JSON logs; pass nil for default 500 on error
Flush(ctx) Explicitly drain pending records to FlushTo (for graceful shutdown)
Len() Number of records physically stored (ignores MaxAge)
Capacity() Total buffer capacity
TotalRecords() Monotonic count of records ever written (survives wrap-around; reset by Clear)
PendingFlushCount() Number of records pending for next flush (0 if flush not configured)
Clear() Remove all records and reset flush state
Options
Field Type Description
Level slog.Leveler Minimum level stored (default: INFO)
FlushOn slog.Leveler Level that triggers flush to FlushTo
FlushTo slog.Handler Destination for flushed records
MaxAge time.Duration Exclude records older than this from reads; 0 = no filter

Black box pattern

Keep a ring buffer of recent logs and flush them to stderr when an error occurs:

rec := slogbox.New(500, &slogbox.Options{
	FlushOn: slog.LevelError,
	FlushTo: slog.NewJSONHandler(os.Stderr, nil),
	MaxAge:  5 * time.Minute,
})
logger := slog.New(rec)

logger.Info("request started", "path", "/api/users")
logger.Info("db query", "rows", 42)
// ... when an error happens, all recent logs are flushed to stderr
logger.Error("query failed", "err", err)

Serve the ring buffer over HTTP:

http.Handle("GET /debug/logs", slogbox.HTTPHandler(rec, nil))

Or with a custom error handler:

http.Handle("GET /debug/logs", slogbox.HTTPHandler(rec, func(w http.ResponseWriter, r *http.Request, err error) {
	slog.Error("debug/logs: write error", "err", err)
}))
Graceful shutdown

On process exit, records accumulated since the last level-triggered flush are silently lost. Use Flush to drain them:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := rec.Flush(ctx); err != nil {
	log.Printf("flush error: %v", err)
}
Observability

Monitor buffer throughput and pending flush count:

fmt.Printf("total records written: %d\n", rec.TotalRecords())
fmt.Printf("pending flush: %d\n", rec.PendingFlushCount())

Streaming JSON with GOEXPERIMENT=jsonv2

When built with GOEXPERIMENT=jsonv2, WriteTo uses encoding/json/v2's streaming jsontext.Encoder to write records one at a time, avoiding a single large intermediate []byte allocation. The API is identical -- the optimization is transparent.

GOEXPERIMENT=jsonv2 go build ./...
GOEXPERIMENT=jsonv2 go test -bench=BenchmarkWriteTo -benchmem ./...

Without the experiment flag, WriteTo falls back to encoding/json.Marshal (the default behavior).

Benchmarks

Representative values on an Intel Core i9-14900K (32 threads):

Benchmark ns/op B/op allocs/op
Handle 150 48 1
Handle_Parallel 440 48 1
Handle_WithFlush 144 48 1
Handle_FlushTrigger (100 records) 32,900 37,568 101
Flush (100 records) 33,800 37,568 101
Records (1000) 138,000 294,912 1
All (1000) 161,900 294,984 4
JSON (100 records, 5 attrs) 306,500 143,400 1,904
WriteTo (100 records) 224,600 143,300 1,904
WriteTo_LargeBuffer (10K records) 25,700,000 16,300,000 190,026
WithAttrs (5 attrs) 459 496 3
WithGroup 38 16 1
Records_WithMaxAge 198,600 294,912 1
WriteTo with GOEXPERIMENT=jsonv2

When built with the jsonv2 experiment, WriteTo streams records through jsontext.Encoder instead of marshalling the entire array at once:

Benchmark default jsonv2 improvement
WriteTo (100 records, B/op) 143,300 34,296 4x less memory
WriteTo (100 records, allocs/op) 1,904 35 54x fewer allocs
WriteTo (10K records, ns/op) 25,700,000 960,000 27x faster
WriteTo (10K records, B/op) 16,300,000 2,885,115 6x less memory
WriteTo (10K records, allocs/op) 190,026 35 5400x fewer allocs

License

GPL-3.0

Documentation

Overview

Package slogbox provides a slog.Handler that keeps the last N log records in a circular buffer. It is designed for exposing recent logs via health check or admin HTTP endpoints.

When FlushOn/FlushTo are configured, records are automatically forwarded on level-triggered thresholds. Handler.Flush provides explicit draining for graceful shutdown. Handler.TotalRecords and Handler.PendingFlushCount expose buffer throughput for monitoring.

Flushed records are replayed with the caller-provided context (for explicit Handler.Flush) or context.Background (for level-triggered flushes). The original request context is intentionally not stored per record to avoid GC pressure from retained context chains and stale deadlines.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func HTTPHandler

func HTTPHandler(h *Handler, onErr func(http.ResponseWriter, *http.Request, error)) http.Handler

HTTPHandler returns an http.Handler that serves the buffered records as a JSON array. It sets Content-Type to application/json and calls Handler.WriteTo to stream the response. An empty buffer produces a 200 response with "[]".

onErr is called when WriteTo returns an error. If onErr is nil and no response bytes have been written yet, the handler replies with 500 Internal Server Error. When bytes have already been sent (headers committed), the status code cannot be changed.

Example
package main

import (
	"fmt"
	"log/slog"
	"net/http"

	"github.com/alexrios/slogbox"
)

func main() {
	h := slogbox.New(100, nil)
	logger := slog.New(h)

	logger.Info("request handled", "status", 200)

	http.Handle("/debug/logs", slogbox.HTTPHandler(h, nil))
	fmt.Println("handler registered")
}
Output:

handler registered

Types

type Handler

type Handler struct {
	// contains filtered or unexported fields
}

Handler is a slog.Handler that stores log records in a fixed-size ring buffer. Handlers returned by Handler.WithAttrs and Handler.WithGroup share the same underlying buffer.

func New

func New(size int, opts *Options) *Handler

New creates a Handler with the given buffer capacity. It panics if size < 1.

Example
package main

import (
	"fmt"
	"log/slog"

	"github.com/alexrios/slogbox"
)

func main() {
	h := slogbox.New(100, nil)
	logger := slog.New(h)

	logger.Info("server started", "port", 8080)
	logger.Warn("high latency", "ms", 250)

	fmt.Println(h.Len())
}
Output:

2

func (*Handler) All

func (h *Handler) All() iter.Seq[slog.Record]

All returns an iterator over stored records from oldest to newest. The snapshot is taken under a read lock; the iteration itself holds no lock. If MaxAge is set, records older than MaxAge are excluded.

Example
package main

import (
	"fmt"
	"log/slog"

	"github.com/alexrios/slogbox"
)

func main() {
	h := slogbox.New(10, nil)
	logger := slog.New(h)

	logger.Info("alpha")
	logger.Info("beta")

	for r := range h.All() {
		fmt.Println(r.Message)
	}
}
Output:

alpha
beta

func (*Handler) Capacity

func (h *Handler) Capacity() int

Capacity returns the total buffer capacity (the size passed to New).

func (*Handler) Clear

func (h *Handler) Clear()

Clear removes all records from the buffer and resets flush state. It does not wait for any in-flight flush: a concurrent Handle that has already claimed its flush window will still deliver those records to FlushTo after Clear returns. New records written after Clear form a fresh window.

func (*Handler) Enabled

func (h *Handler) Enabled(_ context.Context, level slog.Level) bool

Enabled reports whether the handler stores records at the given level.

func (*Handler) Flush

func (h *Handler) Flush(ctx context.Context) error

Flush explicitly drains pending records to [Options.FlushTo]. It is intended for graceful shutdown sequences where buffered records would otherwise be lost.

Flush only requires [Options.FlushTo] to be set; [Options.FlushOn] is not needed. This allows a manual-only flush pattern where records are never flushed automatically but can be drained on demand.

Flush is a no-op (returns nil) when FlushTo is not configured, when the buffer is empty, or when all records have already been flushed.

The flush window is claimed under the write lock before delivery begins (same at-most-once semantics as Handler.Handle). The provided context is passed to each FlushTo.Handle call; ctx.Err() is checked between records to support early cancellation.

On error (from FlushTo or context cancellation), Flush returns immediately. Records delivered before the error are not retried; records not yet delivered are lost because the flush window was already claimed.

Example
package main

import (
	"context"
	"fmt"
	"io"
	"log/slog"
	"time"

	"github.com/alexrios/slogbox"
)

func main() {
	target := slog.NewJSONHandler(io.Discard, nil)
	h := slogbox.New(100, &slogbox.Options{
		FlushTo: target,
	})
	logger := slog.New(h)

	logger.Info("buffered event")

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := h.Flush(ctx); err != nil {
		fmt.Println("flush error:", err)
	}
	fmt.Println("pending after flush:", h.PendingFlushCount())
}
Output:

pending after flush: 0

func (*Handler) Handle

func (h *Handler) Handle(_ context.Context, r slog.Record) error

Handle stores a clone of r in the ring buffer. If FlushOn/FlushTo are configured and the record's level reaches the FlushOn threshold, all records since the last flush are forwarded to FlushTo. If FlushTo.Handle returns an error, Handle returns that error to the caller. The flush window is claimed before flushing begins (at-most-once semantics), so claimed records are never re-sent even when an error is returned.

func (*Handler) JSON

func (h *Handler) JSON() ([]byte, error)

JSON returns the buffered records as a JSON array suitable for HTTP responses. If MaxAge is set, records older than MaxAge are excluded.

Example
package main

import (
	"fmt"
	"log/slog"

	"github.com/alexrios/slogbox"
)

func main() {
	h := slogbox.New(10, &slogbox.Options{Level: slog.LevelError})
	logger := slog.New(h)

	logger.Info("ignored") // below Error level
	logger.Error("failure", "code", 500)

	data, err := h.JSON()
	if err != nil {
		panic(err)
	}
	fmt.Println(h.Len())
	fmt.Println(len(data) > 0)
}
Output:

1
true

func (*Handler) Len

func (h *Handler) Len() int

Len returns the number of records physically stored in the buffer. It does not apply MaxAge filtering.

func (*Handler) PendingFlushCount

func (h *Handler) PendingFlushCount() int

PendingFlushCount returns the number of records that would be flushed by Handler.Flush or the next level-triggered flush. Returns 0 if [Options.FlushTo] is not set.

func (*Handler) Records

func (h *Handler) Records() []slog.Record

Records returns a snapshot of stored records from oldest to newest. If MaxAge is set, records older than MaxAge are excluded.

Example
package main

import (
	"fmt"
	"log/slog"

	"github.com/alexrios/slogbox"
)

func main() {
	h := slogbox.New(10, nil)
	logger := slog.New(h)

	logger.Info("first")
	logger.Info("second")

	for _, r := range h.Records() {
		fmt.Println(r.Message)
	}
}
Output:

first
second

func (*Handler) RecordsAbove

func (h *Handler) RecordsAbove(minLevel slog.Level) []slog.Record

RecordsAbove returns a snapshot of stored records whose level is >= minLevel, from oldest to newest. If MaxAge is set, old records are excluded before level filtering. The semantics match Handler.Enabled: a record is included when its level reaches or exceeds minLevel.

func (*Handler) TotalRecords

func (h *Handler) TotalRecords() uint64

TotalRecords returns the total number of records ever written to the buffer. The counter is monotonically increasing and survives wrap-around; it is only reset by Handler.Clear.

func (*Handler) WithAttrs

func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler

WithAttrs returns a new Handler whose records will include the given attrs. The new handler shares the same ring buffer.

func (*Handler) WithGroup

func (h *Handler) WithGroup(name string) slog.Handler

WithGroup returns a new Handler that qualifies later attrs with the given group name. The new handler shares the same ring buffer.

func (*Handler) WriteTo

func (h *Handler) WriteTo(w io.Writer) (int64, error)

WriteTo writes the buffered records as a JSON array to w. It implements io.WriterTo so it can be passed directly to helpers that accept that interface, and can write directly to an http.ResponseWriter. If MaxAge is set, records older than MaxAge are excluded.

Example
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log/slog"

	"github.com/alexrios/slogbox"
)

func main() {
	h := slogbox.New(10, nil)
	logger := slog.New(h)

	logger.Info("first")
	logger.Info("second")

	var buf bytes.Buffer
	if _, err := h.WriteTo(&buf); err != nil {
		panic(err)
	}

	var entries []map[string]any
	if err := json.Unmarshal(buf.Bytes(), &entries); err != nil {
		panic(err)
	}
	for _, e := range entries {
		fmt.Println(e["msg"])
	}
}
Output:

first
second

type Options

type Options struct {
	// Level reports the minimum record level that will be stored.
	// The handler discards records with lower levels.
	// If Level is nil, the handler assumes LevelInfo.
	Level slog.Leveler

	// FlushOn sets the level threshold that triggers an automatic flush of
	// buffered records to FlushTo. Both FlushOn and FlushTo must be set for
	// level-triggered flush to be active.
	// Flush errors are returned by Handle. Records are claimed before flushing
	// begins: at-most-once delivery — a record is never re-sent even if
	// FlushTo.Handle returns an error.
	FlushOn slog.Leveler

	// FlushTo is the destination handler for flushed records.
	// Required for both level-triggered flush (with FlushOn) and explicit
	// [Handler.Flush] calls. Setting FlushTo without FlushOn enables a
	// manual-only pattern where records are never flushed automatically.
	//
	// FlushTo must not directly or indirectly log back to the same slogbox Handler;
	// doing so will deadlock.
	FlushTo slog.Handler

	// MaxAge excludes records older than this duration from read operations
	// (Records, All, JSON, WriteTo). Zero means no age filtering.
	// Negative values cause New to panic.
	// Len returns the physical count regardless of MaxAge.
	//
	// MaxAge assumes records are stored in chronological order (non-decreasing
	// timestamps). If Handle is called with out-of-order timestamps, MaxAge
	// filtering may return incorrect results.
	MaxAge time.Duration
}

Options configure a Handler.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL