feat(sofiia-console): add structured json logging for chat ops
Made-with: Cursor
This commit is contained in:
49
services/sofiia-console/app/logging.py
Normal file
49
services/sofiia-console/app/logging.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
_LOGGER_NAME = "sofiia"
|
||||||
|
_JSON_ENABLED = os.getenv("SOFIIA_LOG_JSON", "1").strip().lower() not in {"0", "false", "no", "off"}
|
||||||
|
_LOG_LEVEL_NAME = os.getenv("SOFIIA_LOG_LEVEL", "INFO").strip().upper()
|
||||||
|
_LOG_LEVEL = getattr(logging, _LOG_LEVEL_NAME, logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_sofiia_logger() -> logging.Logger:
|
||||||
|
logger = logging.getLogger(_LOGGER_NAME)
|
||||||
|
logger.setLevel(_LOG_LEVEL)
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def hash_idempotency_key(key: str) -> str:
|
||||||
|
cleaned = (key or "").strip()
|
||||||
|
if not cleaned:
|
||||||
|
return ""
|
||||||
|
return hashlib.sha256(cleaned.encode("utf-8")).hexdigest()[:12]
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_id(request: Any) -> str:
|
||||||
|
headers = getattr(request, "headers", None)
|
||||||
|
req_id = ""
|
||||||
|
if headers is not None:
|
||||||
|
req_id = (headers.get("X-Request-Id") or "").strip()
|
||||||
|
return req_id[:64] if req_id else uuid.uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
|
def log_event(event: str, **fields: Any) -> None:
|
||||||
|
logger = logging.getLogger(_LOGGER_NAME)
|
||||||
|
payload = {
|
||||||
|
"event": event,
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
payload.update({k: v for k, v in fields.items() if v is not None})
|
||||||
|
if _JSON_ENABLED:
|
||||||
|
logger.info(json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str))
|
||||||
|
else:
|
||||||
|
logger.info("%s %s", event, payload)
|
||||||
@@ -64,8 +64,15 @@ from .metrics import (
|
|||||||
render_metrics,
|
render_metrics,
|
||||||
)
|
)
|
||||||
from .idempotency import get_idempotency_store, ReplayEntry
|
from .idempotency import get_idempotency_store, ReplayEntry
|
||||||
|
from .logging import (
|
||||||
|
configure_sofiia_logger,
|
||||||
|
get_request_id,
|
||||||
|
hash_idempotency_key,
|
||||||
|
log_event,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
configure_sofiia_logger()
|
||||||
|
|
||||||
# ── Build info ────────────────────────────────────────────────────────────────
|
# ── Build info ────────────────────────────────────────────────────────────────
|
||||||
_VERSION = "0.4.0"
|
_VERSION = "0.4.0"
|
||||||
@@ -3106,6 +3113,7 @@ def _cursor_decode(cursor: Optional[str]) -> Dict[str, Any]:
|
|||||||
|
|
||||||
@app.get("/api/chats")
|
@app.get("/api/chats")
|
||||||
async def api_chats_list(
|
async def api_chats_list(
|
||||||
|
request: Request,
|
||||||
nodes: str = Query("NODA1,NODA2"),
|
nodes: str = Query("NODA1,NODA2"),
|
||||||
agent_id: Optional[str] = Query(None),
|
agent_id: Optional[str] = Query(None),
|
||||||
q: Optional[str] = Query(None),
|
q: Optional[str] = Query(None),
|
||||||
@@ -3184,6 +3192,17 @@ async def api_chats_list(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
has_more = len(sessions) >= fetch_limit or len(items) >= limit
|
has_more = len(sessions) >= fetch_limit or len(items) >= limit
|
||||||
|
log_event(
|
||||||
|
"chat.list",
|
||||||
|
request_id=get_request_id(request),
|
||||||
|
node_id=",".join(sorted(node_filter)) if node_filter else None,
|
||||||
|
agent_id=(agent_id or None),
|
||||||
|
cursor_present=bool(cursor),
|
||||||
|
limit=limit,
|
||||||
|
has_more=has_more,
|
||||||
|
next_cursor_present=bool(next_cursor),
|
||||||
|
status="ok",
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"items": items,
|
"items": items,
|
||||||
"count": len(items),
|
"count": len(items),
|
||||||
@@ -3212,6 +3231,7 @@ async def api_chat_create(body: ChatCreateBody, _auth: str = Depends(require_aut
|
|||||||
@app.get("/api/chats/{chat_id}/messages")
|
@app.get("/api/chats/{chat_id}/messages")
|
||||||
async def api_chat_messages(
|
async def api_chat_messages(
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
|
request: Request,
|
||||||
limit: int = Query(100, ge=1, le=500),
|
limit: int = Query(100, ge=1, le=500),
|
||||||
cursor: Optional[str] = Query(None),
|
cursor: Optional[str] = Query(None),
|
||||||
_auth: str = Depends(require_auth),
|
_auth: str = Depends(require_auth),
|
||||||
@@ -3249,6 +3269,18 @@ async def api_chat_messages(
|
|||||||
if has_more and page_desc:
|
if has_more and page_desc:
|
||||||
tail = page_desc[-1]
|
tail = page_desc[-1]
|
||||||
next_cursor = _cursor_encode({"ts": tail.get("ts"), "message_id": tail.get("msg_id")})
|
next_cursor = _cursor_encode({"ts": tail.get("ts"), "message_id": tail.get("msg_id")})
|
||||||
|
log_event(
|
||||||
|
"chat.messages.list",
|
||||||
|
request_id=get_request_id(request),
|
||||||
|
chat_id=chat_id,
|
||||||
|
node_id=info["node_id"],
|
||||||
|
agent_id=info["agent_id"],
|
||||||
|
cursor_present=bool(cursor),
|
||||||
|
limit=limit,
|
||||||
|
has_more=has_more,
|
||||||
|
next_cursor_present=bool(next_cursor),
|
||||||
|
status="ok",
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"items": messages,
|
"items": messages,
|
||||||
"count": len(messages),
|
"count": len(messages),
|
||||||
@@ -3260,12 +3292,14 @@ async def api_chat_messages(
|
|||||||
|
|
||||||
@app.post("/api/chats/{chat_id}/send")
|
@app.post("/api/chats/{chat_id}/send")
|
||||||
async def api_chat_send_v2(chat_id: str, body: ChatMessageSendBody, request: Request, _auth: str = Depends(require_auth)):
|
async def api_chat_send_v2(chat_id: str, body: ChatMessageSendBody, request: Request, _auth: str = Depends(require_auth)):
|
||||||
|
started_at = time.monotonic()
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
if not _check_rate(f"chat_v2:{client_ip}", max_calls=30, window_sec=60):
|
if not _check_rate(f"chat_v2:{client_ip}", max_calls=30, window_sec=60):
|
||||||
raise HTTPException(status_code=429, detail="Rate limit: 30 messages/min")
|
raise HTTPException(status_code=429, detail="Rate limit: 30 messages/min")
|
||||||
text = (body.text or "").strip()
|
text = (body.text or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
raise HTTPException(status_code=400, detail="text is required")
|
raise HTTPException(status_code=400, detail="text is required")
|
||||||
|
request_id = get_request_id(request)
|
||||||
idem_key = (
|
idem_key = (
|
||||||
(
|
(
|
||||||
request.headers.get("Idempotency-Key")
|
request.headers.get("Idempotency-Key")
|
||||||
@@ -3273,19 +3307,41 @@ async def api_chat_send_v2(chat_id: str, body: ChatMessageSendBody, request: Req
|
|||||||
or ""
|
or ""
|
||||||
).strip()
|
).strip()
|
||||||
)[:128]
|
)[:128]
|
||||||
|
idem_hash = hash_idempotency_key(idem_key)
|
||||||
|
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"
|
||||||
|
log_event(
|
||||||
|
"chat.send",
|
||||||
|
request_id=request_id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
node_id=target_node,
|
||||||
|
agent_id=target_agent,
|
||||||
|
idempotency_key_hash=(idem_hash or None),
|
||||||
|
replayed=False,
|
||||||
|
status="ok",
|
||||||
|
)
|
||||||
if idem_key:
|
if idem_key:
|
||||||
cache_key = f"{chat_id}::{idem_key}"
|
cache_key = f"{chat_id}::{idem_key}"
|
||||||
cached = _idempotency_store.get(cache_key)
|
cached = _idempotency_store.get(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
SOFIIA_IDEMPOTENCY_REPLAYS_TOTAL.inc()
|
SOFIIA_IDEMPOTENCY_REPLAYS_TOTAL.inc()
|
||||||
|
log_event(
|
||||||
|
"chat.send.replay",
|
||||||
|
request_id=request_id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
node_id=target_node,
|
||||||
|
agent_id=target_agent,
|
||||||
|
idempotency_key_hash=(idem_hash or None),
|
||||||
|
replayed=True,
|
||||||
|
message_id=cached.message_id,
|
||||||
|
status="ok",
|
||||||
|
)
|
||||||
replay = dict(cached.response_body)
|
replay = dict(cached.response_body)
|
||||||
replay["idempotency"] = {"replayed": True, "key": idem_key}
|
replay["idempotency"] = {"replayed": True, "key": idem_key}
|
||||||
return replay
|
return replay
|
||||||
|
|
||||||
await _ensure_chat_project()
|
await _ensure_chat_project()
|
||||||
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()
|
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
|
||||||
@@ -3309,6 +3365,18 @@ async def api_chat_send_v2(chat_id: str, body: ChatMessageSendBody, request: Req
|
|||||||
}
|
}
|
||||||
base_url = get_router_url(target_node)
|
base_url = get_router_url(target_node)
|
||||||
if not base_url:
|
if not base_url:
|
||||||
|
duration_ms = int((time.monotonic() - started_at) * 1000)
|
||||||
|
log_event(
|
||||||
|
"chat.send.error",
|
||||||
|
request_id=request_id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
node_id=target_node,
|
||||||
|
agent_id=target_agent,
|
||||||
|
idempotency_key_hash=(idem_hash or None),
|
||||||
|
status="error",
|
||||||
|
error_code="router_url_not_configured",
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
raise HTTPException(status_code=400, detail=f"router_url is not configured for node {target_node}")
|
raise HTTPException(status_code=400, detail=f"router_url is not configured for node {target_node}")
|
||||||
try:
|
try:
|
||||||
out = await infer(
|
out = await infer(
|
||||||
@@ -3321,6 +3389,19 @@ async def api_chat_send_v2(chat_id: str, body: ChatMessageSendBody, request: Req
|
|||||||
api_key=ROUTER_API_KEY,
|
api_key=ROUTER_API_KEY,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
duration_ms = int((time.monotonic() - started_at) * 1000)
|
||||||
|
log_event(
|
||||||
|
"chat.send.error",
|
||||||
|
request_id=request_id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
node_id=target_node,
|
||||||
|
agent_id=target_agent,
|
||||||
|
idempotency_key_hash=(idem_hash or None),
|
||||||
|
status="error",
|
||||||
|
error_code="upstream_error",
|
||||||
|
error=str(e)[:180],
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
_broadcast_bg(
|
_broadcast_bg(
|
||||||
_make_event(
|
_make_event(
|
||||||
"error",
|
"error",
|
||||||
@@ -3367,6 +3448,18 @@ async def api_chat_send_v2(chat_id: str, body: ChatMessageSendBody, request: Req
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
result["idempotency"] = {"replayed": False, "key": idem_key}
|
result["idempotency"] = {"replayed": False, "key": idem_key}
|
||||||
|
duration_ms = int((time.monotonic() - started_at) * 1000)
|
||||||
|
log_event(
|
||||||
|
"chat.send.ok",
|
||||||
|
request_id=request_id,
|
||||||
|
chat_id=chat_id,
|
||||||
|
node_id=target_node,
|
||||||
|
agent_id=target_agent,
|
||||||
|
idempotency_key_hash=(idem_hash or None),
|
||||||
|
message_id=(result.get("message") or {}).get("message_id"),
|
||||||
|
status="ok",
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
88
tests/test_sofiia_structured_logging.py
Normal file
88
tests/test_sofiia_structured_logging.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
def _create_chat(client, agent_id: str, node_id: str, ref: str) -> str:
|
||||||
|
r = client.post(
|
||||||
|
"/api/chats",
|
||||||
|
json={
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"node_id": node_id,
|
||||||
|
"source": "web",
|
||||||
|
"external_chat_ref": ref,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()["chat"]["chat_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _event_messages(caplog, event: str):
|
||||||
|
out = []
|
||||||
|
for rec in caplog.records:
|
||||||
|
try:
|
||||||
|
payload = json.loads(rec.getMessage())
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if payload.get("event") == event:
|
||||||
|
out.append(payload)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_structured_logging_send_and_replay(sofiia_client, sofiia_module, monkeypatch, caplog):
|
||||||
|
def _router_url(node_id: str) -> str:
|
||||||
|
return {"NODA1": "http://noda1-router.test", "NODA2": "http://noda2-router.test"}.get(node_id, "")
|
||||||
|
|
||||||
|
async def _fake_infer(base_url, agent_id, text, **kwargs):
|
||||||
|
return {"response": "ok-structured", "backend": "fake", "model": "fake-model"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(sofiia_module, "get_router_url", _router_url)
|
||||||
|
monkeypatch.setattr(sofiia_module, "infer", _fake_infer)
|
||||||
|
|
||||||
|
chat_id = _create_chat(sofiia_client, "sofiia", "NODA1", "log-send")
|
||||||
|
headers = {"Idempotency-Key": "idem-log-1", "X-Request-Id": "req-123"}
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO, logger="sofiia"):
|
||||||
|
r1 = sofiia_client.post(f"/api/chats/{chat_id}/send", json={"text": "hello"}, headers=headers)
|
||||||
|
r2 = sofiia_client.post(f"/api/chats/{chat_id}/send", json={"text": "hello"}, headers=headers)
|
||||||
|
|
||||||
|
assert r1.status_code == 200, r1.text
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
assert r2.json().get("idempotency", {}).get("replayed") is True
|
||||||
|
|
||||||
|
send_events = _event_messages(caplog, "chat.send")
|
||||||
|
replay_events = _event_messages(caplog, "chat.send.replay")
|
||||||
|
ok_events = _event_messages(caplog, "chat.send.ok")
|
||||||
|
|
||||||
|
assert send_events, "Expected chat.send structured log"
|
||||||
|
assert replay_events, "Expected chat.send.replay structured log"
|
||||||
|
assert ok_events, "Expected chat.send.ok structured log"
|
||||||
|
|
||||||
|
first_send = send_events[0]
|
||||||
|
assert first_send["chat_id"] == chat_id
|
||||||
|
assert first_send["node_id"] == "NODA1"
|
||||||
|
assert first_send["event"] == "chat.send"
|
||||||
|
assert first_send["request_id"] == "req-123"
|
||||||
|
assert "idempotency_key_hash" in first_send
|
||||||
|
|
||||||
|
replay = replay_events[0]
|
||||||
|
assert replay["chat_id"] == chat_id
|
||||||
|
assert replay["status"] == "ok"
|
||||||
|
assert replay["replayed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_structured_logging_pagination_events(sofiia_client, caplog):
|
||||||
|
with caplog.at_level(logging.INFO, logger="sofiia"):
|
||||||
|
r = sofiia_client.get("/api/chats?nodes=NODA1,NODA2&limit=5")
|
||||||
|
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
list_events = _event_messages(caplog, "chat.list")
|
||||||
|
assert list_events, "Expected chat.list structured log"
|
||||||
|
|
||||||
|
entry = list_events[0]
|
||||||
|
assert entry["event"] == "chat.list"
|
||||||
|
assert entry["limit"] == 5
|
||||||
|
assert "cursor_present" in entry
|
||||||
|
assert "has_more" in entry
|
||||||
|
assert entry["status"] == "ok"
|
||||||
Reference in New Issue
Block a user