Files
2026-07-26 09:39:17 +00:00

524 lines
13 KiB
Go

package core
import (
"context"
"fmt"
"log/slog"
"regexp"
"sort"
"strings"
"sync"
"time"
)
// LogAttr is one flattened structured field. Groups are folded into the key
// with dots so the GUI can render a single flat line per entry.
type LogAttr struct {
Key string
Value string
}
// LogEntry is a single captured log record.
type LogEntry struct {
Seq uint64
Time time.Time
Level slog.Level
Msg string
Attrs []LogAttr
// Source is the value of the conventional "from" attribute, used by the
// GUI to group logs by subsystem.
Source string
}
// Text renders the entry the way the console handler would, minus colour.
func (e LogEntry) Text() string {
var b strings.Builder
b.WriteString(e.Time.Format("2006-01-02 15:04:05.000"))
b.WriteByte(' ')
b.WriteString(levelLabel(e.Level))
b.WriteByte(' ')
b.WriteString(e.Msg)
for _, a := range e.Attrs {
b.WriteByte(' ')
b.WriteString(a.Key)
b.WriteByte('=')
if strings.ContainsAny(a.Value, " \t\"") {
fmt.Fprintf(&b, "%q", a.Value)
} else {
b.WriteString(a.Value)
}
}
return b.String()
}
func levelLabel(l slog.Level) string {
switch {
case l < slog.LevelInfo:
return "DBG"
case l < slog.LevelWarn:
return "INF"
case l < slog.LevelError:
return "WRN"
default:
return "ERR"
}
}
// LevelLabel exposes the three-letter level name used in exports and the GUI.
func LevelLabel(l slog.Level) string { return levelLabel(l) }
// LogQuery filters a buffer snapshot.
type LogQuery struct {
// MinLevel drops anything below it.
MinLevel slog.Level
// Text is a case-insensitive substring matched against the message, the
// attribute values and the source.
Text string
// Source, when set, keeps only entries from that subsystem.
Source string
// Limit keeps only the newest N matches. Zero means unlimited.
Limit int
}
func (q LogQuery) match(e LogEntry) bool {
if e.Level < q.MinLevel {
return false
}
if q.Source != "" && e.Source != q.Source {
return false
}
if q.Text == "" {
return true
}
needle := strings.ToLower(q.Text)
if strings.Contains(strings.ToLower(e.Msg), needle) {
return true
}
if strings.Contains(strings.ToLower(e.Source), needle) {
return true
}
for _, a := range e.Attrs {
if strings.Contains(strings.ToLower(a.Key), needle) ||
strings.Contains(strings.ToLower(a.Value), needle) {
return true
}
}
return false
}
// LogBuffer is a fixed-capacity ring of the most recent log records. It is the
// single source of truth for the GUI's log view and for diagnostic exports.
//
// All methods are safe for concurrent use.
type LogBuffer struct {
mu sync.RWMutex
entries []LogEntry // ring storage, len == cap once full
start int // index of the oldest entry
count int
nextSeq uint64
dropped uint64
subs map[int]chan struct{}
nextSub int
sources map[string]int
levelCnt map[slog.Level]int
}
// DefaultLogCapacity is how many records the GUI keeps in memory. At roughly
// 200 bytes per record this is a few megabytes at most.
const DefaultLogCapacity = 20000
// NewLogBuffer returns a buffer holding at most capacity records.
func NewLogBuffer(capacity int) *LogBuffer {
if capacity <= 0 {
capacity = DefaultLogCapacity
}
return &LogBuffer{
entries: make([]LogEntry, capacity),
subs: make(map[int]chan struct{}),
sources: make(map[string]int),
levelCnt: make(map[slog.Level]int),
}
}
// Add appends an entry, evicting the oldest record when full.
func (b *LogBuffer) Add(e LogEntry) {
b.mu.Lock()
b.nextSeq++
e.Seq = b.nextSeq
capacity := len(b.entries)
if b.count == capacity {
evicted := b.entries[b.start]
b.decStatsLocked(evicted)
b.entries[b.start] = e
b.start = (b.start + 1) % capacity
b.dropped++
} else {
b.entries[(b.start+b.count)%capacity] = e
b.count++
}
b.incStatsLocked(e)
for _, ch := range b.subs {
select {
case ch <- struct{}{}:
default: // subscriber has a pending wakeup already
}
}
b.mu.Unlock()
}
func (b *LogBuffer) incStatsLocked(e LogEntry) {
b.levelCnt[e.Level]++
if e.Source != "" {
b.sources[e.Source]++
}
}
func (b *LogBuffer) decStatsLocked(e LogEntry) {
b.levelCnt[e.Level]--
if b.levelCnt[e.Level] <= 0 {
delete(b.levelCnt, e.Level)
}
if e.Source != "" {
b.sources[e.Source]--
if b.sources[e.Source] <= 0 {
delete(b.sources, e.Source)
}
}
}
// Len returns the number of buffered records.
func (b *LogBuffer) Len() int {
b.mu.RLock()
defer b.mu.RUnlock()
return b.count
}
// Dropped returns how many records were evicted because the ring was full.
func (b *LogBuffer) Dropped() uint64 {
b.mu.RLock()
defer b.mu.RUnlock()
return b.dropped
}
// LastSeq returns the sequence number of the most recent record.
func (b *LogBuffer) LastSeq() uint64 {
b.mu.RLock()
defer b.mu.RUnlock()
return b.nextSeq
}
// Counts returns how many buffered records exist per level.
func (b *LogBuffer) Counts() map[slog.Level]int {
b.mu.RLock()
defer b.mu.RUnlock()
out := make(map[slog.Level]int, len(b.levelCnt))
for k, v := range b.levelCnt {
out[k] = v
}
return out
}
// Sources returns the distinct subsystem names currently buffered, sorted.
func (b *LogBuffer) Sources() []string {
b.mu.RLock()
defer b.mu.RUnlock()
out := make([]string, 0, len(b.sources))
for k := range b.sources {
out = append(out, k)
}
sort.Strings(out)
return out
}
// Snapshot returns every buffered record, oldest first.
func (b *LogBuffer) Snapshot() []LogEntry {
b.mu.RLock()
defer b.mu.RUnlock()
return b.collectLocked(func(LogEntry) bool { return true }, 0)
}
// Tail returns the newest n records, oldest first.
//
// It walks backwards from the newest record so the cost is O(n), not O(ring).
// The GUI's log overlay calls this on every frame; scanning a full 20k-entry
// ring each time was enough on its own to keep a core busy.
func (b *LogBuffer) Tail(n int) []LogEntry {
if n <= 0 {
return nil
}
b.mu.RLock()
defer b.mu.RUnlock()
return b.newestLocked(func(LogEntry) bool { return true }, n)
}
// Filter returns the records matching q, oldest first.
func (b *LogBuffer) Filter(q LogQuery) []LogEntry {
b.mu.RLock()
defer b.mu.RUnlock()
if q.Limit > 0 {
return b.newestLocked(q.match, q.Limit)
}
return b.collectLocked(q.match, 0)
}
// newestLocked walks the ring newest-first, keeping at most limit matches, and
// returns them oldest-first.
func (b *LogBuffer) newestLocked(keep func(LogEntry) bool, limit int) []LogEntry {
capacity := len(b.entries)
out := make([]LogEntry, 0, min(limit, b.count))
for i := b.count - 1; i >= 0 && len(out) < limit; i-- {
e := b.entries[(b.start+i)%capacity]
if keep(e) {
out = append(out, e)
}
}
// Reverse in place to restore chronological order.
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out
}
// collectLocked walks the ring oldest-first. When limit > 0 only the newest
// limit matches are kept.
func (b *LogBuffer) collectLocked(keep func(LogEntry) bool, limit int) []LogEntry {
capacity := len(b.entries)
out := make([]LogEntry, 0, min(b.count, 512))
for i := 0; i < b.count; i++ {
e := b.entries[(b.start+i)%capacity]
if keep(e) {
out = append(out, e)
}
}
if limit > 0 && len(out) > limit {
out = out[len(out)-limit:]
}
return out
}
// Subscribe returns a channel that receives a value whenever a record is
// added, plus a function that cancels the subscription. The channel is
// buffered and coalescing: a slow reader sees one wakeup, not a backlog.
func (b *LogBuffer) Subscribe() (<-chan struct{}, func()) {
ch := make(chan struct{}, 1)
b.mu.Lock()
id := b.nextSub
b.nextSub++
b.subs[id] = ch
b.mu.Unlock()
var once sync.Once
cancel := func() {
once.Do(func() {
b.mu.Lock()
delete(b.subs, id)
b.mu.Unlock()
})
}
return ch, cancel
}
// ---------------------------------------------------------------------------
// slog handler
// ---------------------------------------------------------------------------
// bufHandler tees records into a LogBuffer and on to a wrapped handler.
type bufHandler struct {
buf *LogBuffer
next slog.Handler
attrs []LogAttr
groups []string
}
// Handler returns a slog.Handler that records everything into b and forwards
// to next. next may be nil, in which case records are only buffered.
//
// The buffer always captures at debug level regardless of what next filters,
// so the GUI can show detail the console suppressed.
func (b *LogBuffer) Handler(next slog.Handler) slog.Handler {
return &bufHandler{buf: b, next: next}
}
func (h *bufHandler) Enabled(ctx context.Context, l slog.Level) bool {
// Always capture: the buffer is the diagnostic record of last resort.
return true
}
func (h *bufHandler) Handle(ctx context.Context, r slog.Record) error {
attrs := make([]LogAttr, 0, len(h.attrs)+r.NumAttrs())
attrs = append(attrs, h.attrs...)
r.Attrs(func(a slog.Attr) bool {
attrs = appendAttr(attrs, h.groups, a)
return true
})
source := ""
for _, a := range attrs {
if a.Key == "from" {
source = a.Value
}
}
t := r.Time
if t.IsZero() {
t = time.Now()
}
h.buf.Add(LogEntry{
Time: t,
Level: r.Level,
Msg: r.Message,
Attrs: attrs,
Source: source,
})
if h.next != nil && h.next.Enabled(ctx, r.Level) {
return h.next.Handle(ctx, r)
}
return nil
}
func (h *bufHandler) WithAttrs(as []slog.Attr) slog.Handler {
if len(as) == 0 {
return h
}
clone := *h
clone.attrs = make([]LogAttr, len(h.attrs), len(h.attrs)+len(as))
copy(clone.attrs, h.attrs)
for _, a := range as {
clone.attrs = appendAttr(clone.attrs, h.groups, a)
}
if h.next != nil {
clone.next = h.next.WithAttrs(as)
}
return &clone
}
func (h *bufHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
clone := *h
clone.groups = append(append([]string(nil), h.groups...), name)
if h.next != nil {
clone.next = h.next.WithGroup(name)
}
return &clone
}
// appendAttr flattens a slog.Attr, expanding groups into dotted keys.
func appendAttr(dst []LogAttr, groups []string, a slog.Attr) []LogAttr {
a.Value = a.Value.Resolve()
if a.Equal(slog.Attr{}) {
return dst
}
if a.Value.Kind() == slog.KindGroup {
sub := a.Value.Group()
if len(sub) == 0 {
return dst
}
nested := groups
if a.Key != "" {
nested = append(append([]string(nil), groups...), a.Key)
}
for _, s := range sub {
dst = appendAttr(dst, nested, s)
}
return dst
}
key := a.Key
if len(groups) > 0 {
key = strings.Join(groups, ".") + "." + key
}
return append(dst, LogAttr{Key: key, Value: a.Value.String()})
}
// NewLoggerWithBuffer builds the console logger exactly as [NewLogger] does
// and tees every record into buf.
func NewLoggerWithBuffer(level string, useJsonFormat bool, buf *LogBuffer) *slog.Logger {
base := NewLogger(level, useJsonFormat)
logger := slog.New(buf.Handler(base.Handler()))
slog.SetDefault(logger)
return logger
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
// secretPattern matches Tailscale auth keys and OAuth client secrets, which
// are the one thing in these logs that must never reach a paste service.
var secretPattern = regexp.MustCompile(`\b(tskey-[a-zA-Z]+-)[A-Za-z0-9\-_]{6,}`)
// secretKeys are attribute names whose values are replaced wholesale.
var secretKeys = map[string]bool{
"auth_key": true,
"authkey": true,
"auth-key": true,
"token": true,
"secret": true,
"password": true,
"client_secret": true,
}
// Redact removes credentials from a single string.
func Redact(s string) string {
return secretPattern.ReplaceAllString(s, "${1}REDACTED")
}
func redactAttr(a LogAttr) LogAttr {
if secretKeys[strings.ToLower(a.Key)] {
if a.Value == "" {
return a
}
return LogAttr{Key: a.Key, Value: "[REDACTED]"}
}
a.Value = Redact(a.Value)
return a
}
// ExportOptions controls how a log dump is rendered.
type ExportOptions struct {
Query LogQuery
// Redact strips credentials. Callers sharing logs publicly must leave this
// on; it defaults to on because [ExportText] is built for sharing.
NoRedact bool
// Header is prepended verbatim, used for environment metadata.
Header string
}
// ExportText renders matching entries as a plain-text report suitable for
// pasting into an issue tracker or a paste service.
func (b *LogBuffer) ExportText(opt ExportOptions) string {
entries := b.Filter(opt.Query)
var sb strings.Builder
if opt.Header != "" {
sb.WriteString(opt.Header)
if !strings.HasSuffix(opt.Header, "\n") {
sb.WriteByte('\n')
}
sb.WriteString("\n")
}
if dropped := b.Dropped(); dropped > 0 {
fmt.Fprintf(&sb, "# %d earlier record(s) were dropped from the ring buffer\n\n", dropped)
}
for _, e := range entries {
if !opt.NoRedact {
e.Msg = Redact(e.Msg)
redacted := make([]LogAttr, len(e.Attrs))
for i, a := range e.Attrs {
redacted[i] = redactAttr(a)
}
e.Attrs = redacted
}
sb.WriteString(e.Text())
sb.WriteByte('\n')
}
if len(entries) == 0 {
sb.WriteString("(no matching log entries)\n")
}
return sb.String()
}