feat: enhance log tailing with attrs, redaction, ring buffer, level control and persistent UI

Backend: include all slog attrs in WS payload with sensitive key redaction,
add 100-entry ring buffer for replay on subscribe, support per-client level
selection via logs.tail { level: "debug" }.

Frontend: move log state to Zustand store so tailing persists across page
navigation, add level selector, client-side level filter, text search, and
inline attr rendering.
This commit is contained in:
viettranx committed 2026-03-06 19:41:49 +07:00
1 parent c5127733a0
commit e0da99d150
5 files changed
+416 -109

No files matched your search

+160 -31
View File
@@ -3,19 +3,43 @@ 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]*Client // client ID → client
clients map[string]*logSubscriber
// Ring buffer of recent entries for replay on subscribe.
ringMu sync.RWMutex
ring []map[string]interface{}
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
@@ -23,57 +47,107 @@ type LogTee struct {
func NewLogTee(inner slog.Handler) *LogTee {
return &LogTee{
inner: inner,
clients: make(map[string]*Client),
clients: make(map[string]*logSubscriber),
ring: make([]map[string]interface{}, ringBufferSize),
}
}
func (t *LogTee) Enabled(ctx context.Context, level slog.Level) bool {
return t.inner.Enabled(ctx, level)
// 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 {
// Forward to subscribers (non-blocking).
// Build entry for WS clients.
t.mu.RLock()
n := len(t.clients)
t.mu.RUnlock()
if n > 0 {
entry := map[string]interface{}{
"timestamp": r.Time.UnixMilli(),
"level": levelName(r.Level),
"message": r.Message,
}
needEntry := n > 0 // need to broadcast
// Always build entry for ring buffer regardless of subscribers.
entry := t.buildEntry(r)
// Collect attributes into source hint.
var src string
r.Attrs(func(a slog.Attr) bool {
if a.Key == "component" || a.Key == "source" || a.Key == "module" {
src = a.Value.String()
return false
}
return true
})
if src != "" {
entry["source"] = src
}
// 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 _, c := range t.clients {
c.SendEvent(*evt)
for _, sub := range t.clients {
if level >= sub.level {
sub.client.SendEvent(*evt)
}
}
t.mu.RUnlock()
}
return t.inner.Handle(ctx, r)
// 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]interface{} {
entry := map[string]interface{}{
"timestamp": r.Time.UnixMilli(),
"level": levelName(r.Level),
"message": r.Message,
}
attrs := map[string]interface{}{}
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,
mu: t.mu,
ring: t.ring,
}
}
@@ -81,17 +155,45 @@ func (t *LogTee) WithGroup(name string) slog.Handler {
return &LogTee{
inner: t.inner.WithGroup(name),
clients: t.clients,
mu: t.mu,
ring: t.ring,
}
}
// Subscribe adds a client to the log tailing set.
func (t *LogTee) Subscribe(client *Client) {
// 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()] = client
t.clients[client.ID()] = &logSubscriber{client: client, level: level}
t.mu.Unlock()
// Send an initial log entry so the client knows tailing started.
// Replay ring buffer entries at the requested level.
t.ringMu.RLock()
var entries []map[string]interface{}
if t.ringFul {
// Buffer is full — read from ringPos (oldest) to ringPos-1 (newest).
for i := 0; i < ringBufferSize; i++ {
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]interface{}{
"timestamp": time.Now().UnixMilli(),
"level": "info",
@@ -119,3 +221,30 @@ func levelName(l slog.Level) string {
return "debug"
}
}
// logLevelValue converts a level name string back to slog.Level for filtering.
func logLevelValue(v interface{}) 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
}
+18 -1
View File
@@ -3,6 +3,7 @@ package methods
import (
"context"
"encoding/json"
"log/slog"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
@@ -24,6 +25,7 @@ func (m *LogsMethods) Register(router *gateway.MethodRouter) {
func (m *LogsMethods) handleTail(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) {
var params struct {
Action string `json:"action"`
Level string `json:"level"` // "debug", "info", "warn", "error" (default: "info")
}
if req.Params != nil {
json.Unmarshal(req.Params, &params)
@@ -31,9 +33,11 @@ func (m *LogsMethods) handleTail(_ context.Context, client *gateway.Client, req
switch params.Action {
case "start":
m.logTee.Subscribe(client)
level := parseLogLevel(params.Level)
m.logTee.Subscribe(client, level)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]interface{}{
"status": "tailing",
"level": params.Level,
}))
case "stop":
m.logTee.Unsubscribe(client.ID())
@@ -48,3 +52,16 @@ func (m *LogsMethods) handleTail(_ context.Context, client *gateway.Client, req
))
}
}
func parseLogLevel(s string) slog.Level {
switch s {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
+64 -45
View File
@@ -1,32 +1,39 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useEffect, useCallback, useRef } from "react";
import { useWs } from "@/hooks/use-ws";
import { useWsEvent } from "@/hooks/use-ws-event";
import { Methods } from "@/api/protocol";
import {
useLogsStore,
type LogEntry,
type LogLevel,
} from "@/stores/use-logs-store";
export interface LogEntry {
timestamp: number;
level: string;
message: string;
source?: string;
}
/**
* Bridge hook: connects WsClient (React context) to the global Zustand log store.
* Log state persists across page navigation — tailing continues in background.
* Mount this hook on the logs page; the store retains entries when unmounted.
*/
export function useLogs() {
const ws = useWs();
const [logs, setLogs] = useState<LogEntry[]>([]);
const [tailing, setTailing] = useState(false);
const [error, setError] = useState<string | null>(null);
const maxLogs = useRef(500);
const store = useLogsStore();
const tailingRef = useRef(store.tailing);
tailingRef.current = store.tailing;
const startTail = useCallback(async () => {
if (!ws.isConnected) return;
setError(null);
try {
await ws.call(Methods.LOGS_TAIL, { action: "start" });
setTailing(true);
} catch {
setError("logs.tail is not available on this backend.");
}
}, [ws]);
const startTail = useCallback(
async (level?: LogLevel) => {
if (!ws.isConnected) return;
const lvl = level ?? store.level;
store.setError(null);
try {
await ws.call(Methods.LOGS_TAIL, { action: "start", level: lvl });
store.setTailing(true);
store.setLevel(lvl);
} catch {
store.setError("logs.tail is not available on this backend.");
}
},
[ws, store],
);
const stopTail = useCallback(async () => {
if (!ws.isConnected) return;
@@ -35,31 +42,43 @@ export function useLogs() {
} catch {
// ignore
}
setTailing(false);
}, [ws]);
store.setTailing(false);
}, [ws, store]);
// Listen for log events
useWsEvent("log", (payload: unknown) => {
const entry = payload as LogEntry;
if (!entry) return;
setLogs((prev) => {
const next = [...prev, entry];
return next.length > maxLogs.current ? next.slice(-maxLogs.current) : next;
});
});
// Listen for log events — always active while this hook is mounted.
useWsEvent(
"log",
useCallback(
(payload: unknown) => {
const entry = payload as LogEntry;
if (entry) useLogsStore.getState().appendLog(entry);
},
[],
),
);
// On WS reconnect: re-subscribe if tailing was active.
const prevConnected = useRef(ws.isConnected);
useEffect(() => {
return () => {
// Stop tailing on unmount
if (tailing) {
stopTail();
}
};
}, [tailing, stopTail]);
if (ws.isConnected && !prevConnected.current && tailingRef.current) {
// Reconnected while tailing — re-subscribe.
ws.call(Methods.LOGS_TAIL, {
action: "start",
level: useLogsStore.getState().level,
}).catch(() => {});
}
prevConnected.current = ws.isConnected;
}, [ws, ws.isConnected]);
const clearLogs = useCallback(() => {
setLogs([]);
}, []);
return { logs, tailing, error, startTail, stopTail, clearLogs };
return {
logs: store.logs,
tailing: store.tailing,
level: store.level,
error: store.error,
startTail,
stopTail,
clearLogs: store.clearLogs,
};
}
export type { LogEntry, LogLevel };
+131 -32
View File
@@ -1,43 +1,90 @@
import { useEffect, useRef } from "react";
import { Terminal, Play, Square, Trash2 } from "lucide-react";
import { useEffect, useRef, useState, useMemo } from "react";
import { Terminal, Play, Square, Trash2, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/shared/page-header";
import { useLogs, type LogEntry } from "./hooks/use-logs";
import { useLogs, type LogEntry, type LogLevel } from "./hooks/use-logs";
const levelColors: Record<string, string> = {
error: "text-red-500",
warn: "text-yellow-500",
info: "text-blue-500",
debug: "text-muted-foreground",
debug: "text-zinc-500",
};
export function LogsPage() {
const { logs, tailing, error, startTail, stopTail, clearLogs } = useLogs();
const scrollRef = useRef<HTMLDivElement>(null);
const levels: LogLevel[] = ["debug", "info", "warn", "error"];
export function LogsPage() {
const { logs, tailing, level, error, startTail, stopTail, clearLogs } =
useLogs();
const scrollRef = useRef<HTMLDivElement>(null);
const [filterLevel, setFilterLevel] = useState<LogLevel>("debug");
const [search, setSearch] = useState("");
// Auto-scroll to bottom on new logs.
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
const filtered = useMemo(() => {
const levelIdx = levels.indexOf(filterLevel);
return logs.filter((entry) => {
if (levels.indexOf(entry.level as LogLevel) < levelIdx) return false;
if (search) {
const q = search.toLowerCase();
const haystack =
`${entry.message} ${entry.source ?? ""} ${Object.values(entry.attrs ?? {}).join(" ")}`.toLowerCase();
if (!haystack.includes(q)) return false;
}
return true;
});
}, [logs, filterLevel, search]);
return (
<div className="flex h-full flex-col p-6">
<PageHeader
title="Logs"
description="Live log tailing"
description={
tailing
? `Live tailing at ${level} level`
: "Live log tailing"
}
actions={
<div className="flex items-center gap-2">
{tailing && <Badge variant="success">Live</Badge>}
{tailing ? (
<Button variant="outline" size="sm" onClick={stopTail} className="gap-1">
<Button
variant="outline"
size="sm"
onClick={stopTail}
className="gap-1"
>
<Square className="h-3.5 w-3.5" /> Stop
</Button>
) : (
<Button size="sm" onClick={startTail} className="gap-1">
<Play className="h-3.5 w-3.5" /> Start Tail
</Button>
<div className="flex items-center gap-1">
<select
value={level}
onChange={(e) => startTail(e.target.value as LogLevel)}
className="h-8 cursor-pointer rounded-md border bg-background px-2 text-xs"
title="Log level"
>
{levels.map((l) => (
<option key={l} value={l}>
{l.toUpperCase()}
</option>
))}
</select>
<Button
size="sm"
onClick={() => startTail()}
className="gap-1"
>
<Play className="h-3.5 w-3.5" /> Start
</Button>
</div>
)}
<Button
variant="outline"
@@ -52,48 +99,100 @@ export function LogsPage() {
}
/>
{/* Filters */}
<div className="mt-3 flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter logs..."
className="h-7 w-full rounded-md border bg-background pl-8 pr-3 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="flex gap-0.5">
{levels.map((l) => (
<button
key={l}
onClick={() => setFilterLevel(l)}
className={`cursor-pointer rounded-md px-2 py-1 text-xs font-medium transition-colors ${
filterLevel === l
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50"
}`}
>
{l.toUpperCase()}
</button>
))}
</div>
<span className="text-xs text-muted-foreground">
{filtered.length}/{logs.length}
</span>
</div>
{/* Log output */}
<div
ref={scrollRef}
className="mt-4 flex-1 overflow-y-auto rounded-md border bg-zinc-950 p-4 font-mono text-xs text-zinc-300"
className="mt-2 flex-1 overflow-y-auto rounded-md border bg-zinc-950 p-4 font-mono text-xs text-zinc-300"
>
{error ? (
<div className="flex items-center justify-center py-12 text-zinc-500">
<div className="text-center">
<Terminal className="mx-auto mb-2 h-8 w-8" />
<p className="text-yellow-500">{error}</p>
<p className="mt-1 text-zinc-600">
This feature is not yet implemented on the Go backend.
</p>
</div>
</div>
) : logs.length === 0 ? (
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-12 text-zinc-500">
<div className="text-center">
<Terminal className="mx-auto mb-2 h-8 w-8" />
<p>
{tailing
? "Waiting for logs..."
: "Click \"Start Tail\" to begin streaming logs."}
? search
? "No logs match filter."
: "Waiting for logs..."
: 'Click "Start" to begin streaming logs.'}
</p>
</div>
</div>
) : (
logs.map((entry: LogEntry, i: number) => (
<div key={i} className="leading-relaxed">
<span className="text-zinc-500">
{new Date(entry.timestamp).toLocaleTimeString()}
</span>{" "}
<span className={levelColors[entry.level] || "text-zinc-400"}>
[{entry.level?.toUpperCase() || "LOG"}]
</span>{" "}
{entry.source && (
<span className="text-zinc-500">[{entry.source}] </span>
)}
<span>{entry.message}</span>
</div>
filtered.map((entry: LogEntry, i: number) => (
<LogLine key={i} entry={entry} />
))
)}
</div>
</div>
);
}
function LogLine({ entry }: { entry: LogEntry }) {
const attrs = entry.attrs;
const hasAttrs = attrs && Object.keys(attrs).length > 0;
return (
<div className="leading-relaxed">
<span className="text-zinc-500">
{new Date(entry.timestamp).toLocaleTimeString()}
</span>{" "}
<span className={levelColors[entry.level] || "text-zinc-400"}>
[{entry.level?.toUpperCase() || "LOG"}]
</span>{" "}
{entry.source && (
<span className="text-cyan-600">[{entry.source}] </span>
)}
<span>{entry.message}</span>
{hasAttrs && (
<span className="ml-2 text-zinc-600">
{Object.entries(attrs!).map(([k, v]) => (
<span key={k} className="mr-2">
<span className="text-zinc-500">{k}</span>
<span className="text-zinc-600">=</span>
<span className="text-zinc-400">{v}</span>
</span>
))}
</span>
)}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { create } from "zustand";
export interface LogEntry {
timestamp: number;
level: string;
message: string;
source?: string;
attrs?: Record<string, string>;
}
export type LogLevel = "debug" | "info" | "warn" | "error";
interface LogsState {
logs: LogEntry[];
tailing: boolean;
level: LogLevel;
error: string | null;
appendLog: (entry: LogEntry) => void;
setTailing: (v: boolean) => void;
setLevel: (l: LogLevel) => void;
setError: (e: string | null) => void;
clearLogs: () => void;
}
const MAX_LOGS = 500;
export const useLogsStore = create<LogsState>((set) => ({
logs: [],
tailing: false,
level: "info",
error: null,
appendLog: (entry) =>
set((s) => {
const next = [...s.logs, entry];
return { logs: next.length > MAX_LOGS ? next.slice(-MAX_LOGS) : next };
}),
setTailing: (v) => set({ tailing: v }),
setLevel: (l) => set({ level: l }),
setError: (e) => set({ error: e }),
clearLogs: () => set({ logs: [] }),
}));