Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.

Commit e8f065c

Browse files
committed
fix live logs and ml analytics
1 parent a68bc28 commit e8f065c

5 files changed

Lines changed: 155 additions & 28 deletions

File tree

‎admin/app.js‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,11 +123,21 @@ async function loadLLM() {
123123
document.getElementById('card-output-tokens').textContent = formatNum(tu.total_output || 0);
124124
document.getElementById('card-llm-calls').textContent = formatNum(tu.call_count || 0);
125125

126-
// LLM stats table
126+
// Total cost card
127+
const totalCost = tu.total_cost_usd || 0;
128+
document.getElementById('card-total-cost').textContent = formatCost(totalCost);
129+
130+
// Cost breakdown table
131+
const costRows = (data.cost_by_model_7d || []).map(r =>
132+
`<tr><td style="font-family:'JetBrains Mono',monospace;font-size:11px">${r._id||'unknown'}</td><td>${r.call_count||0}</td><td>${formatNum(r.total_input||0)}</td><td>${formatNum(r.total_output||0)}</td><td style="color:#4fc3f7;font-weight:600">${formatCost(r.cost_usd||0)}</td></tr>`
133+
).join('');
134+
document.getElementById('cost-breakdown-body').innerHTML = costRows || '<tr><td colspan="5" style="color:var(--text2)">No cost data yet</td></tr>';
135+
136+
// LLM stats table (with cost column)
127137
const rows = (data.llm_stats_24h || []).map(r =>
128-
`<tr><td>${r._id?.provider||''}</td><td style="font-family:'JetBrains Mono',monospace;font-size:11px">${r._id?.model||''}</td><td>${r._id?.agent||''}</td><td>${r.count}</td><td>${formatNum(r.total_tokens||0)}</td><td>${Math.round(r.avg_latency||0)}ms</td><td>${r.errors||0}</td></tr>`
138+
`<tr><td>${r._id?.provider||''}</td><td style="font-family:'JetBrains Mono',monospace;font-size:11px">${r._id?.model||''}</td><td>${r._id?.agent||''}</td><td>${r.count}</td><td>${formatNum(r.total_tokens||0)}</td><td style="color:#4fc3f7">${formatCost(r.cost_usd||0)}</td><td>${Math.round(r.avg_latency||0)}ms</td><td>${r.errors||0}</td></tr>`
129139
).join('');
130-
document.getElementById('llm-stats-body').innerHTML = rows || '<tr><td colspan="7" style="color:var(--text2)">No LLM calls recorded yet</td></tr>';
140+
document.getElementById('llm-stats-body').innerHTML = rows || '<tr><td colspan="8" style="color:var(--text2)">No LLM calls recorded yet</td></tr>';
131141

132142
// Daily LLM chart
133143
renderDailyLLMChart(data.daily_llm_calls || []);
@@ -507,6 +517,13 @@ function formatNum(n) {
507517
return String(n);
508518
}
509519

520+
function formatCost(usd) {
521+
if (usd === 0 || usd === undefined) return '$0.00';
522+
if (usd < 0.01) return '$' + usd.toFixed(4);
523+
if (usd < 1) return '$' + usd.toFixed(3);
524+
return '$' + usd.toFixed(2);
525+
}
526+
510527
function escHtml(s) {
511528
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
512529
}

‎admin/index.html‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,16 +109,35 @@ <h2 style="margin-bottom:20px">LLM Analytics</h2>
109109
<div class="card blue"><div class="label">Output Tokens (7d)</div><div class="value" id="card-output-tokens">—</div></div>
110110
<div class="card orange"><div class="label">LLM Calls (7d)</div><div class="value" id="card-llm-calls">—</div></div>
111111
</div>
112+
113+
<!-- Cost Summary -->
114+
<div class="cards" style="margin-top:8px">
115+
<div class="card" style="background:linear-gradient(135deg,#1e3a5f 0%,#0d1b2a 100%);border:1px solid #2196f3">
116+
<div class="label" style="color:#90caf9">Estimated Cost (7d)</div>
117+
<div class="value" style="color:#4fc3f7;font-size:28px" id="card-total-cost">$0.00</div>
118+
</div>
119+
</div>
120+
112121
<div class="row">
113122
<div class="panel">
114123
<h3>Daily LLM Usage</h3>
115124
<div class="chart-wrap"><canvas id="chart-daily-llm"></canvas></div>
116125
</div>
117126
<div class="panel">
127+
<h3>Cost Breakdown by Model (7d)</h3>
128+
<table class="tbl">
129+
<thead><tr><th>Model</th><th>Calls</th><th>Input Tokens</th><th>Output Tokens</th><th>Cost (USD)</th></tr></thead>
130+
<tbody id="cost-breakdown-body"><tr><td colspan="5" style="color:var(--text2)">Loading...</td></tr></tbody>
131+
</table>
132+
</div>
133+
</div>
134+
135+
<div class="row" style="margin-top:16px">
136+
<div class="panel" style="flex:1">
118137
<h3>LLM Calls by Agent (24h)</h3>
119138
<table class="tbl">
120-
<thead><tr><th>Provider</th><th>Model</th><th>Agent</th><th>Calls</th><th>Tokens</th><th>Avg Latency</th><th>Errors</th></tr></thead>
121-
<tbody id="llm-stats-body"><tr><td colspan="7" style="color:var(--text2)">Loading...</td></tr></tbody>
139+
<thead><tr><th>Provider</th><th>Model</th><th>Agent</th><th>Calls</th><th>Tokens</th><th>Cost</th><th>Avg Latency</th><th>Errors</th></tr></thead>
140+
<tbody id="llm-stats-body"><tr><td colspan="8" style="color:var(--text2)">Loading...</td></tr></tbody>
122141
</table>
123142
</div>
124143
</div>

‎src/api/app.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ def create_app() -> FastAPI:
9292
@asynccontextmanager
9393
async def lifespan(app: FastAPI):
9494
set_startup_time(time.time())
95+
96+
# Capture the event loop for the WebSocket log handler
97+
from src.api.routes.admin import _set_event_loop
98+
_set_event_loop(asyncio.get_running_loop())
99+
95100
boot_task = asyncio.create_task(_boot_pipelines())
96101
yield
97102
await boot_task

‎src/api/routes/admin.py‎

Lines changed: 89 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,13 @@ async def admin_logout(request: Request):
142142
_log_counter = itertools.count()
143143
_log_buffer: deque[Dict[str, Any]] = deque(maxlen=500)
144144
_ws_clients: List[WebSocket] = []
145+
_event_loop: Optional[asyncio.AbstractEventLoop] = None
146+
147+
148+
def _set_event_loop(loop: asyncio.AbstractEventLoop) -> None:
149+
"""Store the main event loop reference for cross-thread access."""
150+
global _event_loop
151+
_event_loop = loop
145152

146153

147154
class WebSocketLogHandler(logging.Handler):
@@ -160,13 +167,17 @@ def emit(self, record: logging.LogRecord) -> None:
160167

161168
_log_buffer.append(entry)
162169

163-
# Broadcast to all connected WebSocket clients
164-
# Use call_soon_threadsafe to safely schedule from any thread
170+
loop = _event_loop
171+
if loop is None or loop.is_closed():
172+
return
173+
165174
for ws in list(_ws_clients):
166175
try:
167-
loop = asyncio.get_event_loop()
168-
loop.call_soon_threadsafe(lambda w=ws, e=entry: asyncio.create_task(_send_log_safe(w, e)))
169-
except Exception:
176+
loop.call_soon_threadsafe(
177+
loop.create_task,
178+
_send_log_safe(ws, entry),
179+
)
180+
except RuntimeError:
170181
pass
171182

172183

@@ -175,19 +186,16 @@ async def _send_log_safe(websocket: WebSocket, entry: Dict[str, Any]) -> None:
175186
try:
176187
await websocket.send_json(entry)
177188
except Exception:
178-
# Client disconnected or other error — ignore
179189
pass
180190

181191

182192
# Install the handler on the root logger so ALL logs are captured
183193
_ws_log_handler = WebSocketLogHandler()
184194
_ws_log_handler.setLevel(logging.INFO)
185195
logging.getLogger().addHandler(_ws_log_handler)
186-
# Also capture xmem-specific loggers
187196
logging.getLogger("xmem").addHandler(_ws_log_handler)
188197
logging.getLogger("src").addHandler(_ws_log_handler)
189198
logging.getLogger("uvicorn").addHandler(_ws_log_handler)
190-
# Explicitly capture AWS boto3 logs at INFO level
191199
logging.getLogger("boto3").setLevel(logging.INFO)
192200
logging.getLogger("botocore").setLevel(logging.INFO)
193201
logging.getLogger("boto3").addHandler(_ws_log_handler)
@@ -199,6 +207,9 @@ async def ws_live_logs(websocket: WebSocket):
199207
"""WebSocket endpoint for live log streaming."""
200208
await websocket.accept()
201209

210+
# Capture the running event loop so the log handler can broadcast
211+
_set_event_loop(asyncio.get_running_loop())
212+
202213
# Validate auth token from query param
203214
token = websocket.query_params.get("token", "")
204215
if token not in _admin_sessions:
@@ -208,27 +219,22 @@ async def ws_live_logs(websocket: WebSocket):
208219
_ws_clients.append(websocket)
209220

210221
try:
211-
last_id = -1
212222
# Send buffered logs first
213223
for entry in list(_log_buffer):
214224
await websocket.send_json(entry)
215-
last_id = entry.get("id", -1)
216225

217-
# Keep alive — wait for disconnect or new logs
226+
# Keep alive — the WebSocketLogHandler.emit() pushes new logs
227+
# via call_soon_threadsafe. We just need to keep the connection
228+
# open by waiting for client messages (or disconnect).
218229
while True:
219-
# Send any new logs that arrived in the buffer
220-
for entry in list(_log_buffer):
221-
entry_id = entry.get("id", -1)
222-
if entry_id > last_id:
223-
await websocket.send_json(entry)
224-
last_id = entry_id
225-
226230
try:
227-
# Sleep briefly, check if client disconnected
228-
await asyncio.wait_for(websocket.receive_text(), timeout=0.5)
231+
await asyncio.wait_for(websocket.receive_text(), timeout=30)
229232
except asyncio.TimeoutError:
230-
# Send ping to keep connection alive occasionally
231-
pass
233+
# Send a lightweight ping to detect broken connections
234+
try:
235+
await websocket.send_json({"type": "ping"})
236+
except Exception:
237+
break
232238
except WebSocketDisconnect:
233239
pass
234240
except Exception:
@@ -238,6 +244,33 @@ async def ws_live_logs(websocket: WebSocket):
238244
_ws_clients.remove(websocket)
239245

240246

247+
# ═══════════════════════════════════════════════════════════════════════════
248+
# Gemini pricing (paid tier, per 1M tokens in USD)
249+
# ═══════════════════════════════════════════════════════════════════════════
250+
COST_TABLE: Dict[str, Dict[str, float]] = {
251+
"gemini-embedding-001": {"input": 0.15, "output": 0.00},
252+
"gemini-2.5-flash-lite": {"input": 0.10, "output": 0.40},
253+
"gemini-2.5-flash": {"input": 0.15, "output": 0.60},
254+
"gemini-2.0-flash": {"input": 0.10, "output": 0.40},
255+
"gemini-2.0-flash-lite": {"input": 0.075, "output": 0.30},
256+
}
257+
_PER_M = 1_000_000
258+
259+
260+
def _estimate_cost(model: str, input_tokens: int, output_tokens: int) -> float:
261+
"""Estimate USD cost for a given model and token counts."""
262+
key = model.lower().strip()
263+
prices = COST_TABLE.get(key)
264+
if prices is None:
265+
for prefix, p in COST_TABLE.items():
266+
if key.startswith(prefix):
267+
prices = p
268+
break
269+
if prices is None:
270+
return 0.0
271+
return (input_tokens * prices["input"] + output_tokens * prices["output"]) / _PER_M
272+
273+
241274
# ═══════════════════════════════════════════════════════════════════════════
242275
# Analytics summary API
243276
# ═══════════════════════════════════════════════════════════════════════════
@@ -284,6 +317,15 @@ async def analytics_summary(request: Request, user: dict = Depends(_verify_admin
284317
{"$sort": {"count": -1}},
285318
]))
286319

