Files
goclaw/internal/gateway/log_tee.go
T
viettranx bdb60de7ae chore: upgrade Go 1.25 → 1.26 and apply go fix modernizations
- Update go.mod and Dockerfile to Go 1.26
- Apply `go fix ./...` stdlib modernizations across 170+ files
- Add `go fix` to post-implementation checklist in CLAUDE.md
- Fix go fix misapplied rewrite in loop_history.go
2026-03-10 00:09:15 +07:00

251 lines
5.5 KiB
Go

package gateway
import (
"context"
"log/slog"
"strings"
"sync"
"time"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
const (
ringBufferSize = 100
redactedValue = "***"
)
// sensitiveKeys are attribute keys whose values are redacted before forwarding.
var sensitiveKeys = []string{
"key", "token", "secret", "password", "dsn",
"credential", "authorization", "cookie",
}
// LogTee is a slog.Handler that forwards log records to subscribed WS clients
// while delegating to an underlying handler for normal output.
type LogTee struct {
inner slog.Handler
mu sync.RWMutex
clients map[string]*logSubscriber
// Ring buffer of recent entries for replay on subscribe.
ringMu sync.RWMutex
ring []map[string]any
ringPos int
ringFul bool
}
// logSubscriber tracks a client and its requested minimum log level.
type logSubscriber struct {
client *Client
level slog.Level
}
// NewLogTee wraps an existing slog.Handler so log records are also forwarded
// to any WebSocket clients that have started log tailing.
func NewLogTee(inner slog.Handler) *LogTee {
return &LogTee{
inner: inner,
clients: make(map[string]*logSubscriber),
ring: make([]map[string]any, ringBufferSize),
}
}
func (t *LogTee) Enabled(ctx context.Context, level slog.Level) bool {
// Always accept if inner handler wants it.
if t.inner.Enabled(ctx, level) {
return true
}
// Also accept if any subscriber wants this level (e.g. debug).
t.mu.RLock()
defer t.mu.RUnlock()
for _, sub := range t.clients {
if level >= sub.level {
return true
}
}
return false
}
func (t *LogTee) Handle(ctx context.Context, r slog.Record) error {
// Build entry for WS clients.
t.mu.RLock()
n := len(t.clients)
t.mu.RUnlock()
needEntry := n > 0 // need to broadcast
// Always build entry for ring buffer regardless of subscribers.
entry := t.buildEntry(r)
// Store in ring buffer.
t.ringMu.Lock()
t.ring[t.ringPos] = entry
t.ringPos = (t.ringPos + 1) % ringBufferSize
if t.ringPos == 0 {
t.ringFul = true
}
t.ringMu.Unlock()
// Forward to subscribers.
if needEntry {
evt := protocol.NewEvent("log", entry)
level := r.Level
t.mu.RLock()
for _, sub := range t.clients {
if level >= sub.level {
sub.client.SendEvent(*evt)
}
}
t.mu.RUnlock()
}
// Forward to inner handler only if it accepts this level.
if t.inner.Enabled(ctx, r.Level) {
return t.inner.Handle(ctx, r)
}
return nil
}
// buildEntry creates the WS payload from a log record, redacting sensitive attrs.
func (t *LogTee) buildEntry(r slog.Record) map[string]any {
entry := map[string]any{
"timestamp": r.Time.UnixMilli(),
"level": levelName(r.Level),
"message": r.Message,
}
attrs := map[string]any{}
r.Attrs(func(a slog.Attr) bool {
key := a.Key
val := a.Value.String()
// Extract source hint.
if key == "component" || key == "source" || key == "module" {
entry["source"] = val
return true
}
// Redact sensitive values.
if isSensitiveKey(key) {
attrs[key] = redactedValue
} else {
attrs[key] = val
}
return true
})
if len(attrs) > 0 {
entry["attrs"] = attrs
}
return entry
}
func (t *LogTee) WithAttrs(attrs []slog.Attr) slog.Handler {
return &LogTee{
inner: t.inner.WithAttrs(attrs),
clients: t.clients,
ring: t.ring,
}
}
func (t *LogTee) WithGroup(name string) slog.Handler {
return &LogTee{
inner: t.inner.WithGroup(name),
clients: t.clients,
ring: t.ring,
}
}
// Subscribe adds a client to the log tailing set at the given level.
// Pass slog.LevelInfo for default, slog.LevelDebug for verbose.
func (t *LogTee) Subscribe(client *Client, level slog.Level) {
t.mu.Lock()
t.clients[client.ID()] = &logSubscriber{client: client, level: level}
t.mu.Unlock()
// Replay ring buffer entries at the requested level.
t.ringMu.RLock()
var entries []map[string]any
if t.ringFul {
// Buffer is full — read from ringPos (oldest) to ringPos-1 (newest).
for i := range ringBufferSize {
idx := (t.ringPos + i) % ringBufferSize
e := t.ring[idx]
if e != nil && logLevelValue(e["level"]) >= level {
entries = append(entries, e)
}
}
} else {
// Buffer not full — read from 0 to ringPos-1.
for i := 0; i < t.ringPos; i++ {
e := t.ring[i]
if e != nil && logLevelValue(e["level"]) >= level {
entries = append(entries, e)
}
}
}
t.ringMu.RUnlock()
for _, e := range entries {
client.SendEvent(*protocol.NewEvent("log", e))
}
// Send sentinel so the client knows tailing started.
client.SendEvent(*protocol.NewEvent("log", map[string]any{
"timestamp": time.Now().UnixMilli(),
"level": "info",
"message": "Log tailing started",
"source": "gateway",
}))
}
// Unsubscribe removes a client from the log tailing set.
func (t *LogTee) Unsubscribe(clientID string) {
t.mu.Lock()
delete(t.clients, clientID)
t.mu.Unlock()
}
func levelName(l slog.Level) string {
switch {
case l >= slog.LevelError:
return "error"
case l >= slog.LevelWarn:
return "warn"
case l >= slog.LevelInfo:
return "info"
default:
return "debug"
}
}
// logLevelValue converts a level name string back to slog.Level for filtering.
func logLevelValue(v any) slog.Level {
s, _ := v.(string)
switch s {
case "error":
return slog.LevelError
case "warn":
return slog.LevelWarn
case "info":
return slog.LevelInfo
case "debug":
return slog.LevelDebug
default:
return slog.LevelInfo
}
}
func isSensitiveKey(key string) bool {
lower := strings.ToLower(key)
for _, s := range sensitiveKeys {
if strings.Contains(lower, s) {
return true
}
}
return false
}