| 1 | package scribe |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "log/slog" |
| 6 | "slices" |
| 7 | "sync" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | // StoredRecord holds a captured log record for later retrieval. |
| 12 | type StoredRecord struct { |
| 13 | Time time.Time |
| 14 | Level slog.Level |
| 15 | Message string |
| 16 | Attrs []slog.Attr |
| 17 | PC uintptr |
| 18 | Groups []string |
| 19 | } |
| 20 | |
| 21 | // FromSlogRecord creates a StoredRecord from an slog.Record. |
| 22 | func FromSlogRecord(r slog.Record, groups []string) StoredRecord { |
| 23 | attrs := make([]slog.Attr, 0, r.NumAttrs()) |
| 24 | r.Attrs(func(a slog.Attr) bool { |
| 25 | attrs = append(attrs, a) |
| 26 | return true |
| 27 | }) |
| 28 | |
| 29 | return StoredRecord{ |
| 30 | Time: r.Time, |
| 31 | Level: r.Level, |
| 32 | Message: r.Message, |
| 33 | Attrs: attrs, |
| 34 | PC: r.PC, |
| 35 | Groups: slices.Clone(groups), |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | // ToSlogRecord converts back to an slog.Record (for replay). |
| 40 | func (s StoredRecord) ToSlogRecord() slog.Record { |
| 41 | r := slog.NewRecord(s.Time, s.Level, s.Message, s.PC) |
| 42 | r.AddAttrs(s.Attrs...) |
| 43 | return r |
| 44 | } |
| 45 | |
| 46 | // RingBuffer is a generic circular buffer. |
| 47 | type RingBuffer[T any] struct { |
| 48 | mu sync.RWMutex |
| 49 | items []T |
| 50 | head int // next write position |
| 51 | size int // current number of items |
| 52 | capacity int |
| 53 | } |
| 54 | |
| 55 | // NewRingBuffer creates a new ring buffer with the given capacity. |
| 56 | func NewRingBuffer[T any](capacity int) *RingBuffer[T] { |
| 57 | if capacity <= 0 { |
| 58 | capacity = 1000 |
| 59 | } |
| 60 | return &RingBuffer[T]{ |
| 61 | items: make([]T, capacity), |
| 62 | capacity: capacity, |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | // Push adds an item to the buffer, evicting the oldest if full. |
| 67 | func (r *RingBuffer[T]) Push(item T) { |
| 68 | r.mu.Lock() |
| 69 | defer r.mu.Unlock() |
| 70 | |
| 71 | r.items[r.head] = item |
| 72 | r.head = (r.head + 1) % r.capacity |
| 73 | |
| 74 | if r.size < r.capacity { |
| 75 | r.size++ |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | // Snapshot returns a copy of all items in chronological order. |
| 80 | func (r *RingBuffer[T]) Snapshot() []T { |
| 81 | r.mu.RLock() |
| 82 | defer r.mu.RUnlock() |
| 83 | |
| 84 | if r.size == 0 { |
| 85 | return nil |
| 86 | } |
| 87 | |
| 88 | result := make([]T, r.size) |
| 89 | if r.size < r.capacity { |
| 90 | copy(result, r.items[:r.size]) |
| 91 | } else { |
| 92 | // Buffer is full, head points to oldest item |
| 93 | copy(result, r.items[r.head:]) |
| 94 | copy(result[r.capacity-r.head:], r.items[:r.head]) |
| 95 | } |
| 96 | return result |
| 97 | } |
| 98 | |
| 99 | // Len returns the current number of items. |
| 100 | func (r *RingBuffer[T]) Len() int { |
| 101 | r.mu.RLock() |
| 102 | defer r.mu.RUnlock() |
| 103 | return r.size |
| 104 | } |
| 105 | |
| 106 | // Cap returns the buffer capacity. |
| 107 | func (r *RingBuffer[T]) Cap() int { |
| 108 | return r.capacity |
| 109 | } |
| 110 | |
| 111 | // Clear removes all items from the buffer. |
| 112 | func (r *RingBuffer[T]) Clear() { |
| 113 | r.mu.Lock() |
| 114 | defer r.mu.Unlock() |
| 115 | |
| 116 | // Zero out items to allow GC |
| 117 | var zero T |
| 118 | for i := range r.items { |
| 119 | r.items[i] = zero |
| 120 | } |
| 121 | r.head = 0 |
| 122 | r.size = 0 |
| 123 | } |
| 124 | |
| 125 | // Filter returns items matching the predicate. |
| 126 | func (r *RingBuffer[T]) Filter(pred func(T) bool) []T { |
| 127 | snapshot := r.Snapshot() |
| 128 | result := make([]T, 0, len(snapshot)) |
| 129 | for _, item := range snapshot { |
| 130 | if pred(item) { |
| 131 | result = append(result, item) |
| 132 | } |
| 133 | } |
| 134 | return result |
| 135 | } |
| 136 | |
| 137 | // BufferHandler wraps another handler and stores all records in a ring buffer. |
| 138 | // Records are stored regardless of level filtering applied by the inner handler. |
| 139 | type BufferHandler struct { |
| 140 | inner slog.Handler |
| 141 | buffer *RingBuffer[StoredRecord] |
| 142 | groups []string |
| 143 | } |
| 144 | |
| 145 | // NewBufferHandler creates a buffering wrapper around another handler. |
| 146 | func NewBufferHandler(inner slog.Handler, opts ...Option) *BufferHandler { |
| 147 | o := defaultOptions() |
| 148 | for _, opt := range opts { |
| 149 | if opt != nil { |
| 150 | opt(o) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | return &BufferHandler{ |
| 155 | inner: inner, |
| 156 | buffer: NewRingBuffer[StoredRecord](o.bufferSize), |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // Enabled implements slog.Handler. |
| 161 | // Always returns true to capture all records in the buffer. |
| 162 | func (h *BufferHandler) Enabled(_ context.Context, _ slog.Level) bool { |
| 163 | return true |
| 164 | } |
| 165 | |
| 166 | // Handle implements slog.Handler. |
| 167 | func (h *BufferHandler) Handle(ctx context.Context, r slog.Record) error { |
| 168 | // Always store in buffer |
| 169 | h.buffer.Push(FromSlogRecord(r, h.groups)) |
| 170 | |
| 171 | // Delegate to inner handler (which may filter by level) |
| 172 | if h.inner.Enabled(ctx, r.Level) { |
| 173 | return h.inner.Handle(ctx, r) |
| 174 | } |
| 175 | return nil |
| 176 | } |
| 177 | |
| 178 | // WithAttrs implements slog.Handler. |
| 179 | func (h *BufferHandler) WithAttrs(attrs []slog.Attr) slog.Handler { |
| 180 | return &BufferHandler{ |
| 181 | inner: h.inner.WithAttrs(attrs), |
| 182 | buffer: h.buffer, // shared buffer |
| 183 | groups: h.groups, |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | // WithGroup implements slog.Handler. |
| 188 | func (h *BufferHandler) WithGroup(name string) slog.Handler { |
| 189 | if name == "" { |
| 190 | return h |
| 191 | } |
| 192 | return &BufferHandler{ |
| 193 | inner: h.inner.WithGroup(name), |
| 194 | buffer: h.buffer, // shared buffer |
| 195 | groups: append(slices.Clone(h.groups), name), |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | // Buffer returns the underlying ring buffer for direct access. |
| 200 | func (h *BufferHandler) Buffer() *RingBuffer[StoredRecord] { |
| 201 | return h.buffer |
| 202 | } |
| 203 | |
| 204 | // Records returns all stored records. |
| 205 | func (h *BufferHandler) Records() []StoredRecord { |
| 206 | return h.buffer.Snapshot() |
| 207 | } |
| 208 | |
| 209 | // RecordsByLevel returns records matching the given level. |
| 210 | func (h *BufferHandler) RecordsByLevel(level slog.Level) []StoredRecord { |
| 211 | return h.buffer.Filter(func(r StoredRecord) bool { |
| 212 | return r.Level == level |
| 213 | }) |
| 214 | } |
| 215 | |
| 216 | // RecordsSince returns records from the given time onwards. |
| 217 | func (h *BufferHandler) RecordsSince(t time.Time) []StoredRecord { |
| 218 | return h.buffer.Filter(func(r StoredRecord) bool { |
| 219 | return !r.Time.Before(t) |
| 220 | }) |
| 221 | } |
| 222 | |
| 223 | // Replay sends all buffered records to another handler. |
| 224 | func (h *BufferHandler) Replay(ctx context.Context, target slog.Handler) error { |
| 225 | for _, stored := range h.buffer.Snapshot() { |
| 226 | r := stored.ToSlogRecord() |
| 227 | if target.Enabled(ctx, r.Level) { |
| 228 | if err := target.Handle(ctx, r); err != nil { |
| 229 | return err |
| 230 | } |
| 231 | } |
| 232 | } |
| 233 | return nil |
| 234 | } |
| 235 | |