From e8b5679cf44090b6fbd06dc4017b90f8821ae02b Mon Sep 17 00:00:00 2001 From: Aska Date: Wed, 12 Aug 2026 17:16:33 +0800 Subject: [PATCH] feat(admin): add full-chain trace and performance monitoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: trace.Logger ring-buffer for request chain events - Backend: perf.Monitor with runtime.MemStats sampling every 10s - Backend: /api/v1/admin/trace and /api/v1/admin/perf endpoints - Frontend: TracePage — timeline with hop icons and status dots - Frontend: PerformancePage — memory/GC bar charts + live stats - Admin nav expanded to 11 items --- backend/cmd/cyrene/main.go | 46 ++++++++ backend/services/ai-core/perf/monitor.go | 104 ++++++++++++++++++ backend/services/ai-core/trace/logger.go | 82 ++++++++++++++ backend/services/gateway/api/router.go | 36 ++++++ frontend/web/src/App.tsx | 4 + .../web/src/components/admin/AdminLayout.tsx | 2 + .../web/src/pages/admin/PerformancePage.tsx | 92 ++++++++++++++++ frontend/web/src/pages/admin/TracePage.tsx | 76 +++++++++++++ 8 files changed, 442 insertions(+) create mode 100644 backend/services/ai-core/perf/monitor.go create mode 100644 backend/services/ai-core/trace/logger.go create mode 100644 frontend/web/src/pages/admin/PerformancePage.tsx create mode 100644 frontend/web/src/pages/admin/TracePage.tsx diff --git a/backend/cmd/cyrene/main.go b/backend/cmd/cyrene/main.go index 1a038e8..3069265 100644 --- a/backend/cmd/cyrene/main.go +++ b/backend/cmd/cyrene/main.go @@ -33,6 +33,8 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/llm" llmlog "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/llm" + tracepkg "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/trace" + perfpkg "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/perf" aiCoreCtx "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/context" "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/orchestrator" "git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/persona" @@ -233,10 +235,16 @@ func main() { // Build dependencies for gateway handlers llmLogger := llmlog.NewLogger(200) + traceLogger := tracepkg.NewLogger(500) + perfMon := perfpkg.NewMonitor(120, 10*time.Second) + perfMon.Start() + gwDeps := api.Dependencies{ AuthMgr: authMgr, Orchestrator: orch, LLMLogger: &llmLoggerAdapter{Logger: llmLogger}, + TraceLog: &traceAdapter{Logger: traceLogger}, + PerfMon: &perfAdapter{Monitor: perfMon}, ModelConfig: api.NewModelConfigStore(), PluginList: pluginNamesToMap(reg.ListNames()), } @@ -294,3 +302,41 @@ func pluginNamesToMap(names []string) []map[string]string { } return result } + +// traceAdapter adapts trace.Logger to api.TraceLogger interface. +type traceAdapter struct{ *tracepkg.Logger } +func (a *traceAdapter) Recent(n int) []map[string]any { + events := a.Logger.Recent(n) + result := make([]map[string]any, len(events)) + for i, e := range events { + result[i] = map[string]any{ + "id": e.ID, "timestamp": e.Timestamp.Format(time.RFC3339), + "hop": e.Hop, "label": e.Label, "status": e.Status, + "duration_ms": e.DurationMs, "detail": e.Detail, "data": e.Data, + } + if result[i]["data"] == nil { result[i]["data"] = map[string]any{} } + } + return result +} + +// perfAdapter adapts perf.Monitor to api.PerfMonitor interface. +type perfAdapter struct{ *perfpkg.Monitor } +func (a *perfAdapter) Latest() map[string]any { + s := a.Monitor.Latest() + return map[string]any{ + "alloc_mb": s.AllocMB, "sys_mb": s.SysMB, + "num_gc": s.NumGC, "goroutines": s.Goroutines, "heap_objects": s.HeapObj, + } +} +func (a *perfAdapter) History() []map[string]any { + snapshots := a.Monitor.History() + result := make([]map[string]any, len(snapshots)) + for i, s := range snapshots { + result[i] = map[string]any{ + "timestamp": s.Timestamp.Format(time.RFC3339), + "alloc_mb": s.AllocMB, "sys_mb": s.SysMB, + "num_gc": s.NumGC, "goroutines": s.Goroutines, + } + } + return result +} diff --git a/backend/services/ai-core/perf/monitor.go b/backend/services/ai-core/perf/monitor.go new file mode 100644 index 0000000..5d299bc --- /dev/null +++ b/backend/services/ai-core/perf/monitor.go @@ -0,0 +1,104 @@ +// Package perf provides lightweight performance metrics sampling. +package perf + +import ( + "runtime" + "sync" + "time" +) + +// Snapshot captures a moment-in-time system performance reading. +type Snapshot struct { + Timestamp time.Time `json:"timestamp"` + AllocMB float64 `json:"alloc_mb"` + SysMB float64 `json:"sys_mb"` + NumGC uint32 `json:"num_gc"` + Goroutines int `json:"goroutines"` + HeapObj uint64 `json:"heap_objects"` +} + +// Monitor samples runtime.MemStats periodically and keeps a history. +type Monitor struct { + mu sync.RWMutex + history []Snapshot + max int + index int + stopCh chan struct{} + interval time.Duration +} + +func bytesToMB(b uint64) float64 { return float64(b) / 1024 / 1024 } + +// NewMonitor creates a performance monitor that samples every `interval`. +func NewMonitor(maxSamples int, interval time.Duration) *Monitor { + return &Monitor{ + history: make([]Snapshot, maxSamples), + max: maxSamples, + stopCh: make(chan struct{}), + interval: interval, + } +} + +// Start begins periodic sampling. +func (m *Monitor) Start() { + go func() { + ticker := time.NewTicker(m.interval) + defer ticker.Stop() + for { + select { + case <-m.stopCh: + return + case <-ticker.C: + m.sample() + } + } + }() +} + +// Stop stops sampling. +func (m *Monitor) Stop() { close(m.stopCh) } + +func (m *Monitor) sample() { + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + + s := Snapshot{ + Timestamp: time.Now(), + AllocMB: bytesToMB(ms.Alloc), + SysMB: bytesToMB(ms.Sys), + NumGC: ms.NumGC, + Goroutines: runtime.NumGoroutine(), + HeapObj: ms.HeapObjects, + } + + m.mu.Lock() + m.history[m.index%m.max] = s + m.index++ + m.mu.Unlock() +} + +// Latest returns the most recent snapshot. +func (m *Monitor) Latest() Snapshot { + m.mu.RLock() + defer m.mu.RUnlock() + if m.index == 0 { + m.sample() // Sample once on first call + } + return m.history[(m.index-1+m.max)%m.max] +} + +// History returns the sampled history. +func (m *Monitor) History() []Snapshot { + m.mu.RLock() + defer m.mu.RUnlock() + n := m.index + if n > m.max { n = m.max } + result := make([]Snapshot, n) + for i := 0; i < n; i++ { + idx := (m.index - 1 - i + m.max) % m.max + if idx < m.max { + result[n-1-i] = m.history[idx] + } + } + return result +} diff --git a/backend/services/ai-core/trace/logger.go b/backend/services/ai-core/trace/logger.go new file mode 100644 index 0000000..8a875ad --- /dev/null +++ b/backend/services/ai-core/trace/logger.go @@ -0,0 +1,82 @@ +// Package trace provides a ring-buffer for full-chain tracing events. +package trace + +import ( + "sync" + "time" +) + +// Event represents a single event in a request trace chain. +type Event struct { + ID string `json:"id"` + Timestamp time.Time `json:"timestamp"` + Hop string `json:"hop"` // "msg_received" | "intent" | "llm_call" | "tool_call" | "response" + Label string `json:"label"` + Status string `json:"status"` // "success" | "error" | "pending" + DurationMs int64 `json:"duration_ms"` + Detail string `json:"detail"` + Data map[string]any `json:"data,omitempty"` +} + +// Logger is a ring-buffer that stores the last N trace events. +type Logger struct { + mu sync.RWMutex + events []Event + max int + index int +} + +// NewLogger creates a trace logger with the given capacity. +func NewLogger(capacity int) *Logger { + return &Logger{events: make([]Event, capacity), max: capacity} +} + +// Record adds a trace event. +func (l *Logger) Record(event Event) { + l.mu.Lock() + defer l.mu.Unlock() + if event.ID == "" { + event.ID = time.Now().Format("150405") + "-" + event.Hop + } + l.events[l.index%l.max] = event + l.index++ +} + +// Recent returns the most recent N events. +func (l *Logger) Recent(n int) []Event { + l.mu.RLock() + defer l.mu.RUnlock() + + if n > l.max { n = l.max } + count := l.index + if count > l.max { count = l.max } + if n > count { n = count } + + result := make([]Event, n) + for i := 0; i < n; i++ { + idx := (l.index - 1 - i + l.max) % l.max + if idx < l.max { + result[n-1-i] = l.events[idx] + } + } + return result +} + +// Hops returns unique hop names seen in the buffer. +func (l *Logger) Hops() []string { + l.mu.RLock() + defer l.mu.RUnlock() + + seen := make(map[string]bool) + var result []string + count := l.index + if count > l.max { count = l.max } + for i := 0; i < count; i++ { + ev := l.events[i] + if !seen[ev.Hop] && ev.Hop != "" { + seen[ev.Hop] = true + result = append(result, ev.Hop) + } + } + return result +} diff --git a/backend/services/gateway/api/router.go b/backend/services/gateway/api/router.go index 9d7f3a1..feb0930 100644 --- a/backend/services/gateway/api/router.go +++ b/backend/services/gateway/api/router.go @@ -66,6 +66,17 @@ type LLMCallLogger interface { Stats() map[string]any } +// TraceLogger provides full-chain trace events. +type TraceLogger interface { + Recent(n int) []map[string]any +} + +// PerfMonitor provides performance snapshot history. +type PerfMonitor interface { + Latest() map[string]any + History() []map[string]any +} + // Dependencies holds all injectable services. type Dependencies struct { AuthMgr AuthManager @@ -74,6 +85,8 @@ type Dependencies struct { Orchestrator OrchestratorPipeline WSHub WebSocketHub LLMLogger LLMCallLogger + TraceLog TraceLogger + PerfMon PerfMonitor ModelConfig *ModelConfigStore PluginList []map[string]string } @@ -118,6 +131,8 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/v1/admin/sessions", r.authMW(r.adminMW(r.adminSessionsHandler))) r.mux.HandleFunc("/api/v1/admin/models/config", r.authMW(r.adminMW(r.modelConfigHandler))) r.mux.HandleFunc("/api/v1/admin/models/config/", r.authMW(r.adminMW(r.modelConfigItemHandler))) + r.mux.HandleFunc("/api/v1/admin/trace", r.authMW(r.adminMW(r.traceHandler))) + r.mux.HandleFunc("/api/v1/admin/perf", r.authMW(r.adminMW(r.perfHandler))) } // ServeHTTP implements http.Handler. @@ -486,6 +501,27 @@ func num(m map[string]any, key string) int { return 0 } +// --- Trace & Perf --- + +func (r *Router) traceHandler(w http.ResponseWriter, req *http.Request) { + if r.deps.TraceLog == nil { + writeJSON(w, 200, M{"events": []any{}}) + return + } + writeJSON(w, 200, M{"events": r.deps.TraceLog.Recent(100)}) +} + +func (r *Router) perfHandler(w http.ResponseWriter, req *http.Request) { + if r.deps.PerfMon == nil { + writeJSON(w, 200, M{"latest": M{}, "history": []any{}}) + return + } + writeJSON(w, 200, M{ + "latest": r.deps.PerfMon.Latest(), + "history": r.deps.PerfMon.History(), + }) +} + // --- Helpers --- type M = map[string]any diff --git a/frontend/web/src/App.tsx b/frontend/web/src/App.tsx index 768da84..305ca27 100644 --- a/frontend/web/src/App.tsx +++ b/frontend/web/src/App.tsx @@ -12,6 +12,8 @@ import LLMCallsPage from './pages/admin/LLMCallsPage' import SystemPage from './pages/admin/SystemPage' import ModelsPage from './pages/admin/ModelsPage' import BridgePage from './pages/admin/BridgePage' +import TracePage from './pages/admin/TracePage' +import PerformancePage from './pages/admin/PerformancePage' import LoginPage from './pages/LoginPage' export default function App() { @@ -42,6 +44,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> diff --git a/frontend/web/src/components/admin/AdminLayout.tsx b/frontend/web/src/components/admin/AdminLayout.tsx index fbeb131..9934fe5 100644 --- a/frontend/web/src/components/admin/AdminLayout.tsx +++ b/frontend/web/src/components/admin/AdminLayout.tsx @@ -8,6 +8,8 @@ const navItems = [ { to: '/admin/plugins', label: '插件管理', icon: '◇' }, { to: '/admin/memory', label: '记忆管理', icon: '♡' }, { to: '/admin/bridge', label: '平台桥接', icon: '↗' }, + { to: '/admin/trace', label: '链路追踪', icon: '▸' }, + { to: '/admin/perf', label: '性能监控', icon: '▴' }, { to: '/admin/llm-calls', label: 'LLM 日志', icon: '🧠' }, { to: '/admin/system', label: '系统信息', icon: '⊞' }, ] diff --git a/frontend/web/src/pages/admin/PerformancePage.tsx b/frontend/web/src/pages/admin/PerformancePage.tsx new file mode 100644 index 0000000..9cce2bf --- /dev/null +++ b/frontend/web/src/pages/admin/PerformancePage.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from 'react' +import { getToken } from '../../api/client' + +export default function PerformancePage() { + const [latest, setLatest] = useState({}) + const [history, setHistory] = useState([]) + + useEffect(() => { + fetchPerf() + const interval = setInterval(fetchPerf, 10000) + return () => clearInterval(interval) + }, []) + + const fetchPerf = async () => { + try { + const token = getToken() + const res = await fetch('/api/v1/admin/perf', { headers: { Authorization: `Bearer ${token}` } }) + const data = await res.json() + setLatest(data.latest || {}) + setHistory(data.history || []) + } catch {} + } + + const maxAlloc = Math.max(10, ...history.map((h: any) => h.alloc_mb || 0)) + + return ( +
+

