add: gui and tsdiag
This commit is contained in:
+523
@@ -0,0 +1,523 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user