diff --git a/services/sofiia-console/app/main.py b/services/sofiia-console/app/main.py index fbfde3e1..9b764e58 100644 --- a/services/sofiia-console/app/main.py +++ b/services/sofiia-console/app/main.py @@ -57,6 +57,12 @@ from .monitor import collect_all_nodes from .ops import run_ops_action, OPS_ACTIONS from .docs_router import docs_router 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__) @@ -3134,6 +3140,7 @@ async def api_chats_list( cursor: Optional[str] = Query(None), _auth: str = Depends(require_auth), ): + SOFIIA_CURSOR_REQUESTS_TOTAL.labels(resource="chats").inc() await _ensure_chat_project() node_filter = {n.strip().upper() for n in nodes.split(",") if n.strip()} cur = _cursor_decode(cursor) @@ -3236,6 +3243,7 @@ async def api_chat_messages( cursor: Optional[str] = Query(None), _auth: str = Depends(require_auth), ): + SOFIIA_CURSOR_REQUESTS_TOTAL.labels(resource="messages").inc() cur = _cursor_decode(cursor) before_ts = str(cur.get("ts") 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: cached = _idem_get(chat_id, idem_key) if cached: + SOFIIA_IDEMPOTENCY_REPLAYS_TOTAL.inc() replay = dict(cached) replay["idempotency"] = {"replayed": True, "key": idem_key} 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) target_node = ((body.routing or {}).get("force_node_id") or info["node_id"] or "NODA2").upper() 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 session_id = body.session_id or chat_id 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 +@app.get("/metrics") +def metrics(): + data, content_type = render_metrics() + return Response(content=data, media_type=content_type) + + @app.post("/api/chat/send") async def api_chat_send(body: ChatSendBody, request: Request): """BFF chat: Ollama or router. Returns runtime contract fields. Rate: 30/min.""" diff --git a/services/sofiia-console/app/metrics.py b/services/sofiia-console/app/metrics.py new file mode 100644 index 00000000..96c7b512 --- /dev/null +++ b/services/sofiia-console/app/metrics.py @@ -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" + diff --git a/services/sofiia-console/requirements.txt b/services/sofiia-console/requirements.txt new file mode 100644 index 00000000..71ba37d6 --- /dev/null +++ b/services/sofiia-console/requirements.txt @@ -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 diff --git a/tests/test_sofiia_metrics.py b/tests/test_sofiia_metrics.py new file mode 100644 index 00000000..20663c34 --- /dev/null +++ b/tests/test_sofiia_metrics.py @@ -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 +