File size: 6,653 Bytes
37ae25d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 | """
metrics_service.py β LibBee v3.1
Fixes applied:
1. All incr / incr_bucket calls are now pure in-memory (no disk I/O per request).
Counters accumulate in _counters / _buckets dicts.
2. flush() merges in-memory state to disk atomically using a temp-file rename.
Called by a background task in app.py every 30 seconds and on shutdown.
3. threading.Lock replaced with asyncio.Lock β consistent with FastAPI's async model.
4. _read() logs a warning (with raw content) on JSON parse failure instead of
silently returning zeros, so corrupt files are visible in logs.
5. _write() uses os.replace (atomic rename) to prevent corrupt state on crash.
"""
import asyncio
import json
import logging
import os
import time
from pathlib import Path
from typing import Any, Dict
logger = logging.getLogger(__name__)
_DEFAULT_STATE: Dict[str, Any] = {
"agent_requests": 0,
"search_requests": 0,
"feedback_total": 0,
"intents": {},
"errors": {},
"summary_fallbacks": 0,
"follow_up_hits": 0,
"search_handoffs": 0,
"last_updated": 0.0,
}
_MAX_QUERY_LOG = 200 # keep last 200 queries in memory
class MetricsService:
def __init__(self, path: Path):
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = asyncio.Lock()
# In-memory accumulators β flushed to disk periodically
self._counters: Dict[str, int] = {}
self._buckets: Dict[str, Dict[str, int]] = {}
self._dirty = False
# In-memory query log (last N queries, never flushed to disk)
self._query_log: list = []
# Seed disk file if it doesn't exist
if not self.path.exists():
self._write_to_disk(dict(_DEFAULT_STATE))
# ββ Public API (sync, no I/O) ββββββββββββββββββββββββββββββββββββββββββββββ
def log_query(
self,
question: str,
intent: str = "",
tool: str = "",
model: str = "",
response_time: float = 0.0,
result_count: int = 0,
error: str = "",
) -> None:
"""Append a query record to the in-memory log (capped at _MAX_QUERY_LOG)."""
import datetime
record = {
"timestamp": datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"),
"question": (question or "")[:120],
"intent": intent or "",
"tool": tool or "",
"model": model or "",
"response_time": round(response_time, 2),
"result_count": result_count,
"error": (error or "")[:80],
}
self._query_log.append(record)
if len(self._query_log) > _MAX_QUERY_LOG:
self._query_log = self._query_log[-_MAX_QUERY_LOG:]
def recent_queries(self, limit: int = 50) -> list:
"""Return the most recent `limit` query records, newest first."""
return list(reversed(self._query_log[-limit:]))
# ββ Public API (sync, no I/O) ββββββββββββββββββββββββββββββββββββββββββββββ
def incr(self, key: str, amount: int = 1) -> None:
"""Increment a top-level counter in memory."""
self._counters[key] = self._counters.get(key, 0) + amount
self._dirty = True
def incr_bucket(self, bucket: str, key: str, amount: int = 1) -> None:
"""Increment a nested bucket counter in memory."""
if bucket not in self._buckets:
self._buckets[bucket] = {}
self._buckets[bucket][key] = self._buckets[bucket].get(key, 0) + amount
self._dirty = True
def snapshot(self) -> Dict[str, Any]:
"""Return current on-disk state merged with in-memory pending counters."""
data = self._read_from_disk()
# Merge pending in-memory counters without flushing
for k, v in self._counters.items():
data[k] = int(data.get(k, 0)) + v
for bucket, keys in self._buckets.items():
b = data.setdefault(bucket, {})
for k, v in keys.items():
b[k] = int(b.get(k, 0)) + v
return data
# ββ Async flush (called by background task + shutdown) βββββββββββββββββββββ
async def flush(self) -> None:
"""Merge in-memory counters to disk. No-op if nothing changed."""
if not self._dirty:
return
async with self._lock:
if not self._dirty:
return # double-check after acquiring lock
data = self._read_from_disk()
for k, v in self._counters.items():
data[k] = int(data.get(k, 0)) + v
for bucket, keys in self._buckets.items():
b = data.setdefault(bucket, {})
for k, v in keys.items():
b[k] = int(b.get(k, 0)) + v
self._write_to_disk(data)
self._counters.clear()
self._buckets.clear()
self._dirty = False
# ββ Internal disk helpers ββββββββββββββββββββββββββββββββββββββββββββββββββ
def _read_from_disk(self) -> Dict[str, Any]:
if not self.path.exists():
return dict(_DEFAULT_STATE)
try:
return json.loads(self.path.read_text(encoding="utf-8"))
except Exception as exc:
raw = ""
try:
raw = self.path.read_text(encoding="utf-8", errors="replace")[:200]
except Exception:
pass
logger.warning(
"MetricsService: failed to parse metrics file %s: %s | raw: %r",
self.path, exc, raw,
)
return dict(_DEFAULT_STATE)
def _write_to_disk(self, data: Dict[str, Any]) -> None:
data["last_updated"] = time.time()
tmp = self.path.with_suffix(".tmp")
try:
tmp.write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
os.replace(tmp, self.path) # atomic on POSIX / Windows
except Exception as exc:
logger.error("MetricsService: failed to write metrics: %s", exc)
try:
tmp.unlink(missing_ok=True)
except Exception:
pass
|