Spaces:
Running
Running
| """ | |
| agent-trace v2 — FORGE Telemetry Backbone | |
| Every agent sends events here. | |
| Owns: ingest, retention, aggregation, real-time dashboard. | |
| agent-learn reads rewards from here. | |
| """ | |
| import asyncio, json, os, sqlite3, time, uuid | |
| from contextlib import asynccontextmanager | |
| from pathlib import Path | |
| from typing import Optional | |
| import uvicorn | |
| from fastapi import FastAPI, HTTPException, Query, Request | |
| from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse | |
| # --------------------------------------------------------------------------- | |
| # Config | |
| # --------------------------------------------------------------------------- | |
| DB_PATH = Path(os.getenv("TRACE_DB", "/tmp/trace.db")) | |
| PORT = int(os.getenv("PORT", "7860")) | |
| INGEST_KEY = os.getenv("TRACE_KEY", "") | |
| RETAIN_DAYS = int(os.getenv("RETAIN_DAYS", "7")) | |
| VALID_TYPES = {"llm_call","tool_use","react_step","skill_load","kanban_move", | |
| "slot_event","self_reflect","reward_signal","error","custom"} | |
| # --------------------------------------------------------------------------- | |
| # Database | |
| # --------------------------------------------------------------------------- | |
| def get_db(): | |
| conn = sqlite3.connect(str(DB_PATH), check_same_thread=False) | |
| conn.row_factory = sqlite3.Row | |
| conn.execute("PRAGMA journal_mode=WAL") | |
| conn.execute("PRAGMA synchronous=NORMAL") | |
| return conn | |
| def init_db(): | |
| conn = get_db() | |
| conn.executescript(""" | |
| CREATE TABLE IF NOT EXISTS traces ( | |
| id TEXT PRIMARY KEY, | |
| agent TEXT NOT NULL DEFAULT 'unknown', | |
| session_id TEXT NOT NULL DEFAULT '', | |
| task_id TEXT NOT NULL DEFAULT '', | |
| event_type TEXT NOT NULL DEFAULT 'custom', | |
| status TEXT NOT NULL DEFAULT 'ok', | |
| latency_ms REAL, | |
| tokens_in INTEGER, | |
| tokens_out INTEGER, | |
| model TEXT, | |
| tool_name TEXT, | |
| skill_id TEXT, | |
| reward REAL, | |
| error_msg TEXT, | |
| payload TEXT NOT NULL DEFAULT '{}', | |
| tags TEXT NOT NULL DEFAULT '[]', | |
| ts REAL NOT NULL | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_tr_ts ON traces(ts DESC); | |
| CREATE INDEX IF NOT EXISTS idx_tr_agent ON traces(agent); | |
| CREATE INDEX IF NOT EXISTS idx_tr_type ON traces(event_type); | |
| CREATE INDEX IF NOT EXISTS idx_tr_sess ON traces(session_id); | |
| CREATE INDEX IF NOT EXISTS idx_tr_task ON traces(task_id); | |
| CREATE INDEX IF NOT EXISTS idx_tr_reward ON traces(reward); | |
| CREATE TABLE IF NOT EXISTS hourly_stats ( | |
| hour TEXT NOT NULL, | |
| agent TEXT NOT NULL, | |
| event_type TEXT NOT NULL, | |
| count INTEGER NOT NULL DEFAULT 0, | |
| errors INTEGER NOT NULL DEFAULT 0, | |
| total_lat REAL NOT NULL DEFAULT 0, | |
| total_tok INTEGER NOT NULL DEFAULT 0, | |
| PRIMARY KEY (hour, agent, event_type) | |
| ); | |
| """) | |
| conn.commit(); conn.close() | |
| def purge_old(): | |
| cutoff = time.time() - RETAIN_DAYS * 86400 | |
| conn = get_db() | |
| n = conn.execute("DELETE FROM traces WHERE ts < ?", (cutoff,)).rowcount | |
| conn.execute("DELETE FROM hourly_stats WHERE hour < ?", | |
| (time.strftime("%Y-%m-%dT%H", time.gmtime(cutoff)),)) | |
| conn.commit(); conn.close() | |
| return n | |
| # --------------------------------------------------------------------------- | |
| # Write | |
| # --------------------------------------------------------------------------- | |
| def ingest_trace(data: dict) -> str: | |
| event_type = str(data.get("event_type","custom")) | |
| if event_type not in VALID_TYPES: event_type = "custom" | |
| status = "error" if str(data.get("status","ok")).lower()=="error" else "ok" | |
| latency_ms = float(data["latency_ms"]) if data.get("latency_ms") is not None else None | |
| tokens_in = int(data["tokens_in"]) if data.get("tokens_in") is not None else None | |
| tokens_out = int(data["tokens_out"]) if data.get("tokens_out") is not None else None | |
| reward = float(data["reward"]) if data.get("reward") is not None else None | |
| ts = float(data.get("ts") or time.time()) | |
| trace_id = str(data.get("id") or uuid.uuid4()) | |
| payload = json.dumps(data.get("payload") or {}) | |
| tags = json.dumps(data.get("tags") or []) | |
| agent = str(data.get("agent","unknown"))[:64] | |
| conn = get_db() | |
| conn.execute(""" | |
| INSERT OR IGNORE INTO traces | |
| (id,agent,session_id,task_id,event_type,status, | |
| latency_ms,tokens_in,tokens_out,model,tool_name,skill_id, | |
| reward,error_msg,payload,tags,ts) | |
| VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", | |
| (trace_id, agent, | |
| str(data.get("session_id",""))[:128], | |
| str(data.get("task_id",""))[:128], | |
| event_type, status, latency_ms, tokens_in, tokens_out, | |
| str(data.get("model","") or "")[:128], | |
| str(data.get("tool_name","") or "")[:128], | |
| str(data.get("skill_id","") or "")[:128], | |
| reward, str(data.get("error_msg","") or "")[:1024], | |
| payload, tags, ts)) | |
| hour = time.strftime("%Y-%m-%dT%H", time.gmtime(ts)) | |
| total_tok = (tokens_in or 0) + (tokens_out or 0) | |
| conn.execute(""" | |
| INSERT INTO hourly_stats (hour,agent,event_type,count,errors,total_lat,total_tok) | |
| VALUES (?,?,?,1,?,?,?) | |
| ON CONFLICT(hour,agent,event_type) DO UPDATE SET | |
| count=count+1, errors=errors+excluded.errors, | |
| total_lat=total_lat+excluded.total_lat, | |
| total_tok=total_tok+excluded.total_tok""", | |
| (hour, agent, event_type, | |
| 1 if status=="error" else 0, | |
| latency_ms or 0, total_tok)) | |
| conn.commit(); conn.close() | |
| return trace_id | |
| def ingest_batch(events: list) -> dict: | |
| ok, errs = 0, [] | |
| for i, ev in enumerate(events): | |
| try: | |
| ingest_trace(ev); ok += 1 | |
| except Exception as e: | |
| errs.append({"index":i,"error":str(e)}) | |
| return {"ingested":ok,"errors":errs} | |
| def update_reward(trace_id: str, reward: float) -> bool: | |
| conn = get_db() | |
| n = conn.execute("UPDATE traces SET reward=? WHERE id=?", (reward, trace_id)).rowcount | |
| conn.commit(); conn.close() | |
| return n > 0 | |
| # --------------------------------------------------------------------------- | |
| # Read | |
| # --------------------------------------------------------------------------- | |
| def query_traces(agent="", event_type="", session_id="", task_id="", | |
| status="", has_reward=False, since_ts=0.0, | |
| limit=100, offset=0) -> list: | |
| conn = get_db() | |
| where, params = [], [] | |
| if agent: where.append("agent=?"); params.append(agent) | |
| if event_type: where.append("event_type=?"); params.append(event_type) | |
| if session_id: where.append("session_id=?"); params.append(session_id) | |
| if task_id: where.append("task_id=?"); params.append(task_id) | |
| if status: where.append("status=?"); params.append(status) | |
| if has_reward: where.append("reward IS NOT NULL") | |
| if since_ts: where.append("ts >= ?"); params.append(since_ts) | |
| sql = ("SELECT * FROM traces" + | |
| (f" WHERE {' AND '.join(where)}" if where else "") + | |
| " ORDER BY ts DESC LIMIT ? OFFSET ?") | |
| rows = conn.execute(sql, params+[limit,offset]).fetchall() | |
| conn.close() | |
| result = [] | |
| for r in rows: | |
| d = dict(r) | |
| for f in ("payload","tags"): | |
| try: d[f] = json.loads(d[f]) | |
| except Exception: pass | |
| result.append(d) | |
| return result | |
| def get_agents() -> list: | |
| conn = get_db() | |
| rows = conn.execute("SELECT DISTINCT agent FROM traces ORDER BY agent").fetchall() | |
| conn.close() | |
| return [r[0] for r in rows] | |
| def get_stats(window_hours=24) -> dict: | |
| conn = get_db() | |
| since = time.time() - window_hours * 3600 | |
| total = conn.execute("SELECT COUNT(*) FROM traces WHERE ts>=?",(since,)).fetchone()[0] | |
| errors = conn.execute("SELECT COUNT(*) FROM traces WHERE ts>=? AND status='error'",(since,)).fetchone()[0] | |
| by_agent = conn.execute( | |
| "SELECT agent, COUNT(*) as cnt, SUM(CASE WHEN status='error' THEN 1 ELSE 0 END) as errs " | |
| "FROM traces WHERE ts>=? GROUP BY agent ORDER BY cnt DESC",(since,)).fetchall() | |
| by_type = conn.execute( | |
| "SELECT event_type, COUNT(*) as cnt FROM traces WHERE ts>=? GROUP BY event_type ORDER BY cnt DESC",(since,)).fetchall() | |
| avg_lat = conn.execute("SELECT AVG(latency_ms) FROM traces WHERE ts>=? AND latency_ms IS NOT NULL",(since,)).fetchone()[0] | |
| total_tok = conn.execute( | |
| "SELECT SUM(COALESCE(tokens_in,0)+COALESCE(tokens_out,0)) FROM traces WHERE ts>=?",(since,)).fetchone()[0] | |
| rw = conn.execute( | |
| "SELECT COUNT(*),AVG(reward),MIN(reward),MAX(reward) FROM traces WHERE ts>=? AND reward IS NOT NULL",(since,)).fetchone() | |
| since_hour = time.strftime("%Y-%m-%dT%H", time.gmtime(since)) | |
| hourly = conn.execute( | |
| "SELECT hour, SUM(count) as total, SUM(errors) as errs " | |
| "FROM hourly_stats WHERE hour>=? GROUP BY hour ORDER BY hour",(since_hour,)).fetchall() | |
| conn.close() | |
| return { | |
| "window_hours": window_hours, | |
| "total_events": total, | |
| "error_count": errors, | |
| "error_rate": round(errors/max(total,1)*100,2), | |
| "avg_latency_ms": round(avg_lat or 0,1), | |
| "total_tokens": total_tok or 0, | |
| "by_agent": [dict(r) for r in by_agent], | |
| "by_event_type": [dict(r) for r in by_type], | |
| "reward_stats": {"count":rw[0]or 0,"avg":round(rw[1]or 0,4),"min":rw[2],"max":rw[3]}, | |
| "hourly_trend": [dict(r) for r in hourly], | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Seed demo data | |
| # --------------------------------------------------------------------------- | |
| def seed_demo(): | |
| conn = get_db() | |
| n = conn.execute("SELECT COUNT(*) FROM traces").fetchone()[0] | |
| conn.close() | |
| if n > 0: return | |
| import random; random.seed(42) | |
| now = time.time() | |
| agents = ["nexus","pulse","kanban","memory","relay"] | |
| types = list(VALID_TYPES) | |
| for _ in range(80): | |
| a = random.choice(agents) | |
| et = random.choice(types) | |
| ingest_trace({ | |
| "agent": a, | |
| "event_type": et, | |
| "session_id": f"demo-{random.randint(1,8)}", | |
| "task_id": f"task-{random.randint(1,15)}", | |
| "status": "error" if random.random()<0.08 else "ok", | |
| "latency_ms": round(random.uniform(80,3500),1) if et=="llm_call" else round(random.uniform(5,400),1), | |
| "tokens_in": random.randint(100,2000) if et=="llm_call" else None, | |
| "tokens_out": random.randint(50,800) if et=="llm_call" else None, | |
| "model": "qwen/qwen3.5-35b-a3b" if a=="nexus" else "", | |
| "tool_name": random.choice(["web_search","calculator","kanban_create","slot_reserve"]) if et=="tool_use" else "", | |
| "reward": round(random.uniform(-0.3,1.0),3) if random.random()<0.3 else None, | |
| "ts": now - random.uniform(0, 23*3600), | |
| }) | |
| # --------------------------------------------------------------------------- | |
| # MCP | |
| # --------------------------------------------------------------------------- | |
| MCP_TOOLS = [ | |
| {"name":"trace_ingest","description":"Ingest a trace event into FORGE telemetry.", | |
| "inputSchema":{"type":"object","required":["agent","event_type"], | |
| "properties":{"agent":{"type":"string"},"event_type":{"type":"string"}, | |
| "session_id":{"type":"string"},"task_id":{"type":"string"}, | |
| "status":{"type":"string"},"latency_ms":{"type":"number"}, | |
| "tokens_in":{"type":"integer"},"tokens_out":{"type":"integer"}, | |
| "model":{"type":"string"},"tool_name":{"type":"string"}, | |
| "skill_id":{"type":"string"},"error_msg":{"type":"string"}, | |
| "payload":{"type":"object"},"tags":{"type":"array"}}}}, | |
| {"name":"trace_query","description":"Query trace events with filters.", | |
| "inputSchema":{"type":"object","properties":{"agent":{"type":"string"},"event_type":{"type":"string"}, | |
| "session_id":{"type":"string"},"task_id":{"type":"string"}, | |
| "status":{"type":"string"},"since_hours":{"type":"number"}, | |
| "limit":{"type":"integer"}}}}, | |
| {"name":"trace_stats","description":"Get aggregated telemetry statistics.", | |
| "inputSchema":{"type":"object","properties":{"window_hours":{"type":"integer","default":24}}}}, | |
| {"name":"trace_agents","description":"List all agents that have sent telemetry.", | |
| "inputSchema":{"type":"object","properties":{}}}, | |
| {"name":"trace_session","description":"Get complete event timeline for a session.", | |
| "inputSchema":{"type":"object","required":["session_id"],"properties":{"session_id":{"type":"string"}}}}, | |
| {"name":"trace_reward","description":"Assign reward score to a trace event (called by agent-learn).", | |
| "inputSchema":{"type":"object","required":["trace_id","reward"], | |
| "properties":{"trace_id":{"type":"string"},"reward":{"type":"number"}}}}, | |
| ] | |
| def handle_mcp(method, params, req_id): | |
| def ok(r): return {"jsonrpc":"2.0","id":req_id,"result":r} | |
| def txt(d): return ok({"content":[{"type":"text","text":json.dumps(d)}]}) | |
| if method=="initialize": | |
| return ok({"protocolVersion":"2024-11-05","serverInfo":{"name":"agent-trace","version":"1.0.0"},"capabilities":{"tools":{}}}) | |
| if method=="tools/list": return ok({"tools":MCP_TOOLS}) | |
| if method=="tools/call": | |
| n, a = params.get("name",""), params.get("arguments",{}) | |
| if n=="trace_ingest": | |
| tid = ingest_trace(a); return txt({"ok":True,"trace_id":tid}) | |
| if n=="trace_query": | |
| ev = query_traces(agent=a.get("agent",""),event_type=a.get("event_type",""), | |
| session_id=a.get("session_id",""),task_id=a.get("task_id",""), | |
| status=a.get("status",""),since_ts=time.time()-a.get("since_hours",1)*3600, | |
| limit=min(a.get("limit",50),200)) | |
| return txt({"events":ev,"count":len(ev)}) | |
| if n=="trace_stats": return txt(get_stats(a.get("window_hours",24))) | |
| if n=="trace_agents": return txt({"agents":get_agents()}) | |
| if n=="trace_session": | |
| return txt({"session_id":a["session_id"],"events":query_traces(session_id=a["session_id"],limit=500)}) | |
| if n=="trace_reward": | |
| return txt({"ok":update_reward(a["trace_id"],float(a["reward"])),"trace_id":a["trace_id"]}) | |
| return {"jsonrpc":"2.0","id":req_id,"error":{"code":-32601,"message":f"Unknown tool: {n}"}} | |
| if method in ("notifications/initialized","notifications/cancelled"): return None | |
| return {"jsonrpc":"2.0","id":req_id,"error":{"code":-32601,"message":f"Method not found: {method}"}} | |
| # --------------------------------------------------------------------------- | |
| # App | |
| # --------------------------------------------------------------------------- | |
| async def lifespan(app): | |
| init_db(); seed_demo() | |
| asyncio.create_task(_purge_loop()) | |
| yield | |
| async def _purge_loop(): | |
| while True: | |
| await asyncio.sleep(3600); purge_old() | |
| app = FastAPI(title="agent-trace", version="1.0.0", lifespan=lifespan) | |
| def _auth(r): return not INGEST_KEY or r.headers.get("x-trace-key","") == INGEST_KEY | |
| async def api_ingest(request: Request): | |
| if not _auth(request): raise HTTPException(403,"Invalid X-Trace-Key") | |
| body = await request.json() | |
| if isinstance(body, list): return JSONResponse(ingest_batch(body)) | |
| tid = ingest_trace(body) | |
| return JSONResponse({"ok":True,"trace_id":tid}) | |
| async def api_batch(request: Request): | |
| if not _auth(request): raise HTTPException(403,"Invalid X-Trace-Key") | |
| body = await request.json() | |
| return JSONResponse(ingest_batch(body if isinstance(body,list) else [body])) | |
| async def api_query( | |
| agent:str=Query(""), event_type:str=Query(""), | |
| session_id:str=Query(""), task_id:str=Query(""), | |
| status:str=Query(""), has_reward:bool=Query(False), | |
| since_hours:float=Query(24.0), limit:int=Query(100,le=1000), offset:int=Query(0)): | |
| ev = query_traces(agent=agent, event_type=event_type, session_id=session_id, | |
| task_id=task_id, status=status, has_reward=has_reward, | |
| since_ts=time.time()-since_hours*3600, limit=limit, offset=offset) | |
| return JSONResponse({"events":ev,"count":len(ev)}) | |
| async def api_stats(window_hours:int=Query(24)): | |
| return JSONResponse(get_stats(window_hours)) | |
| async def api_agents(): return JSONResponse({"agents":get_agents()}) | |
| async def api_session(sid:str): | |
| return JSONResponse({"session_id":sid,"events":query_traces(session_id=sid,limit=500)}) | |
| async def api_reward(trace_id:str, request:Request): | |
| if not _auth(request): raise HTTPException(403,"Invalid X-Trace-Key") | |
| body = await request.json() | |
| updated = update_reward(trace_id, float(body.get("reward",0))) | |
| return JSONResponse({"ok":updated,"trace_id":trace_id}) | |
| async def api_purge(request:Request): | |
| if not _auth(request): raise HTTPException(403,"Invalid X-Trace-Key") | |
| return JSONResponse({"ok":True,"deleted":purge_old(),"retain_days":RETAIN_DAYS}) | |
| async def api_health(): | |
| conn=get_db(); n=conn.execute("SELECT COUNT(*) FROM traces").fetchone()[0]; conn.close() | |
| return JSONResponse({"ok":True,"total_traces":n,"retain_days":RETAIN_DAYS}) | |
| async def mcp_sse(request:Request): | |
| async def gen(): | |
| yield f"data: {json.dumps({'jsonrpc':'2.0','method':'connected','params':{}})}\n\n" | |
| yield f"data: {json.dumps({'jsonrpc':'2.0','method':'notifications/tools','params':{'tools':MCP_TOOLS}})}\n\n" | |
| while True: | |
| if await request.is_disconnected(): break | |
| yield ": ping\n\n"; await asyncio.sleep(15) | |
| return StreamingResponse(gen(), media_type="text/event-stream", | |
| headers={"Cache-Control":"no-cache","Connection":"keep-alive","X-Accel-Buffering":"no"}) | |
| async def mcp_rpc(request:Request): | |
| try: body = await request.json() | |
| except Exception: return JSONResponse({"jsonrpc":"2.0","id":None,"error":{"code":-32700,"message":"Parse error"}}) | |
| if isinstance(body,list): | |
| return JSONResponse([r for r in [handle_mcp(x.get("method",""),x.get("params",{}),x.get("id")) for x in body] if r]) | |
| r = handle_mcp(body.get("method",""), body.get("params",{}), body.get("id")) | |
| return JSONResponse(r or {"jsonrpc":"2.0","id":body.get("id"),"result":{}}) | |
| # --------------------------------------------------------------------------- | |
| # SPA | |
| # --------------------------------------------------------------------------- | |
| SPA = r"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>📊 TRACE — FORGE Telemetry</title> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Syne:wght@400;600;800&family=DM+Mono:wght@300;400;500&display=swap'); | |
| *{box-sizing:border-box;margin:0;padding:0} | |
| :root{--bg:#07070e;--sf:#0e0e1a;--sf2:#131320;--br:#1c1c2e;--ac:#ff6b00;--ac2:#ff9500;--tx:#dde0f0;--mu:#55557a;--gr:#00ff88;--rd:#ff4455;--cy:#06b6d4;--pu:#8b5cf6;--ye:#f59e0b} | |
| html,body{height:100%;background:var(--bg);color:var(--tx);font-family:'Syne',sans-serif;overflow:hidden} | |
| ::-webkit-scrollbar{width:5px;height:5px}::-webkit-scrollbar-track{background:var(--sf)}::-webkit-scrollbar-thumb{background:var(--br);border-radius:3px} | |
| .app{display:grid;grid-template-rows:52px 1fr;height:100vh} | |
| .hdr{display:flex;align-items:center;gap:1rem;padding:0 1.5rem;border-bottom:1px solid var(--br);background:var(--sf)} | |
| .logo{font-family:'Space Mono',monospace;font-size:1.1rem;font-weight:700;color:var(--ac)} | |
| .sub{font-family:'DM Mono',monospace;font-size:0.62rem;color:var(--mu);letter-spacing:0.2em;text-transform:uppercase} | |
| .hs{text-align:center}.hs-n{font-family:'Space Mono',monospace;font-size:1rem;font-weight:700;color:var(--ac)} | |
| .hs-l{font-family:'DM Mono',monospace;font-size:0.58rem;color:var(--mu);text-transform:uppercase;letter-spacing:.1em} | |
| .hstats{display:flex;gap:1.5rem;margin-left:1rem} | |
| .win-btn{padding:3px 10px;font-family:'DM Mono',monospace;font-size:.7rem;background:var(--sf2);border:1px solid var(--br);color:var(--mu);border-radius:4px;cursor:pointer;transition:all .15s} | |
| .win-btn.active,.win-btn:hover{border-color:var(--ac);color:var(--ac)} | |
| .dot{width:8px;height:8px;border-radius:50%;background:var(--gr);animation:pulse 2s ease-in-out infinite} | |
| @keyframes pulse{0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(0,255,136,.5)}50%{opacity:.6;box-shadow:0 0 0 6px rgba(0,255,136,0)}} | |
| .body{display:grid;grid-template-columns:1fr 320px;overflow:hidden} | |
| .left{display:flex;flex-direction:column;overflow:hidden;border-right:1px solid var(--br)} | |
| .kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:1px;border-bottom:1px solid var(--br);background:var(--br)} | |
| .kpi{background:var(--sf);padding:.65rem 1rem;text-align:center} | |
| .kpi-n{font-family:'Space Mono',monospace;font-size:1.3rem;font-weight:700;color:var(--ac);line-height:1} | |
| .kpi-l{font-family:'DM Mono',monospace;font-size:.58rem;color:var(--mu);text-transform:uppercase;letter-spacing:.1em;margin-top:3px} | |
| .charts{display:grid;grid-template-columns:1fr 1fr;gap:1px;border-bottom:1px solid var(--br);background:var(--br);height:155px} | |
| .cpanel{background:var(--sf);padding:.6rem .9rem;display:flex;flex-direction:column;overflow:hidden} | |
| .ct{font-family:'DM Mono',monospace;font-size:.62rem;color:var(--mu);text-transform:uppercase;letter-spacing:.12em;margin-bottom:.4rem} | |
| .bars{flex:1;display:flex;align-items:flex-end;gap:2px} | |
| .bl{font-family:'DM Mono',monospace;font-size:.52rem;color:var(--mu);text-align:center;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} | |
| .dw{flex:1;display:flex;gap:.6rem;align-items:center} | |
| .leg{flex:1;display:flex;flex-direction:column;gap:3px} | |
| .li{display:flex;align-items:center;gap:4px;font-family:'DM Mono',monospace;font-size:.62rem;color:var(--mu)} | |
| .ld{width:7px;height:7px;border-radius:50%;flex-shrink:0}.lv{margin-left:auto;color:var(--tx)} | |
| .feed-hdr{display:flex;align-items:center;gap:.6rem;padding:.55rem .9rem;border-bottom:1px solid var(--br)} | |
| .filt-sel{background:var(--sf2);border:1px solid var(--br);color:var(--tx);padding:2px 7px;font-family:'DM Mono',monospace;font-size:.68rem;border-radius:4px;outline:none} | |
| .filt-sel:focus{border-color:var(--ac)}.filt-sel option{background:var(--sf)} | |
| .feed{flex:1;overflow-y:auto} | |
| .erow{display:grid;grid-template-columns:65px 75px 88px 1fr 75px;gap:.4rem;align-items:center;padding:.3rem .9rem;border-bottom:1px solid #11111d;font-family:'DM Mono',monospace;font-size:.7rem;cursor:pointer;transition:background .1s} | |
| .erow:hover{background:var(--sf)}.erow.sel{background:var(--sf2);border-left:2px solid var(--ac)} | |
| .et{font-size:.6rem;padding:1px 5px;border-radius:3px;text-align:center} | |
| .type-llm_call{background:#1a1000;color:#ff9500;border:1px solid #3a2500} | |
| .type-tool_use{background:#001a0a;color:#00ff88;border:1px solid #004422} | |
| .type-react_step{background:#0a001a;color:#8b5cf6;border:1px solid #2a0066} | |
| .type-skill_load{background:#001a1a;color:#06b6d4;border:1px solid #003344} | |
| .type-kanban_move{background:#1a0a00;color:#f59e0b;border:1px solid #443000} | |
| .type-slot_event{background:#0a0a1a;color:#6366f1;border:1px solid #22246a} | |
| .type-self_reflect{background:#1a0010;color:#ec4899;border:1px solid #440033} | |
| .type-reward_signal{background:#001a08;color:#10b981;border:1px solid #004422} | |
| .type-error{background:#1a0000;color:#ff4455;border:1px solid #440011} | |
| .type-custom{background:var(--sf2);color:var(--mu);border:1px solid var(--br)} | |
| .right{display:flex;flex-direction:column;overflow:hidden} | |
| .rt{font-family:'DM Mono',monospace;font-size:.62rem;color:var(--mu);text-transform:uppercase;letter-spacing:.15em;padding:.65rem 1rem;border-bottom:1px solid var(--br)} | |
| .dp{flex:1;overflow-y:auto;padding:.7rem 1rem} | |
| .df{margin-bottom:.55rem} | |
| .dfl{font-family:'DM Mono',monospace;font-size:.6rem;color:var(--mu);text-transform:uppercase;letter-spacing:.1em;margin-bottom:2px} | |
| .dfv{font-family:'DM Mono',monospace;font-size:.75rem;word-break:break-all} | |
| pre.dj{background:#0a0a14;border:1px solid var(--br);border-radius:4px;padding:.55rem;font-size:.63rem;color:var(--gr);overflow-x:auto;white-space:pre-wrap;max-height:110px;overflow-y:auto} | |
| .empty{display:flex;align-items:center;justify-content:center;height:100%;color:var(--mu);font-family:'DM Mono',monospace;font-size:.78rem} | |
| </style></head><body> | |
| <div class="app"> | |
| <header class="hdr"> | |
| <div><div class="logo">📊 TRACE</div><div class="sub">FORGE Telemetry Backbone</div></div> | |
| <div class="hstats"> | |
| <div class="hs"><div class="hs-n" id="hT">—</div><div class="hs-l">Events</div></div> | |
| <div class="hs"><div class="hs-n" id="hE" style="color:var(--rd)">—</div><div class="hs-l">Err%</div></div> | |
| <div class="hs"><div class="hs-n" id="hL">—</div><div class="hs-l">Avg ms</div></div> | |
| <div class="hs"><div class="hs-n" id="hTk" style="color:var(--cy)">—</div><div class="hs-l">Tokens</div></div> | |
| </div> | |
| <div style="display:flex;gap:.5rem;margin-left:1.5rem"> | |
| <button class="win-btn active" id="w1" onclick="setW(1)">1h</button> | |
| <button class="win-btn" id="w6" onclick="setW(6)">6h</button> | |
| <button class="win-btn" id="w24" onclick="setW(24)">24h</button> | |
| </div> | |
| <div style="margin-left:auto"><div class="dot"></div></div> | |
| </header> | |
| <div class="body"> | |
| <div class="left"> | |
| <div class="kpis" id="kpis"></div> | |
| <div class="charts"> | |
| <div class="cpanel"><div class="ct">Hourly trend</div><div class="bars" id="bars"></div></div> | |
| <div class="cpanel"> | |
| <div class="ct">By event type</div> | |
| <div class="dw"> | |
| <svg width="80" height="80" viewBox="0 0 36 36" id="donut"></svg> | |
| <div class="leg" id="leg"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="feed-hdr"> | |
| <span style="font-family:'DM Mono',monospace;font-size:.65rem;color:var(--mu);text-transform:uppercase;letter-spacing:.15em">Stream</span> | |
| <div style="display:flex;gap:.4rem;margin-left:auto"> | |
| <select class="filt-sel" id="fA" onchange="loadFeed()"> | |
| <option value="">all agents</option> | |
| </select> | |
| <select class="filt-sel" id="fT" onchange="loadFeed()"> | |
| <option value="">all types</option> | |
| <option>llm_call</option><option>tool_use</option><option>react_step</option> | |
| <option>skill_load</option><option>kanban_move</option><option>slot_event</option> | |
| <option>self_reflect</option><option>reward_signal</option><option>error</option><option>custom</option> | |
| </select> | |
| <select class="filt-sel" id="fS" onchange="loadFeed()"> | |
| <option value="">all</option><option value="ok">ok</option><option value="error">error</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="feed" id="feed"><div class="empty">Loading...</div></div> | |
| </div> | |
| <div class="right"> | |
| <div class="rt" id="rtitle">Select an event</div> | |
| <div class="dp" id="dp"><div class="empty" style="height:200px">← Click event to inspect</div></div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const TC={llm_call:'#ff9500',tool_use:'#00ff88',react_step:'#8b5cf6',skill_load:'#06b6d4',kanban_move:'#f59e0b',slot_event:'#6366f1',self_reflect:'#ec4899',reward_signal:'#10b981',error:'#ff4455',custom:'#55557a'}; | |
| let W=1,events=[],selId=null; | |
| function setW(h){W=h;['w1','w6','w24'].forEach(i=>document.getElementById(i).classList.remove('active'));document.getElementById('w'+h).classList.add('active');loadAll()} | |
| async function loadAll(){await Promise.all([loadStats(),loadFeed()])} | |
| async function loadStats(){ | |
| const s=await(await fetch('/api/stats?window_hours='+W)).json(); | |
| document.getElementById('hT').textContent=fmt(s.total_events); | |
| document.getElementById('hE').textContent=s.error_rate+'%'; | |
| document.getElementById('hL').textContent=s.avg_latency_ms; | |
| document.getElementById('hTk').textContent=fmtk(s.total_tokens); | |
| // KPIs | |
| const ba={};(s.by_agent||[]).forEach(a=>ba[a.agent]=a.cnt); | |
| const aa=['nexus','pulse','kanban','memory']; | |
| let other=0;Object.entries(ba).forEach(([a,c])=>{if(!aa.includes(a))other+=c}); | |
| document.getElementById('kpis').innerHTML=aa.map(a=>`<div class="kpi"><div class="kpi-n">${fmt(ba[a]||0)}</div><div class="kpi-l">${a}</div></div>`).join('')+`<div class="kpi"><div class="kpi-n" style="color:var(--mu)">${fmt(other)}</div><div class="kpi-l">other</div></div>`; | |
| // Agent filter | |
| const sel=document.getElementById('fA'),cur=sel.value; | |
| sel.innerHTML='<option value="">all agents</option>'; | |
| (s.by_agent||[]).forEach(a=>{const o=document.createElement('option');o.value=a.agent;o.textContent=a.agent;if(a.agent===cur)o.selected=true;sel.appendChild(o)}); | |
| // Bars | |
| const trend=s.hourly_trend||[],max=Math.max(...trend.map(r=>r.total||0),1); | |
| const slots=[];for(let i=W-1;i>=0;i--){const d=new Date(Date.now()-i*3600000),k=d.toISOString().slice(0,13),f=trend.find(r=>r.hour===k);slots.push({h:d.getHours()+'h',n:f?f.total:0,e:f?f.errs:0})} | |
| document.getElementById('bars').innerHTML=slots.map(s=>{const pct=Math.max(3,Math.round(s.n/max*100)),ep=s.n>0?Math.round(s.e/s.n*pct):0;return`<div style="flex:1;display:flex;flex-direction:column;align-items:center"><div style="width:100%;flex:1;display:flex;flex-direction:column;justify-content:flex-end"><div style="height:${pct}%;background:var(--ac);border-radius:2px 2px 0 0;min-height:2px;position:relative" title="${s.n} events">${ep>0?`<div style="height:${ep}px;max-height:100%;background:var(--rd);position:absolute;bottom:0;left:0;right:0;border-radius:2px 2px 0 0"></div>`:''}</div></div><div class="bl">${s.h}</div></div>`}).join(''); | |
| // Donut | |
| const bt=s.by_event_type||[],tot=bt.reduce((s,d)=>s+(d.cnt||0),0)||1; | |
| const top=bt.slice(0,6);let off=0;const cx=18,cy=18,r=15.9,c=2*Math.PI*r; | |
| let paths='<circle cx="18" cy="18" r="15.9" fill="none" stroke="#1c1c2e" stroke-width="3.5"/>'; | |
| top.forEach(d=>{const p=d.cnt/tot,dash=p*c,col=TC[d.event_type]||'#555';paths+=`<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${col}" stroke-width="3.5" stroke-dasharray="${dash} ${c-dash}" stroke-dashoffset="${-off*c}" transform="rotate(-90 18 18)"/>`;off+=p}); | |
| document.getElementById('donut').innerHTML=paths; | |
| document.getElementById('leg').innerHTML=top.slice(0,5).map(d=>`<div class="li"><span class="ld" style="background:${TC[d.event_type]||'#555'}"></span><span>${d.event_type}</span><span class="lv">${d.cnt}</span></div>`).join(''); | |
| } | |
| async function loadFeed(){ | |
| const p=new URLSearchParams({since_hours:W,limit:120}); | |
| ['fA','fT','fS'].forEach((id,i)=>{const v=document.getElementById(id).value,k=['agent','event_type','status'][i];if(v)p.set(k,v)}); | |
| events=(await(await fetch('/api/traces?'+p)).json()).events||[]; | |
| renderFeed(); | |
| } | |
| function renderFeed(){ | |
| const f=document.getElementById('feed'); | |
| if(!events.length){f.innerHTML='<div class="empty">No events in window</div>';return} | |
| f.innerHTML=events.map(e=>{const t=new Date(e.ts*1000).toTimeString().slice(0,8),lat=e.latency_ms?Math.round(e.latency_ms)+'ms':'',prev=e.error_msg||e.tool_name||e.skill_id||e.model||'';return`<div class="erow${e.id===selId?' sel':''}" onclick="selectEv('${e.id}')"><span style="color:var(--mu);font-size:.63rem">${t}</span><span style="color:var(--ac);font-weight:700">${e.agent}</span><span class="et type-${e.event_type}">${e.event_type}</span><span style="color:var(--mu);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(prev)}</span><span style="text-align:right;color:${e.status==='error'?'var(--rd)':'var(--mu)'}">${lat||e.status}</span></div>`}).join(''); | |
| } | |
| function selectEv(id){ | |
| selId=id;renderFeed(); | |
| const e=events.find(x=>x.id===id);if(!e)return; | |
| document.getElementById('rtitle').textContent=e.event_type+' — '+e.agent; | |
| const fields=[['ID',e.id],['Agent',e.agent],['Type',e.event_type],['Status',`<span style="color:${e.status==='error'?'var(--rd)':'var(--gr)'}">${e.status}</span>`],['Time',new Date(e.ts*1000).toISOString()],['Session',e.session_id||'—'],['Task',e.task_id||'—'],['Latency',e.latency_ms?e.latency_ms+'ms':'—'],['Model',e.model||'—'],['Tool',e.tool_name||'—'],['Skill',e.skill_id||'—'],['Tokens',e.tokens_in||e.tokens_out?`${e.tokens_in||0} in / ${e.tokens_out||0} out`:'—'],['Reward',e.reward!=null?e.reward:'—'],['Error',e.error_msg||'—']]; | |
| const fh=fields.filter(([,v])=>v&&v!=='—').map(([l,v])=>`<div class="df"><div class="dfl">${l}</div><div class="dfv">${v}</div></div>`).join(''); | |
| let ph='';try{const p=typeof e.payload==='string'?JSON.parse(e.payload):e.payload;if(p&&Object.keys(p).length)ph=`<div class="df"><div class="dfl">Payload</div><pre class="dj">${esc(JSON.stringify(p,null,2))}</pre></div>`}catch(x){} | |
| const rw=e.reward!=null?`<div style="margin-top:.4rem;padding:.4rem;background:#001f08;border:1px solid var(--gr);border-radius:5px;font-family:'DM Mono',monospace;font-size:.72rem;color:var(--gr);text-align:center">🏆 Reward: <strong>${e.reward}</strong></div>`:''; | |
| document.getElementById('dp').innerHTML=fh+ph+rw; | |
| } | |
| function fmt(n){return n>=1000?(n/1000).toFixed(1)+'k':String(n||0)} | |
| function fmtk(n){return n>=1000000?(n/1000000).toFixed(1)+'M':n>=1000?(n/1000).toFixed(0)+'k':String(n||0)} | |
| function esc(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')} | |
| loadAll();setInterval(loadAll,5000); | |
| </script></body></html>""" | |
| async def root(): return HTMLResponse(content=SPA, media_type="text/html; charset=utf-8") | |
| if __name__ == "__main__": | |
| uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="info") | |