feat: API request/response logging with admin log viewer

- Add api_logs table (migration 0007) and ApiLog model
- Add web/lib/api_logger.py — httpx wrapper that records every outbound call
- Wire api_logger into vk_sync, vk_catalog, and connections test endpoints
- Add /admin/logs page with filters (service, method, status, time range, URL search) and expandable request/response detail
- Add "Логи" nav link for admin users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-05-12 22:00:14 +03:00
parent cad0b10fbb
commit 9960d760a0
10 changed files with 392 additions and 21 deletions

82
web/lib/api_logger.py Normal file
View File

@@ -0,0 +1,82 @@
"""Thin wrapper around httpx that logs every outbound API call to api_logs."""
import json
import time
import urllib.parse
from typing import Any
import httpx
from web.database import SessionLocal
from web.models.connections import ApiLog
_MAX_BODY = 8000 # truncate stored bodies beyond this
def _service_from_url(url: str) -> str:
host = urllib.parse.urlparse(url).netloc
if "evotor" in host:
return "evotor"
if "vk.com" in host:
return "vk"
return "other"
def _truncate(text: str | None) -> str | None:
if text and len(text) > _MAX_BODY:
return text[:_MAX_BODY] + ""
return text
def _record(
user_id: int | None,
method: str,
url: str,
request_body: str | None,
response_status: int | None,
response_body: str | None,
duration_ms: int,
) -> None:
try:
db = SessionLocal()
db.add(ApiLog(
user_id=user_id,
service=_service_from_url(url),
method=method.upper(),
url=url,
request_body=_truncate(request_body),
response_status=response_status,
response_body=_truncate(response_body),
duration_ms=duration_ms,
))
db.commit()
db.close()
except Exception:
pass # never let logging crash the caller
def get(url: str, *, user_id: int | None = None, **kwargs) -> httpx.Response:
t0 = time.monotonic()
resp = httpx.get(url, **kwargs)
ms = int((time.monotonic() - t0) * 1000)
try:
body = resp.text
except Exception:
body = None
_record(user_id, "GET", url, None, resp.status_code, body, ms)
return resp
def post(url: str, *, user_id: int | None = None, data: Any = None, json: Any = None, **kwargs) -> httpx.Response:
t0 = time.monotonic()
resp = httpx.post(url, data=data, json=json, **kwargs)
ms = int((time.monotonic() - t0) * 1000)
try:
req_body = resp.request.content.decode("utf-8", errors="replace") if resp.request.content else None
except Exception:
req_body = None
try:
body = resp.text
except Exception:
body = None
_record(user_id, "POST", url, req_body, resp.status_code, body, ms)
return resp