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() }