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:
82
web/lib/api_logger.py
Normal file
82
web/lib/api_logger.py
Normal 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
|
||||
Reference in New Issue
Block a user