320+
# Compute cost for each LLM stats row
321+
for row in llm_stats_24h:
322+
model_name = (row.get("_id") or {}).get("model", "")
323+
row["cost_usd"] = round(_estimate_cost(
324+
model_name,
325+
row.get("total_input_tokens", 0),
326+
row.get("total_output_tokens", 0),
327+
), 6)
328+
287329
# Hourly request volume (last 24h)
288330
hourly_volume = list(collection.aggregate([
289331
{"$match": {"event": "api_call", "ts": {"$gte": last_24h}}},
@@ -303,7 +345,7 @@ async def analytics_summary(request: Request, user: dict = Depends(_verify_admin
303345
"event": "api_call", "ts": {"$gte": last_24h}, "user_id": {"$ne": ""},
304346
})
305347

306-
# Total token usage (last 7d, last 30d)
348+
# Total token usage (last 7d)
307349
token_usage_7d = list(collection.aggregate([
308350
{"$match": {"event": "llm_call", "ts": {"$gte": last_7d}}},
309351
{"$group": {
@@ -315,6 +357,28 @@ async def analytics_summary(request: Request, user: dict = Depends(_verify_admin
315357
}},
316358
]))
317359

360+
# Per-model cost breakdown (last 7d)
361+
cost_by_model_7d = list(collection.aggregate([
362+
{"$match": {"event": "llm_call", "ts": {"$gte": last_7d}}},
363+
{"$group": {
364+
"_id": "$model",
365+
"total_input": {"$sum": "$input_tokens"},
366+
"total_output": {"$sum": "$output_tokens"},
367+
"call_count": {"$sum": 1},
368+
}},
369+
{"$sort": {"total_input": -1}},
370+
]))
371+
372+
total_cost_7d = 0.0
373+
for row in cost_by_model_7d:
374+
model_name = row.get("_id") or ""
375+
cost = _estimate_cost(model_name, row.get("total_input", 0), row.get("total_output", 0))
376+
row["cost_usd"] = round(cost, 6)
377+
total_cost_7d += cost
378+
379+
if token_usage_7d:
380+
token_usage_7d[0]["total_cost_usd"] = round(total_cost_7d, 6)
381+
318382
# Daily LLM calls (last 7d) for chart
319383
daily_llm = list(collection.aggregate([
320384
{"$match": {"event": "llm_call", "ts": {"$gte": last_7d}}},
@@ -337,6 +401,8 @@ async def analytics_summary(request: Request, user: dict = Depends(_verify_admin
337401
"unique_users_24h": len(unique_users),
338402
"token_usage_7d": _bson_safe(token_usage_7d[0] if token_usage_7d else {}),
339403
"daily_llm_calls": _bson_safe(daily_llm),
404+
"cost_by_model_7d": _bson_safe(cost_by_model_7d),
405+
"cost_table": COST_TABLE,
340406
})
341407
except Exception as exc:
342408
logger.exception("Analytics summary failed")

‎src/pipelines/ingest.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,13 +152,33 @@ def embed_text(text: str) -> tuple[float, ...]:
152152

153153

154154
def _embed_text_gemini(text: str) -> tuple[float, ...]:
155+
import time as _time
155156
client = get_embedding_client()
157+
start = _time.perf_counter()
156158
result = client.models.embed_content(
157159
model=settings.embedding_model,
158160
contents=text,
159161
config=types.EmbedContentConfig(output_dimensionality=settings.pinecone_dimension),
160162
)
163+
elapsed = _time.perf_counter() - start
161164
[embedding_obj] = result.embeddings
165+
166+
# Track embedding call for cost analytics
167+
input_tokens = getattr(result, "input_tokens", 0) or len(text.split())
168+
try:
169+
from src.config.analytics import analytics
170+
analytics.track_llm_call(
171+
provider="gemini",
172+
model=settings.embedding_model,
173+
agent="embedding",
174+
latency_ms=round(elapsed * 1000, 2),
175+
input_tokens=input_tokens,
176+
output_tokens=0,
177+
total_tokens=input_tokens,
178+
)
179+
except Exception:
180+
pass
181+
162182
return tuple(embedding_obj.values)
163183

164184

0 commit comments

Comments
 (0)