性能监控

+ + {/* Current stats */} +
+ + + + +
+ + {/* Memory chart */} +
+

内存使用趋势 (最近 {history.length} 次采样)

+
+
+ {history.slice(-60).map((h: any, i: number) => { + const pct = maxAlloc > 0 ? ((h.alloc_mb || 0) / maxAlloc) * 100 : 0 + return ( +
+ ) + })} +
+
+ {history[0]?.timestamp?.slice(11, 19) || '--'} + {history[history.length-1]?.timestamp?.slice(11, 19) || '--'} +
+
+
+ + {/* GC chart */} +
+

GC 次数趋势

+
+
+ {history.slice(-60).map((h: any, i: number) => { + const maxGC = Math.max(1, ...history.map((x: any) => x.num_gc || 0)) + const pct = ((h.num_gc || 0) / maxGC) * 100 + return ( +
+ ) + })} +
+
+
+
+ ) +} + +function Stat({ label, value, sub }: { label:string; value:string|number; sub:string }) { + return ( +
+

{label}

+

{value}

+

{sub}

+
+ ) +} diff --git a/frontend/web/src/pages/admin/TracePage.tsx b/frontend/web/src/pages/admin/TracePage.tsx new file mode 100644 index 0000000..dbcc8b0 --- /dev/null +++ b/frontend/web/src/pages/admin/TracePage.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from 'react' +import { getToken } from '../../api/client' + +const hopIcons: Record = { + msg_received: '📥', msg_sent: '📤', + intent: '🎯', llm_call: '🧠', tool_call: '🔧', + response: '💬', session: '👤', subsession: '⚡', +} + +export default function TracePage() { + const [events, setEvents] = useState([]) + + useEffect(() => { + fetchTrace() + const interval = setInterval(fetchTrace, 5000) + return () => clearInterval(interval) + }, []) + + const fetchTrace = async () => { + try { + const token = getToken() + const res = await fetch('/api/v1/admin/trace', { headers: { Authorization: `Bearer ${token}` } }) + const data = await res.json() + setEvents(data.events || []) + } catch {} + } + + return ( +
+

全链路追踪

+

最近 {events.length} 条追踪事件

+ + {events.length === 0 ? ( +
暂无追踪数据 — 发送消息后刷新
+ ) : ( +
+ {events.map((ev, i) => ( +
+ {/* Hop icon */} + {hopIcons[ev.hop] || '●'} + + {/* Content */} +
+
+ {ev.label || ev.hop} + {ev.duration_ms > 0 && ( + {ev.duration_ms}ms + )} +
+ {ev.detail && ( +

{ev.detail}

+ )} +
+ + {/* Status */} +
+ + {ev.timestamp ? ev.timestamp.slice(11, 19) : ''} + + +
+
+ ))} +
+ )} +
+ ) +}