feat(sofiia-console): expose /metrics and add basic ops counters
Expose Prometheus-style metrics endpoint and add counters for send requests, idempotency replays, and cursor pagination calls, including a safe in-process fallback exposition when prometheus_client is unavailable. Made-with: Cursor
This commit is contained in:
@@ -57,6 +57,12 @@ from .monitor import collect_all_nodes
|
|||||||
from .ops import run_ops_action, OPS_ACTIONS
|
from .ops import run_ops_action, OPS_ACTIONS
|
||||||
from .docs_router import docs_router
|
from .docs_router import docs_router
|
||||||
from . import db as _app_db
|
from . import db as _app_db
|
||||||
|
from .metrics import (
|
||||||
|
SOFIIA_SEND_REQUESTS_TOTAL,
|
||||||
|
SOFIIA_IDEMPOTENCY_REPLAYS_TOTAL,
|
||||||
|
SOFIIA_CURSOR_REQUESTS_TOTAL,
|
||||||
|
render_metrics,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -3134,6 +3140,7 @@ async def api_chats_list(
|
|||||||
cursor: Optional[str] = Query(None),
|
cursor: Optional[str] = Query(None),
|
||||||
_auth: str = Depends(require_auth),
|
_auth: str = Depends(require_auth),
|
||||||
):
|
):
|
||||||
|
SOFIIA_CURSOR_REQUESTS_TOTAL.labels(resource="chats").inc()
|
||||||
await _ensure_chat_project()
|
await _ensure_chat_project()
|
||||||
node_filter = {n.strip().upper() for n in nodes.split(",") if n.strip()}
|
node_filter = {n.strip().upper() for n in nodes.split(",") if n.strip()}
|
||||||
cur = _cursor_decode(cursor)
|
cur = _cursor_decode(cursor)
|
||||||
@@ -3236,6 +3243,7 @@ async def api_chat_messages(
|
|||||||
cursor: Optional[str] = Query(None),
|
cursor: Optional[str] = Query(None),
|
||||||
_auth: str = Depends(require_auth),
|
_auth: str = Depends(require_auth),
|
||||||
):
|
):
|
||||||
|
SOFIIA_CURSOR_REQUESTS_TOTAL.labels(resource="messages").inc()
|
||||||
cur = _cursor_decode(cursor)
|
cur = _cursor_decode(cursor)
|
||||||
before_ts = str(cur.get("ts") or "").strip() or None
|
before_ts = str(cur.get("ts") or "").strip() or None
|
||||||
before_message_id = str(cur.get("message_id") or "").strip() or None
|
before_message_id = str(cur.get("message_id") or "").strip() or None
|
||||||
@@ -3295,6 +3303,7 @@ async def api_chat_send_v2(chat_id: str, body: ChatMessageSendBody, request: Req
|
|||||||
if idem_key:
|
if idem_key:
|
||||||
cached = _idem_get(chat_id, idem_key)
|
cached = _idem_get(chat_id, idem_key)
|
||||||
if cached:
|
if cached:
|
||||||
|
SOFIIA_IDEMPOTENCY_REPLAYS_TOTAL.inc()
|
||||||
replay = dict(cached)
|
replay = dict(cached)
|
||||||
replay["idempotency"] = {"replayed": True, "key": idem_key}
|
replay["idempotency"] = {"replayed": True, "key": idem_key}
|
||||||
return replay
|
return replay
|
||||||
@@ -3303,6 +3312,7 @@ async def api_chat_send_v2(chat_id: str, body: ChatMessageSendBody, request: Req
|
|||||||
info = _parse_chat_id(chat_id)
|
info = _parse_chat_id(chat_id)
|
||||||
target_node = ((body.routing or {}).get("force_node_id") or info["node_id"] or "NODA2").upper()
|
target_node = ((body.routing or {}).get("force_node_id") or info["node_id"] or "NODA2").upper()
|
||||||
target_agent = info["agent_id"] or "sofiia"
|
target_agent = info["agent_id"] or "sofiia"
|
||||||
|
SOFIIA_SEND_REQUESTS_TOTAL.labels(node_id=target_node).inc()
|
||||||
project_id = body.project_id or CHAT_PROJECT_ID
|
project_id = body.project_id or CHAT_PROJECT_ID
|
||||||
session_id = body.session_id or chat_id
|
session_id = body.session_id or chat_id
|
||||||
user_id = body.user_id or "console_user"
|
user_id = body.user_id or "console_user"
|
||||||
@@ -3377,6 +3387,12 @@ async def api_chat_send_v2(chat_id: str, body: ChatMessageSendBody, request: Req
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/metrics")
|
||||||
|
def metrics():
|
||||||
|
data, content_type = render_metrics()
|
||||||
|
return Response(content=data, media_type=content_type)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/chat/send")
|
@app.post("/api/chat/send")
|
||||||
async def api_chat_send(body: ChatSendBody, request: Request):
|
async def api_chat_send(body: ChatSendBody, request: Request):
|
||||||
"""BFF chat: Ollama or router. Returns runtime contract fields. Rate: 30/min."""
|
"""BFF chat: Ollama or router. Returns runtime contract fields. Rate: 30/min."""
|
||||||
|
|||||||
109
services/sofiia-console/app/metrics.py
Normal file
109
services/sofiia-console/app/metrics.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
|
try:
|
||||||
|
from prometheus_client import Counter as _PromCounter
|
||||||
|
from prometheus_client import generate_latest as _prom_generate_latest
|
||||||
|
from prometheus_client import CONTENT_TYPE_LATEST as _PROM_CONTENT_TYPE
|
||||||
|
_PROM_OK = True
|
||||||
|
except Exception:
|
||||||
|
_PROM_OK = False
|
||||||
|
|
||||||
|
|
||||||
|
if _PROM_OK:
|
||||||
|
# Total send requests, labeled by routed node_id
|
||||||
|
SOFIIA_SEND_REQUESTS_TOTAL = _PromCounter(
|
||||||
|
"sofiia_send_requests_total",
|
||||||
|
"Total number of send requests processed by sofiia-console",
|
||||||
|
["node_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Total idempotency replays (same response served again)
|
||||||
|
SOFIIA_IDEMPOTENCY_REPLAYS_TOTAL = _PromCounter(
|
||||||
|
"sofiia_idempotency_replays_total",
|
||||||
|
"Total number of idempotency replays served from cache",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Total cursor pagination requests
|
||||||
|
SOFIIA_CURSOR_REQUESTS_TOTAL = _PromCounter(
|
||||||
|
"sofiia_cursor_requests_total",
|
||||||
|
"Total number of cursor pagination requests",
|
||||||
|
["resource"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def render_metrics() -> Tuple[bytes, str]:
|
||||||
|
return _prom_generate_latest(), _PROM_CONTENT_TYPE
|
||||||
|
|
||||||
|
else:
|
||||||
|
class _FallbackCounterChild:
|
||||||
|
def __init__(self, parent: "_FallbackCounter", key: Tuple[str, ...]):
|
||||||
|
self._parent = parent
|
||||||
|
self._key = key
|
||||||
|
|
||||||
|
def inc(self, amount: float = 1.0) -> None:
|
||||||
|
self._parent._values[self._key] = self._parent._values.get(self._key, 0.0) + float(amount)
|
||||||
|
|
||||||
|
class _FallbackCounter:
|
||||||
|
def __init__(self, name: str, doc: str, labelnames: List[str] | Tuple[str, ...] | None = None):
|
||||||
|
self.name = name
|
||||||
|
self.doc = doc
|
||||||
|
self.labelnames = tuple(labelnames or [])
|
||||||
|
self._values: Dict[Tuple[str, ...], float] = {}
|
||||||
|
|
||||||
|
def labels(self, **kwargs: str) -> _FallbackCounterChild:
|
||||||
|
key = tuple(str(kwargs.get(lbl, "")) for lbl in self.labelnames)
|
||||||
|
if key not in self._values:
|
||||||
|
self._values[key] = 0.0
|
||||||
|
return _FallbackCounterChild(self, key)
|
||||||
|
|
||||||
|
def inc(self, amount: float = 1.0) -> None:
|
||||||
|
key = tuple()
|
||||||
|
self._values[key] = self._values.get(key, 0.0) + float(amount)
|
||||||
|
|
||||||
|
def _render(self) -> List[str]:
|
||||||
|
out = [f"# HELP {self.name} {self.doc}", f"# TYPE {self.name} counter"]
|
||||||
|
if not self._values:
|
||||||
|
if self.labelnames:
|
||||||
|
out.append(f"{self.name}{{}} 0.0")
|
||||||
|
else:
|
||||||
|
out.append(f"{self.name} 0.0")
|
||||||
|
return out
|
||||||
|
for key, value in self._values.items():
|
||||||
|
if self.labelnames:
|
||||||
|
labels = ",".join(
|
||||||
|
f'{lname}="{lval}"' for lname, lval in zip(self.labelnames, key)
|
||||||
|
)
|
||||||
|
out.append(f"{self.name}{{{labels}}} {value}")
|
||||||
|
else:
|
||||||
|
out.append(f"{self.name} {value}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
SOFIIA_SEND_REQUESTS_TOTAL = _FallbackCounter(
|
||||||
|
"sofiia_send_requests_total",
|
||||||
|
"Total number of send requests processed by sofiia-console",
|
||||||
|
["node_id"],
|
||||||
|
)
|
||||||
|
SOFIIA_IDEMPOTENCY_REPLAYS_TOTAL = _FallbackCounter(
|
||||||
|
"sofiia_idempotency_replays_total",
|
||||||
|
"Total number of idempotency replays served from cache",
|
||||||
|
)
|
||||||
|
SOFIIA_CURSOR_REQUESTS_TOTAL = _FallbackCounter(
|
||||||
|
"sofiia_cursor_requests_total",
|
||||||
|
"Total number of cursor pagination requests",
|
||||||
|
["resource"],
|
||||||
|
)
|
||||||
|
|
||||||
|
_ALL = [
|
||||||
|
SOFIIA_SEND_REQUESTS_TOTAL,
|
||||||
|
SOFIIA_IDEMPOTENCY_REPLAYS_TOTAL,
|
||||||
|
SOFIIA_CURSOR_REQUESTS_TOTAL,
|
||||||
|
]
|
||||||
|
|
||||||
|
def render_metrics() -> Tuple[bytes, str]:
|
||||||
|
lines: List[str] = []
|
||||||
|
for c in _ALL:
|
||||||
|
lines.extend(c._render())
|
||||||
|
text = "\n".join(lines) + "\n"
|
||||||
|
return text.encode("utf-8"), "text/plain; version=0.0.4; charset=utf-8"
|
||||||
|
|
||||||
12
services/sofiia-console/requirements.txt
Normal file
12
services/sofiia-console/requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
fastapi>=0.104.0
|
||||||
|
uvicorn[standard]>=0.24.0
|
||||||
|
httpx>=0.25.0
|
||||||
|
python-multipart>=0.0.6
|
||||||
|
pyyaml>=6.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
prometheus-client>=0.20.0
|
||||||
|
# Projects / Documents / Sessions persistence (Phase 1: SQLite)
|
||||||
|
aiosqlite>=0.20.0
|
||||||
|
# Document text extraction (optional — used when available)
|
||||||
|
pypdf>=3.0.0
|
||||||
|
python-docx>=1.1.0
|
||||||
11
tests/test_sofiia_metrics.py
Normal file
11
tests/test_sofiia_metrics.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_endpoint_exposes_sofiia_counters(sofiia_client):
|
||||||
|
r = sofiia_client.get("/metrics")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.text
|
||||||
|
assert "sofiia_send_requests_total" in body
|
||||||
|
assert "sofiia_idempotency_replays_total" in body
|
||||||
|
assert "sofiia_cursor_requests_total" in body
|
||||||
|
|
||||||
Reference in New Issue
Block a user