Searchable ring

handlers/logring keeps the most recent records in a bounded in-memory ring, so a process can answer “what just happened” without leaving the process. A debug endpoint or an admin page can read it directly.

The ring is a slog.Handler. On its own it captures records and prints nothing, so add it next to your output instead of replacing it:

import (
	"github.com/psyb0t/slogging/handlers/logring"
	"github.com/psyb0t/slogging/slogconf"
)

ring := logring.New(logring.Options{})
slogconf.AddSink(ring)

It is a debugging aid, not a log store. It is per process, bounded, and gone when the process dies, which is exactly when you most want the logs. Ship them somewhere durable as well, for example with the Loki handler.

Options

The zero value works. Every field falls back to a Default* constant.

Field Meaning Zero value means
MaxBytes Total size of retained records. Oldest records are evicted to stay under it. DefaultMaxBytes, 100 MiB
MaxRecordBytes A single record larger than this is dropped and counted. Clamped to MaxBytes. DefaultMaxRecordBytes, 1 MiB
Level Minimum level retained, a slog.Leveler DefaultLevel, slog.LevelInfo
Text Store lines in slog’s text format instead of JSON JSON

The ring is bounded by bytes, not record count, so one huge line cannot evict a hundred useful ones. A record’s size is its formatted line plus its message plus the keys and values of its captured attributes, since all of that stays in memory.

The default level skips debug. A service that logs every SQL statement at debug would fill the ring with query traces in seconds. The ring applies its own level even inside a fan-out, so a process running at debug does not pour its trace lines into a ring set to warn.

Text changes only the format of Entry.Line. Attribute search reads the captured attributes, so it works the same in either format.

What an entry holds

type Entry struct {
	Time  time.Time
	Level slog.Level
	Msg   string
	Line  string // the formatted record, without the trailing newline
	Attrs []Attr // Key and Value, both strings
}

Attrs holds the record’s attributes plus the ones bound earlier with logger.With(...), which never appear on the slog.Record itself. Grouped attributes get dotted keys: a logger from WithGroup("http") logging status stores http.status. Values are rendered to strings when the record is captured, so a slog.LogValuer keeps what it returned at log time and the ring does not hold on to your objects. entry.Attr("request_id") returns one value and whether it was there.

Line does not include source information, even when the process output does.

Searching

page := ring.Search(logring.SearchOptions{
	Attrs:    map[string]string{"request_id": "abc123"},
	MinLevel: slog.LevelWarn,
	Limit:    50,
})

fmt.Printf("showing %d of %d\n", len(page.Entries), page.Total)

Search returns a Page:

Field Meaning
Entries The matches for this page, newest first unless Ascending is set
Total How many entries matched before Limit and Offset were applied
Offset The offset you asked for, echoed back

Without Total, a full page and the last page look the same. It is counted in the same locked walk that collects the page, so both numbers describe the same moment of the ring. The price is that every search visits every entry, even after the page is full.

The zero SearchOptions{} returns everything, newest first, capped at DefaultSearchLimit (200). Every filter you set has to pass:

Field Keeps
Contains Entries whose Line contains this substring, ignoring case
Exclude Drops entries whose Line contains this substring, ignoring case
Match Entries whose Line matches this compiled *regexp.Regexp
Attrs Entries carrying every one of these key and value pairs exactly, matched against the captured attributes, not the line
MinLevel Entries at or above this slog.Leveler. Nil applies no level filter, so custom levels below debug still come back.
Levels Entries at exactly one of these levels, for “warnings and errors but not info”. Empty applies no filter.
Since Entries at or after this time
Until Entries at or before this time
Limit At most this many entries. Zero or less means 200.
Offset Skips this many matches first, for paging
Ascending Oldest first instead of newest first

Match takes a compiled expression, so a bad pattern fails where you compile it with regexp.MustCompile or regexp.Compile, not inside Search.

To page through a large result, keep Limit fixed and raise Offset by Limit until Offset reaches Total. Records arriving between calls shift the pages.

Other reads

matches := ring.Count(opts)   // matches only; Limit and Offset are ignored
recent := ring.Tail(50)       // newest 50, oldest first, unfiltered
bytes := ring.Size()          // bytes held: the number the ring bounds itself by
count := ring.Len()           // records held
n, b, dropped := ring.Stats() // records, bytes and drops under one lock
ring.Clear()                  // empty the ring

Watch Size() against MaxBytes to see how close the ring is to evicting. A nonzero dropped means some records were refused for exceeding MaxRecordBytes, so a search is running over an incomplete picture. dropped counts for the life of the ring and survives Clear.

Handlers derived with With or WithGroup write into the same ring, and every method is safe for concurrent use.