feat: 重构接口日志页面与接口监控看板,支持HTTP方法过滤和实时轮询
This commit is contained in:
@@ -15,6 +15,9 @@ class Settings(BaseSettings):
|
||||
tz: str = "Asia/Shanghai"
|
||||
# consecutive failures before upstream goes unhealthy
|
||||
unhealthy_threshold: int = 3
|
||||
# external API log retention
|
||||
external_api_log_retention_days: int = 30
|
||||
external_api_log_slow_ms: int = 3000
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
|
||||
@@ -41,7 +41,7 @@ def get_db():
|
||||
def init_db():
|
||||
"""Create all tables."""
|
||||
# import models so SQLAlchemy registers them
|
||||
from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website, revoked_token, upstream_key # noqa: F401
|
||||
from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website, revoked_token, upstream_key, external_api_log # noqa: F401
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_ensure_indexes()
|
||||
_migrate_custom_pages()
|
||||
@@ -58,6 +58,8 @@ _NEW_INDEXES = [
|
||||
"CREATE INDEX IF NOT EXISTS ix_sync_binding_created ON website_sync_logs(binding_id, created_at DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_upstream_enabled ON upstreams(enabled)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_key_upstream_name ON upstream_generated_keys(upstream_id, key_name)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_external_api_logs_created_at_desc ON external_api_logs(created_at DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_external_api_logs_direction_created ON external_api_logs(direction, created_at DESC)",
|
||||
]
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -15,7 +15,7 @@ from app.models.admin_user import AdminUser
|
||||
from app.database import SessionLocal
|
||||
from app.utils.auth import hash_password, verify_password, validate_password_supported
|
||||
from app.services.scheduler import start_scheduler, stop_scheduler
|
||||
from app.routers import auth, upstreams, webhooks, logs, custom_pages, websites, auth_capture
|
||||
from app.routers import auth, upstreams, webhooks, logs, custom_pages, websites, auth_capture, external_api_logs
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -97,6 +97,7 @@ app.include_router(logs.router)
|
||||
app.include_router(custom_pages.router)
|
||||
app.include_router(websites.router)
|
||||
app.include_router(auth_capture.router)
|
||||
app.include_router(external_api_logs.router)
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
# models package
|
||||
from app.models.external_api_log import ExternalApiLog
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy import Index, Integer, String, DateTime, Boolean, text
|
||||
from sqlalchemy.orm import mapped_column, Mapped
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ExternalApiLog(Base):
|
||||
__tablename__ = "external_api_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
direction: Mapped[str] = mapped_column(String(32), index=True) # "upstream" | "website"
|
||||
target_type: Mapped[str] = mapped_column(String(32), index=True) # "upstream" | "website"
|
||||
target_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
||||
target_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
method: Mapped[str] = mapped_column(String(16))
|
||||
path: Mapped[str] = mapped_column(String(1024))
|
||||
url_host: Mapped[str] = mapped_column(String(255))
|
||||
status_code: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
||||
success: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
duration_ms: Mapped[int] = mapped_column(Integer)
|
||||
error_type: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
error_message: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(timezone.utc), index=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_external_api_logs_created_at_desc", text("created_at DESC")),
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
"""External API logs listing with filters and summary."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import cast, case, func, Integer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.external_api_log import ExternalApiLog
|
||||
from app.schemas.external_api_log import ExternalApiLogResponse, ExternalApiLogSummaryItem
|
||||
from app.utils.auth import get_current_user
|
||||
from app.config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/api/external-api-logs", tags=["external_api_logs"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[ExternalApiLogResponse])
|
||||
def list_external_api_logs(
|
||||
direction: Optional[str] = Query(None),
|
||||
target_type: Optional[str] = Query(None),
|
||||
target_id: Optional[int] = Query(None),
|
||||
method: Optional[str] = Query(None),
|
||||
success: Optional[bool] = Query(None),
|
||||
status_code: Optional[int] = Query(None),
|
||||
path: Optional[str] = Query(None),
|
||||
limit: int = Query(100, le=500),
|
||||
offset: int = Query(0),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
q = db.query(ExternalApiLog)
|
||||
if direction:
|
||||
q = q.filter(ExternalApiLog.direction == direction)
|
||||
if target_type:
|
||||
q = q.filter(ExternalApiLog.target_type == target_type)
|
||||
if target_id is not None:
|
||||
q = q.filter(ExternalApiLog.target_id == target_id)
|
||||
if method and isinstance(method, str):
|
||||
q = q.filter(ExternalApiLog.method == method.upper())
|
||||
if success is not None:
|
||||
q = q.filter(ExternalApiLog.success == success)
|
||||
if status_code is not None:
|
||||
q = q.filter(ExternalApiLog.status_code == status_code)
|
||||
if path:
|
||||
q = q.filter(ExternalApiLog.path.like(f"%{path}%"))
|
||||
logs = q.order_by(ExternalApiLog.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return logs
|
||||
|
||||
|
||||
@router.get("/count")
|
||||
def count_external_api_logs(
|
||||
direction: Optional[str] = Query(None),
|
||||
target_type: Optional[str] = Query(None),
|
||||
target_id: Optional[int] = Query(None),
|
||||
method: Optional[str] = Query(None),
|
||||
success: Optional[bool] = Query(None),
|
||||
status_code: Optional[int] = Query(None),
|
||||
path: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
q = db.query(ExternalApiLog)
|
||||
if direction:
|
||||
q = q.filter(ExternalApiLog.direction == direction)
|
||||
if target_type:
|
||||
q = q.filter(ExternalApiLog.target_type == target_type)
|
||||
if target_id is not None:
|
||||
q = q.filter(ExternalApiLog.target_id == target_id)
|
||||
if method and isinstance(method, str):
|
||||
q = q.filter(ExternalApiLog.method == method.upper())
|
||||
if success is not None:
|
||||
q = q.filter(ExternalApiLog.success == success)
|
||||
if status_code is not None:
|
||||
q = q.filter(ExternalApiLog.status_code == status_code)
|
||||
if path:
|
||||
q = q.filter(ExternalApiLog.path.like(f"%{path}%"))
|
||||
return {"total": q.count()}
|
||||
|
||||
|
||||
@router.get("/summary", response_model=List[ExternalApiLogSummaryItem])
|
||||
def get_external_api_log_summary(
|
||||
slow_ms: Optional[int] = Query(None),
|
||||
hours: int = Query(24, ge=1, le=720),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
_=Depends(get_current_user),
|
||||
):
|
||||
"""Return aggregated summary grouped by direction + target + method + path.
|
||||
|
||||
- hours: time window (default 24, max 720). None means no time filter.
|
||||
- limit: max rows returned (default 50, max 200).
|
||||
"""
|
||||
settings = get_settings()
|
||||
slow_threshold = slow_ms if slow_ms is not None else settings.external_api_log_slow_ms
|
||||
|
||||
q = db.query(*_summary_cols(slow_threshold))
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
q = q.filter(ExternalApiLog.created_at >= cutoff)
|
||||
q = q.group_by(
|
||||
ExternalApiLog.direction,
|
||||
ExternalApiLog.target_type,
|
||||
ExternalApiLog.target_id,
|
||||
ExternalApiLog.target_name,
|
||||
ExternalApiLog.method,
|
||||
ExternalApiLog.path,
|
||||
).order_by(func.sum(ExternalApiLog.duration_ms).desc())
|
||||
q = q.limit(limit)
|
||||
rows = q.all()
|
||||
|
||||
return [
|
||||
ExternalApiLogSummaryItem(
|
||||
direction=row.direction,
|
||||
target_type=row.target_type,
|
||||
target_id=row.target_id,
|
||||
target_name=row.target_name,
|
||||
method=row.method,
|
||||
path=row.path,
|
||||
call_count=row.call_count,
|
||||
success_count=row.success_count or 0,
|
||||
fail_count=row.fail_count or 0,
|
||||
avg_duration_ms=round(float(row.avg_duration_ms or 0), 1),
|
||||
max_duration_ms=row.max_duration_ms or 0,
|
||||
slow_count=row.slow_count or 0,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def _summary_cols(slow_threshold: int) -> list:
|
||||
return [
|
||||
ExternalApiLog.direction,
|
||||
ExternalApiLog.target_type,
|
||||
ExternalApiLog.target_id,
|
||||
ExternalApiLog.target_name,
|
||||
ExternalApiLog.method,
|
||||
ExternalApiLog.path,
|
||||
func.count(ExternalApiLog.id).label("call_count"),
|
||||
func.sum(cast(ExternalApiLog.success, Integer)).label("success_count"),
|
||||
func.sum(cast(1 - cast(ExternalApiLog.success, Integer), Integer)).label("fail_count"),
|
||||
func.avg(ExternalApiLog.duration_ms).label("avg_duration_ms"),
|
||||
func.max(ExternalApiLog.duration_ms).label("max_duration_ms"),
|
||||
func.sum(case((ExternalApiLog.duration_ms >= slow_threshold, 1), else_=0)).label("slow_count"),
|
||||
]
|
||||
|
||||
|
||||
def clean_expired_logs(db: Session) -> int:
|
||||
"""Delete external API logs older than the configured retention period.
|
||||
|
||||
Returns the number of deleted rows.
|
||||
"""
|
||||
settings = get_settings()
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=settings.external_api_log_retention_days)
|
||||
deleted = db.query(ExternalApiLog).filter(
|
||||
ExternalApiLog.created_at < cutoff
|
||||
).delete(synchronize_session=False)
|
||||
if deleted:
|
||||
db.commit()
|
||||
return deleted
|
||||
@@ -195,6 +195,8 @@ def list_generated_keys(uid: int, db: Session = Depends(get_db), _=Depends(get_c
|
||||
auth_config=auth_config,
|
||||
timeout=float(upstream.timeout_seconds),
|
||||
on_auth_config_update=lambda updated: _persist_auth_config_update(db, upstream, updated),
|
||||
target_id=upstream.id,
|
||||
target_name=upstream.name,
|
||||
) as client:
|
||||
client.login()
|
||||
for prefix in website_sync._fetch_remote_managed_prefixes(db, uid):
|
||||
@@ -628,6 +630,8 @@ def generate_keys_by_groups(
|
||||
auth_config=auth_config,
|
||||
timeout=float(u.timeout_seconds),
|
||||
on_auth_config_update=lambda updated: _persist_auth_config_update(db, u, updated),
|
||||
target_id=u.id,
|
||||
target_name=u.name,
|
||||
) as client:
|
||||
try:
|
||||
client.login()
|
||||
@@ -679,6 +683,8 @@ def _test_upstream_core(db: Session, u: Upstream) -> UpstreamBatchActionItem:
|
||||
auth_config=auth_config,
|
||||
timeout=float(u.timeout_seconds),
|
||||
on_auth_config_update=lambda updated: _persist_auth_config_update(db, u, updated),
|
||||
target_id=u.id,
|
||||
target_name=u.name,
|
||||
) as client:
|
||||
client.login()
|
||||
groups = client.get_available_groups(u.groups_endpoint)
|
||||
@@ -719,6 +725,8 @@ def _check_now_core(db: Session, u: Upstream) -> tuple[str, bool]:
|
||||
auth_config=auth_config,
|
||||
timeout=float(u.timeout_seconds),
|
||||
on_auth_config_update=lambda updated: _persist_auth_config_update(db, u, updated),
|
||||
target_id=u.id,
|
||||
target_name=u.name,
|
||||
) as client:
|
||||
client.login()
|
||||
groups = client.get_available_groups(u.groups_endpoint)
|
||||
|
||||
@@ -140,6 +140,8 @@ def _client(row: Website) -> Sub2ApiWebsiteClient:
|
||||
auth_type=row.auth_type,
|
||||
auth_config=json.loads(row.auth_config_json or "{}"),
|
||||
timeout=float(row.timeout_seconds),
|
||||
target_id=row.id,
|
||||
target_name=row.name,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ExternalApiLogResponse(BaseModel):
|
||||
id: int
|
||||
direction: str
|
||||
target_type: str
|
||||
target_id: Optional[int] = None
|
||||
target_name: Optional[str] = None
|
||||
method: str
|
||||
path: str
|
||||
url_host: str
|
||||
status_code: Optional[int] = None
|
||||
success: bool
|
||||
duration_ms: int
|
||||
error_type: Optional[str] = None
|
||||
error_message: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ExternalApiLogSummaryItem(BaseModel):
|
||||
direction: str
|
||||
target_type: str
|
||||
target_id: Optional[int] = None
|
||||
target_name: Optional[str] = None
|
||||
method: str
|
||||
path: str
|
||||
call_count: int
|
||||
success_count: int
|
||||
fail_count: int
|
||||
avg_duration_ms: float
|
||||
max_duration_ms: int
|
||||
slow_count: int
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Log external API calls to the database and console."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.external_api_log import ExternalApiLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def log_external_api_call(
|
||||
direction: str,
|
||||
target_type: str,
|
||||
method: str,
|
||||
path: str,
|
||||
url_host: str,
|
||||
duration_ms: int,
|
||||
status_code: Optional[int] = None,
|
||||
success: bool = True,
|
||||
target_id: Optional[int] = None,
|
||||
target_name: Optional[str] = None,
|
||||
error_type: Optional[str] = None,
|
||||
error_message: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Persist an external API call log entry and write a structured console log.
|
||||
|
||||
No request/response bodies, tokens, cookies, or API keys are recorded.
|
||||
"""
|
||||
# Console log (structured, for Docker log aggregation)
|
||||
logger.info(
|
||||
"api_log direction=%s target_type=%s target_id=%s target_name=%s "
|
||||
"method=%s path=%s url_host=%s status_code=%s success=%s "
|
||||
"duration_ms=%d error_type=%s error_message=%s",
|
||||
direction,
|
||||
target_type,
|
||||
target_id,
|
||||
target_name or "",
|
||||
method,
|
||||
path,
|
||||
url_host,
|
||||
status_code or "",
|
||||
success,
|
||||
duration_ms,
|
||||
error_type or "",
|
||||
(error_message or "")[:200],
|
||||
)
|
||||
|
||||
# DB persistence
|
||||
db = SessionLocal()
|
||||
try:
|
||||
log = ExternalApiLog(
|
||||
direction=direction,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
target_name=target_name,
|
||||
method=method,
|
||||
path=path,
|
||||
url_host=url_host,
|
||||
status_code=status_code,
|
||||
success=success,
|
||||
duration_ms=duration_ms,
|
||||
error_type=error_type,
|
||||
error_message=error_message,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.exception("failed to persist external API log entry")
|
||||
finally:
|
||||
db.close()
|
||||
@@ -19,6 +19,7 @@ from app.services.snapshot_service import diff_snapshots, prune_snapshots
|
||||
from app.services import webhook_service
|
||||
from app.services import website_sync
|
||||
from app.config import get_settings
|
||||
from app.routers.external_api_logs import clean_expired_logs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -77,6 +78,8 @@ def _check_upstream(upstream_id: int) -> None:
|
||||
auth_config=auth_config,
|
||||
timeout=float(upstream.timeout_seconds),
|
||||
on_auth_config_update=lambda updated: _persist_auth_config_update(upstream, updated),
|
||||
target_id=upstream.id,
|
||||
target_name=upstream.name,
|
||||
) as client:
|
||||
try:
|
||||
client.login()
|
||||
@@ -258,6 +261,8 @@ def _sync_upstream_keys(upstream_id: int, snapshot: dict[str, Any], captured_at:
|
||||
auth_config=auth_config,
|
||||
timeout=float(upstream.timeout_seconds),
|
||||
on_auth_config_update=lambda updated: _persist_auth_config_update(upstream, updated),
|
||||
target_id=upstream.id,
|
||||
target_name=upstream.name,
|
||||
) as client:
|
||||
client.login()
|
||||
remote_key_ids = website_sync._fetch_remote_managed_key_ids(db, client, upstream_id)
|
||||
@@ -328,11 +333,35 @@ def start_scheduler() -> None:
|
||||
upstreams = db.query(Upstream).filter(Upstream.enabled == True).all()
|
||||
for u in upstreams:
|
||||
refresh_upstream(u.id, u.check_interval_seconds, u.enabled)
|
||||
# Daily cleanup of expired external API logs
|
||||
_scheduler.add_job(
|
||||
_cleanup_external_logs,
|
||||
"interval",
|
||||
hours=24,
|
||||
id="cleanup_external_api_logs",
|
||||
replace_existing=True,
|
||||
coalesce=True,
|
||||
max_instances=1,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
logger.info("scheduler started with %d upstream job(s)", len(upstreams))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _cleanup_external_logs() -> None:
|
||||
"""Delete external API logs older than the configured retention period."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
deleted = clean_expired_logs(db)
|
||||
if deleted:
|
||||
logger.info("cleaned %d expired external API log(s)", deleted)
|
||||
except Exception:
|
||||
logger.exception("failed to clean expired external API logs")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def stop_scheduler() -> None:
|
||||
if _scheduler.running:
|
||||
_scheduler.shutdown(wait=True)
|
||||
|
||||
@@ -3,13 +3,18 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Callable, Optional
|
||||
from urllib.parse import urljoin
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from app.utils.number import decimal_string
|
||||
from app.services.external_api_logger import log_external_api_call
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpstreamError(RuntimeError):
|
||||
@@ -429,6 +434,8 @@ class UpstreamClient:
|
||||
auth_config: dict[str, Any],
|
||||
timeout: float = 30.0,
|
||||
on_auth_config_update: Callable[[dict[str, Any]], None] | None = None,
|
||||
target_id: int | None = None,
|
||||
target_name: str | None = None,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_prefix = api_prefix.strip("/")
|
||||
@@ -436,6 +443,8 @@ class UpstreamClient:
|
||||
self.auth_config = auth_config
|
||||
self.timeout = timeout
|
||||
self.on_auth_config_update = on_auth_config_update
|
||||
self.target_id = target_id
|
||||
self.target_name = target_name
|
||||
self._token: str = ""
|
||||
self._cookies: dict[str, str] = {}
|
||||
self._new_api_user: str = ""
|
||||
@@ -549,6 +558,51 @@ class UpstreamClient:
|
||||
if changed and self.on_auth_config_update:
|
||||
self.on_auth_config_update(dict(self.auth_config))
|
||||
|
||||
def _do_request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
|
||||
"""Wrapped self._client.request() with timing and external-API logging.
|
||||
|
||||
Every HTTP call (initial, retry, refresh token) goes through here so
|
||||
the log is accurate and complete.
|
||||
|
||||
A response with status_code >= 400 is logged as a failure even though
|
||||
no exception was raised, so callers can check success/failure rates
|
||||
accurately. Callers still use raise_for_status() for flow control.
|
||||
"""
|
||||
started = time.monotonic()
|
||||
status_code: int | None = None
|
||||
error_type: str | None = None
|
||||
error_msg: str | None = None
|
||||
try:
|
||||
resp = self._client.request(method, url, **kwargs)
|
||||
status_code = getattr(resp, "status_code", None)
|
||||
if status_code is not None and status_code >= 400:
|
||||
error_type = "HTTPStatus"
|
||||
error_msg = f"HTTP {status_code}"
|
||||
return resp
|
||||
except Exception as exc:
|
||||
error_type = type(exc).__name__
|
||||
error_msg = str(exc)[:500]
|
||||
if hasattr(exc, "response") and hasattr(exc.response, "status_code"):
|
||||
status_code = exc.response.status_code
|
||||
raise
|
||||
finally:
|
||||
elapsed = int((time.monotonic() - started) * 1000)
|
||||
parsed = urlparse(url)
|
||||
log_external_api_call(
|
||||
direction="upstream",
|
||||
target_type="upstream",
|
||||
method=method,
|
||||
path=parsed.path or "",
|
||||
url_host=parsed.hostname or "",
|
||||
duration_ms=elapsed,
|
||||
status_code=status_code,
|
||||
success=error_type is None,
|
||||
target_id=self.target_id,
|
||||
target_name=self.target_name,
|
||||
error_type=error_type,
|
||||
error_message=error_msg,
|
||||
)
|
||||
|
||||
def _refresh_sub2api_bearer_token(self) -> bool:
|
||||
if not self._is_sub2api_bearer():
|
||||
return False
|
||||
@@ -556,7 +610,7 @@ class UpstreamClient:
|
||||
if not refresh_token:
|
||||
return False
|
||||
try:
|
||||
resp = self._client.request(
|
||||
resp = self._do_request(
|
||||
"POST",
|
||||
self._url("/auth/refresh"),
|
||||
json={"refresh_token": refresh_token},
|
||||
@@ -587,7 +641,7 @@ class UpstreamClient:
|
||||
allow_refresh: bool = True,
|
||||
**kwargs: Any,
|
||||
) -> httpx.Response:
|
||||
resp = self._client.request(
|
||||
resp = self._do_request(
|
||||
method,
|
||||
url,
|
||||
headers=self._headers(auth),
|
||||
@@ -602,7 +656,7 @@ class UpstreamClient:
|
||||
and self._is_sub2api_bearer()
|
||||
and self._refresh_sub2api_bearer_token()
|
||||
):
|
||||
resp = self._client.request(
|
||||
resp = self._do_request(
|
||||
method,
|
||||
url,
|
||||
headers=self._headers(auth),
|
||||
@@ -621,18 +675,9 @@ class UpstreamClient:
|
||||
raise UpstreamError("Nox-API endpoint requires Nox-Api-User; please fill user id and retry")
|
||||
url = self._url(path)
|
||||
if body is not None:
|
||||
resp = self._send_request(
|
||||
method,
|
||||
url,
|
||||
json=body,
|
||||
auth=auth,
|
||||
)
|
||||
resp = self._send_request(method, url, json=body, auth=auth)
|
||||
else:
|
||||
resp = self._send_request(
|
||||
method,
|
||||
url,
|
||||
auth=auth,
|
||||
)
|
||||
resp = self._send_request(method, url, auth=auth)
|
||||
resp.raise_for_status()
|
||||
ct = resp.headers.get("content-type", "")
|
||||
if not resp.content:
|
||||
@@ -689,11 +734,7 @@ class UpstreamClient:
|
||||
return items, meta
|
||||
|
||||
def _request_new_api_token_list(self, path: str, params: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
resp = self._send_request(
|
||||
"GET",
|
||||
self._url(path),
|
||||
params=params,
|
||||
)
|
||||
resp = self._send_request("GET", self._url(path), params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
self._ensure_api_success(data, "list New-API tokens")
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from app.utils.number import fixed_decimal_number, fixed_decimal_string
|
||||
from app.services.external_api_logger import log_external_api_call
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -136,12 +139,16 @@ class Sub2ApiWebsiteClient:
|
||||
auth_type: str,
|
||||
auth_config: dict[str, Any],
|
||||
timeout: float = 30.0,
|
||||
target_id: int | None = None,
|
||||
target_name: str | None = None,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_prefix = api_prefix.strip("/")
|
||||
self.auth_type = auth_type
|
||||
self.auth_config = auth_config
|
||||
self.timeout = timeout
|
||||
self.target_id = target_id
|
||||
self.target_name = target_name
|
||||
self._client = httpx.Client(timeout=timeout)
|
||||
|
||||
def close(self) -> None:
|
||||
@@ -171,21 +178,63 @@ class Sub2ApiWebsiteClient:
|
||||
return headers
|
||||
|
||||
def _request(self, method: str, path: str, body: Any = None) -> Any:
|
||||
url = self._url(path)
|
||||
started = time.monotonic()
|
||||
status_code: int | None = None
|
||||
error_type: str | None = None
|
||||
error_msg: str | None = None
|
||||
try:
|
||||
resp = self._client.request(method, self._url(path), json=body, headers=self._headers())
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise WebsiteError(_friendly_http_error(exc)) from exc
|
||||
except httpx.TimeoutException as exc:
|
||||
raise WebsiteError(_friendly_connection_error(exc)) from exc
|
||||
except httpx.ConnectError as exc:
|
||||
raise WebsiteError(_friendly_connection_error(exc)) from exc
|
||||
if not resp.content:
|
||||
return None
|
||||
text = resp.text
|
||||
if "application/json" not in resp.headers.get("content-type", "") and text.lstrip().startswith("<"):
|
||||
raise WebsiteError(f"{method} {path} 返回了 HTML,请检查接口地址是否正确")
|
||||
return resp.json()
|
||||
try:
|
||||
resp = self._client.request(method, url, json=body, headers=self._headers())
|
||||
except httpx.TimeoutException as exc:
|
||||
error_type, error_msg = type(exc).__name__, str(exc)[:500]
|
||||
raise WebsiteError(_friendly_connection_error(exc)) from exc
|
||||
except httpx.ConnectError as exc:
|
||||
error_type, error_msg = type(exc).__name__, str(exc)[:500]
|
||||
raise WebsiteError(_friendly_connection_error(exc)) from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
error_type, error_msg = type(exc).__name__, str(exc)[:500]
|
||||
status_code = exc.response.status_code
|
||||
raise WebsiteError(_friendly_http_error(exc)) from exc
|
||||
status_code = getattr(resp, "status_code", None)
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
error_type, error_msg = type(exc).__name__, str(exc)[:500]
|
||||
status_code = exc.response.status_code
|
||||
raise WebsiteError(_friendly_http_error(exc)) from exc
|
||||
if not resp.content:
|
||||
return None
|
||||
text = resp.text
|
||||
if "application/json" not in resp.headers.get("content-type", "") and text.lstrip().startswith("<"):
|
||||
error_type = "WebsiteError"
|
||||
error_msg = f"{method} {path} returned HTML"
|
||||
raise WebsiteError(f"{method} {path} 返回了 HTML,请检查接口地址是否正确")
|
||||
return resp.json()
|
||||
except WebsiteError:
|
||||
if error_type is None:
|
||||
error_type = "WebsiteError"
|
||||
e = sys.exc_info()[1]
|
||||
if e:
|
||||
error_msg = str(e)[:500]
|
||||
raise
|
||||
finally:
|
||||
elapsed = int((time.monotonic() - started) * 1000)
|
||||
parsed = urlparse(url)
|
||||
log_external_api_call(
|
||||
direction="website",
|
||||
target_type="website",
|
||||
method=method,
|
||||
path=parsed.path or path,
|
||||
url_host=parsed.hostname or "",
|
||||
duration_ms=elapsed,
|
||||
status_code=status_code,
|
||||
success=error_type is None,
|
||||
target_id=self.target_id,
|
||||
target_name=self.target_name,
|
||||
error_type=error_type,
|
||||
error_message=error_msg,
|
||||
)
|
||||
|
||||
def get_groups(self, endpoint: str = "/groups") -> list[dict[str, Any]]:
|
||||
"""拉取分组列表,尝试 endpoint 和 fallback /groups/all。"""
|
||||
|
||||
@@ -93,6 +93,8 @@ def _client_for(website: Website) -> Sub2ApiWebsiteClient:
|
||||
auth_type=website.auth_type,
|
||||
auth_config=json.loads(website.auth_config_json or "{}"),
|
||||
timeout=float(website.timeout_seconds),
|
||||
target_id=website.id,
|
||||
target_name=website.name,
|
||||
)
|
||||
|
||||
|
||||
@@ -325,6 +327,8 @@ def _backfill_missing_target_groups_from_remote_accounts(
|
||||
auth_type=website.auth_type,
|
||||
auth_config=json.loads(website.auth_config_json or "{}"),
|
||||
timeout=float(website.timeout_seconds),
|
||||
target_id=website.id,
|
||||
target_name=website.name,
|
||||
) as client:
|
||||
accounts = client.list_accounts() or []
|
||||
except Exception as exc:
|
||||
@@ -838,6 +842,8 @@ def reconcile_upstream_keys_full(db: Session, upstream_id: int) -> bool:
|
||||
auth_config=auth_config,
|
||||
timeout=float(upstream.timeout_seconds),
|
||||
on_auth_config_update=lambda updated: _persist_upstream_auth_config(upstream, updated),
|
||||
target_id=upstream.id,
|
||||
target_name=upstream.name,
|
||||
) as client:
|
||||
client.login()
|
||||
# 获取远端 Key 列表(支持自定义 managed_prefix)
|
||||
|
||||
@@ -0,0 +1,677 @@
|
||||
"""Tests for external API log persistence: logging, query, summary, and cleanup."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.database import Base
|
||||
from app.models.external_api_log import ExternalApiLog
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine():
|
||||
e = create_engine("sqlite://", connect_args={"check_same_thread": False})
|
||||
# Import all models so they register with Base.metadata
|
||||
import app.models.admin_user # noqa: F401
|
||||
import app.models.upstream # noqa: F401
|
||||
import app.models.website # noqa: F401
|
||||
import app.models.snapshot # noqa: F401
|
||||
import app.models.webhook_config # noqa: F401
|
||||
import app.models.notification_log # noqa: F401
|
||||
import app.models.custom_page # noqa: F401
|
||||
import app.models.revoked_token # noqa: F401
|
||||
import app.models.upstream_key # noqa: F401
|
||||
import app.models.external_api_log # noqa: F401
|
||||
Base.metadata.create_all(bind=e)
|
||||
yield e
|
||||
Base.metadata.drop_all(bind=e)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session(engine):
|
||||
TestingSession = sessionmaker(bind=engine)
|
||||
db = TestingSession()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 1. Model: basic insert and read
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def test_insert_and_read(session):
|
||||
now = datetime.now(timezone.utc)
|
||||
log = ExternalApiLog(
|
||||
direction="upstream",
|
||||
target_type="upstream",
|
||||
target_id=1,
|
||||
target_name="test-upstream",
|
||||
method="GET",
|
||||
path="/api/v1/models",
|
||||
url_host="api.test.local",
|
||||
status_code=200,
|
||||
success=True,
|
||||
duration_ms=150,
|
||||
created_at=now,
|
||||
)
|
||||
session.add(log)
|
||||
session.commit()
|
||||
|
||||
rows = session.query(ExternalApiLog).all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].direction == "upstream"
|
||||
assert rows[0].duration_ms == 150
|
||||
assert rows[0].status_code == 200
|
||||
assert rows[0].success is True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 2. Filters (direction, target_type, target_id, success, status_code, path)
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def _seed(session, count: int = 1, **overrides) -> list[ExternalApiLog]:
|
||||
rows = []
|
||||
for i in range(count):
|
||||
defaults = dict(
|
||||
direction="upstream",
|
||||
target_type="upstream",
|
||||
target_id=10 + i,
|
||||
target_name=f"upstream-{i}",
|
||||
method="GET",
|
||||
path=f"/api/v1/endpoint/{i}",
|
||||
url_host="api.test.local",
|
||||
status_code=200,
|
||||
success=True,
|
||||
duration_ms=100 + i * 10,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
defaults.update(overrides)
|
||||
r = ExternalApiLog(**defaults)
|
||||
session.add(r)
|
||||
rows.append(r)
|
||||
session.commit()
|
||||
return rows
|
||||
|
||||
|
||||
def test_filter_by_direction(session):
|
||||
_seed(session, 1, direction="upstream")
|
||||
_seed(session, 1, direction="website")
|
||||
rows = session.query(ExternalApiLog).filter(ExternalApiLog.direction == "upstream").all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].direction == "upstream"
|
||||
|
||||
|
||||
def test_filter_by_success(session):
|
||||
_seed(session, 1, success=True)
|
||||
_seed(session, 1, success=False, status_code=500, error_type="ServerError")
|
||||
rows = session.query(ExternalApiLog).filter(ExternalApiLog.success == False).all() # noqa: E712
|
||||
assert len(rows) == 1
|
||||
assert rows[0].success is False
|
||||
|
||||
|
||||
def test_filter_by_status_code(session):
|
||||
_seed(session, 1, status_code=200)
|
||||
_seed(session, 1, status_code=404, success=False, error_type="NotFound")
|
||||
rows = session.query(ExternalApiLog).filter(ExternalApiLog.status_code == 404).all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].status_code == 404
|
||||
|
||||
|
||||
def test_filter_by_target_id(session):
|
||||
_seed(session, 1, target_id=42)
|
||||
_seed(session, 1, target_id=99)
|
||||
rows = session.query(ExternalApiLog).filter(ExternalApiLog.target_id == 42).all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].target_id == 42
|
||||
|
||||
|
||||
def test_filter_by_path_like(session):
|
||||
_seed(session, 1, path="/api/keys/list")
|
||||
_seed(session, 1, path="/api/groups/list")
|
||||
rows = session.query(ExternalApiLog).filter(ExternalApiLog.path.like("%keys%")).all()
|
||||
assert len(rows) == 1
|
||||
assert "keys" in rows[0].path
|
||||
|
||||
|
||||
def test_pagination(session):
|
||||
_seed(session, 5)
|
||||
total = session.query(ExternalApiLog).count()
|
||||
assert total == 5
|
||||
page = session.query(ExternalApiLog).order_by(ExternalApiLog.id).offset(0).limit(2).all()
|
||||
assert len(page) == 2
|
||||
page2 = session.query(ExternalApiLog).order_by(ExternalApiLog.id).offset(2).limit(2).all()
|
||||
assert len(page2) == 2
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 3. Summary aggregation
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def test_summary_aggregation(session):
|
||||
now = datetime.now(timezone.utc)
|
||||
# 3 calls to same path: 2 success, 1 fail, various durations
|
||||
for i in range(3):
|
||||
session.add(ExternalApiLog(
|
||||
direction="upstream",
|
||||
target_type="upstream",
|
||||
target_id=1,
|
||||
target_name="agg-test",
|
||||
method="GET",
|
||||
path="/api/v1/status",
|
||||
url_host="api.test.local",
|
||||
status_code=200 if i < 2 else 500,
|
||||
success=i < 2,
|
||||
duration_ms=[100, 200, 300][i],
|
||||
created_at=now,
|
||||
))
|
||||
# A different path
|
||||
session.add(ExternalApiLog(
|
||||
direction="website",
|
||||
target_type="website",
|
||||
target_id=2,
|
||||
target_name="site-2",
|
||||
method="POST",
|
||||
path="/api/v1/accounts",
|
||||
url_host="site.test.local",
|
||||
status_code=201,
|
||||
success=True,
|
||||
duration_ms=500,
|
||||
created_at=now,
|
||||
))
|
||||
session.commit()
|
||||
|
||||
from sqlalchemy import cast, case, func, Integer
|
||||
slow_threshold = 250
|
||||
cols = [
|
||||
ExternalApiLog.direction,
|
||||
ExternalApiLog.target_type,
|
||||
ExternalApiLog.target_id,
|
||||
ExternalApiLog.target_name,
|
||||
ExternalApiLog.method,
|
||||
ExternalApiLog.path,
|
||||
func.count(ExternalApiLog.id).label("call_count"),
|
||||
func.sum(cast(ExternalApiLog.success, Integer)).label("success_count"),
|
||||
func.sum(cast(1 - cast(ExternalApiLog.success, Integer), Integer)).label("fail_count"),
|
||||
func.avg(ExternalApiLog.duration_ms).label("avg_duration_ms"),
|
||||
func.max(ExternalApiLog.duration_ms).label("max_duration_ms"),
|
||||
func.sum(case((ExternalApiLog.duration_ms >= slow_threshold, 1), else_=0)).label("slow_count"),
|
||||
]
|
||||
rows = (
|
||||
session.query(*cols)
|
||||
.group_by(
|
||||
ExternalApiLog.direction, ExternalApiLog.target_type,
|
||||
ExternalApiLog.target_id, ExternalApiLog.target_name,
|
||||
ExternalApiLog.method, ExternalApiLog.path,
|
||||
)
|
||||
.order_by(func.sum(ExternalApiLog.duration_ms).desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
assert len(rows) == 2
|
||||
# First row: upstream, 3 calls
|
||||
r1 = rows[0]
|
||||
assert r1.direction == "upstream"
|
||||
assert r1.call_count == 3
|
||||
assert r1.success_count == 2
|
||||
assert r1.fail_count == 1
|
||||
assert r1.slow_count == 1 # only 300ms >= 250
|
||||
assert r1.max_duration_ms == 300
|
||||
assert 190 <= r1.avg_duration_ms <= 210 # ~200
|
||||
|
||||
# Second row: website, 1 call
|
||||
r2 = rows[1]
|
||||
assert r2.direction == "website"
|
||||
assert r2.call_count == 1
|
||||
assert r2.success_count == 1
|
||||
assert r2.fail_count == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 4. Cleanup (retention)
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def test_clean_expired_logs(session):
|
||||
now = datetime.now(timezone.utc)
|
||||
# Old record (40 days ago)
|
||||
session.add(ExternalApiLog(
|
||||
direction="upstream", target_type="upstream", method="GET",
|
||||
path="/old", url_host="old.local",
|
||||
status_code=200, success=True, duration_ms=10,
|
||||
created_at=now - timedelta(days=40),
|
||||
))
|
||||
# Recent record (5 days ago)
|
||||
session.add(ExternalApiLog(
|
||||
direction="upstream", target_type="upstream", method="GET",
|
||||
path="/recent", url_host="recent.local",
|
||||
status_code=200, success=True, duration_ms=10,
|
||||
created_at=now - timedelta(days=5),
|
||||
))
|
||||
session.commit()
|
||||
assert session.query(ExternalApiLog).count() == 2
|
||||
|
||||
# Delete records older than 30 days
|
||||
cutoff = now - timedelta(days=30)
|
||||
deleted = session.query(ExternalApiLog).filter(
|
||||
ExternalApiLog.created_at < cutoff
|
||||
).delete(synchronize_session=False)
|
||||
session.commit()
|
||||
assert deleted == 1
|
||||
assert session.query(ExternalApiLog).count() == 1
|
||||
remaining = session.query(ExternalApiLog).first()
|
||||
assert "/recent" in remaining.path
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 5. log_external_api_call function
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def test_log_external_api_call_success(session):
|
||||
"""Verify the logging helper writes the expected row."""
|
||||
from app.services.external_api_logger import log_external_api_call
|
||||
|
||||
with patch("app.services.external_api_logger.SessionLocal", return_value=session):
|
||||
log_external_api_call(
|
||||
direction="upstream",
|
||||
target_type="upstream",
|
||||
method="POST",
|
||||
path="/api/v1/test",
|
||||
url_host="test.local",
|
||||
duration_ms=250,
|
||||
status_code=201,
|
||||
success=True,
|
||||
target_id=7,
|
||||
target_name="gold-upstream",
|
||||
)
|
||||
|
||||
rows = session.query(ExternalApiLog).all()
|
||||
assert len(rows) == 1
|
||||
r = rows[0]
|
||||
assert r.direction == "upstream"
|
||||
assert r.method == "POST"
|
||||
assert r.path == "/api/v1/test"
|
||||
assert r.url_host == "test.local"
|
||||
assert r.duration_ms == 250
|
||||
assert r.status_code == 201
|
||||
assert r.success is True
|
||||
assert r.target_id == 7
|
||||
assert r.target_name == "gold-upstream"
|
||||
assert r.error_type is None
|
||||
assert r.error_message is None
|
||||
|
||||
|
||||
def test_log_external_api_call_failure(session):
|
||||
"""Failure path: error fields populated, success=False."""
|
||||
from app.services.external_api_logger import log_external_api_call
|
||||
|
||||
with patch("app.services.external_api_logger.SessionLocal", return_value=session):
|
||||
log_external_api_call(
|
||||
direction="website",
|
||||
target_type="website",
|
||||
method="GET",
|
||||
path="/api/v1/groups",
|
||||
url_host="site.local",
|
||||
duration_ms=5000,
|
||||
status_code=502,
|
||||
success=False,
|
||||
target_id=3,
|
||||
target_name="bad-site",
|
||||
error_type="BadGateway",
|
||||
error_message="upstream connection refused",
|
||||
)
|
||||
|
||||
rows = session.query(ExternalApiLog).all()
|
||||
assert len(rows) == 1
|
||||
r = rows[0]
|
||||
assert r.direction == "website"
|
||||
assert r.success is False
|
||||
assert r.status_code == 502
|
||||
assert r.duration_ms == 5000
|
||||
assert r.error_type == "BadGateway"
|
||||
assert r.error_message == "upstream connection refused"
|
||||
|
||||
|
||||
def test_log_no_sensitive_data(session):
|
||||
"""Verify no request/response body or credentials are logged."""
|
||||
from app.services.external_api_logger import log_external_api_call
|
||||
|
||||
with patch("app.services.external_api_logger.SessionLocal", return_value=session):
|
||||
log_external_api_call(
|
||||
direction="upstream",
|
||||
target_type="upstream",
|
||||
method="GET",
|
||||
path="/api/v1/keys",
|
||||
url_host="secure.local",
|
||||
duration_ms=100,
|
||||
status_code=200,
|
||||
success=True,
|
||||
)
|
||||
|
||||
rows = session.query(ExternalApiLog).all()
|
||||
assert len(rows) == 1
|
||||
r = rows[0]
|
||||
# These fields MUST be empty — we never log bodies or credentials
|
||||
assert not hasattr(r, "request_body")
|
||||
assert not hasattr(r, "response_body")
|
||||
assert not hasattr(r, "authorization")
|
||||
assert not hasattr(r, "cookie")
|
||||
assert not hasattr(r, "api_key")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 6. Time window on summary
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def test_summary_time_window(session):
|
||||
now = datetime.now(timezone.utc)
|
||||
# Recent (1 hour ago)
|
||||
session.add(ExternalApiLog(
|
||||
direction="upstream", target_type="upstream", method="GET",
|
||||
path="/recent", url_host="h", status_code=200, success=True,
|
||||
duration_ms=50, created_at=now - timedelta(hours=1),
|
||||
))
|
||||
# Old (48 hours ago)
|
||||
session.add(ExternalApiLog(
|
||||
direction="upstream", target_type="upstream", method="GET",
|
||||
path="/old", url_host="h", status_code=200, success=True,
|
||||
duration_ms=50, created_at=now - timedelta(hours=48),
|
||||
))
|
||||
session.commit()
|
||||
|
||||
# 24-hour window should only include the recent record
|
||||
cutoff = now - timedelta(hours=24)
|
||||
rows = session.query(ExternalApiLog).filter(ExternalApiLog.created_at >= cutoff).all()
|
||||
assert len(rows) == 1
|
||||
assert "/recent" in rows[0].path
|
||||
|
||||
# 72-hour window includes both
|
||||
cutoff = now - timedelta(hours=72)
|
||||
rows = session.query(ExternalApiLog).filter(ExternalApiLog.created_at >= cutoff).all()
|
||||
assert len(rows) == 2
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 7. UpstreamClient integration: 4xx/5xx logged as failure
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def _make_httpx_mock(status_code: int, body: dict | None = None):
|
||||
"""Return a mock for httpx.Client.request that returns a fixed status.
|
||||
|
||||
Used via patch.object(client._client, 'request', ...) which replaces the
|
||||
bound method, so the signature is (method, url, **kwargs) — no self.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
def mock_request(method, url, **kwargs):
|
||||
req = httpx.Request(method, url)
|
||||
return httpx.Response(status_code, json=body or {}, request=req)
|
||||
|
||||
return mock_request
|
||||
|
||||
|
||||
def test_upstream_client_500_logged_as_failure():
|
||||
"""UpstreamClient._request() → HTTP 500 → log with success=False, status_code=500."""
|
||||
from app.services.upstream_client import UpstreamClient
|
||||
|
||||
captured = {}
|
||||
|
||||
def capture(**kw):
|
||||
captured.update(kw)
|
||||
|
||||
with patch("app.services.upstream_client.log_external_api_call", side_effect=capture):
|
||||
client = UpstreamClient(
|
||||
base_url="http://test.local",
|
||||
api_prefix="",
|
||||
auth_type="api_key",
|
||||
auth_config={"key": "test", "header": "Authorization"},
|
||||
target_id=1,
|
||||
target_name="test-upstream",
|
||||
)
|
||||
with patch.object(client._client, "request", _make_httpx_mock(500)):
|
||||
try:
|
||||
client._request("GET", "/api/v1/models")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert captured.get("status_code") == 500
|
||||
assert captured.get("success") is False
|
||||
assert captured.get("error_type") == "HTTPStatus"
|
||||
assert "500" in (captured.get("error_message") or "")
|
||||
|
||||
|
||||
def test_upstream_client_429_logged_as_failure():
|
||||
"""UpstreamClient._request() → HTTP 429 → log with success=False, status_code=429."""
|
||||
from app.services.upstream_client import UpstreamClient
|
||||
|
||||
captured = {}
|
||||
|
||||
def capture(**kw):
|
||||
captured.update(kw)
|
||||
|
||||
with patch("app.services.upstream_client.log_external_api_call", side_effect=capture):
|
||||
client = UpstreamClient(
|
||||
base_url="http://test.local",
|
||||
api_prefix="",
|
||||
auth_type="api_key",
|
||||
auth_config={"key": "test", "header": "Authorization"},
|
||||
target_id=1,
|
||||
target_name="test-upstream",
|
||||
)
|
||||
with patch.object(client._client, "request", _make_httpx_mock(429)):
|
||||
try:
|
||||
client._request("GET", "/api/v1/rate-limited")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
assert captured.get("status_code") == 429
|
||||
assert captured.get("success") is False
|
||||
assert captured.get("error_type") == "HTTPStatus"
|
||||
|
||||
|
||||
def test_upstream_client_200_logged_as_success():
|
||||
"""UpstreamClient._request() → HTTP 200 → log with success=True, status_code=200."""
|
||||
from app.services.upstream_client import UpstreamClient
|
||||
|
||||
captured = {}
|
||||
|
||||
def capture(**kw):
|
||||
captured.update(kw)
|
||||
|
||||
with patch("app.services.upstream_client.log_external_api_call", side_effect=capture):
|
||||
client = UpstreamClient(
|
||||
base_url="http://test.local",
|
||||
api_prefix="",
|
||||
auth_type="api_key",
|
||||
auth_config={"key": "test", "header": "Authorization"},
|
||||
target_id=1,
|
||||
target_name="test-upstream",
|
||||
)
|
||||
with patch.object(client._client, "request", _make_httpx_mock(200, {"data": []})):
|
||||
client._request("GET", "/api/v1/groups")
|
||||
|
||||
assert captured.get("status_code") == 200
|
||||
assert captured.get("success") is True
|
||||
assert captured.get("error_type") is None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 8. Route-level tests — call handlers directly
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def test_route_list_empty(session):
|
||||
"""list_external_api_logs with no data returns []."""
|
||||
from app.routers.external_api_logs import list_external_api_logs
|
||||
|
||||
result = list_external_api_logs(
|
||||
direction=None, target_type=None, target_id=None,
|
||||
success=None, status_code=None, path=None,
|
||||
limit=100, offset=0, db=session, _=None,
|
||||
)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_route_list_with_filters(session):
|
||||
"""list_external_api_logs with filters returns filtered results."""
|
||||
from app.routers.external_api_logs import list_external_api_logs
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
for i in range(3):
|
||||
session.add(ExternalApiLog(
|
||||
direction="upstream" if i < 2 else "website",
|
||||
target_type="upstream" if i < 2 else "website",
|
||||
target_id=i + 1, target_name=f"tgt-{i}",
|
||||
method="GET", path=f"/path/{i}", url_host="h.local",
|
||||
status_code=200 if i < 2 else 500, success=i < 2,
|
||||
duration_ms=100 + i, created_at=now,
|
||||
))
|
||||
session.commit()
|
||||
|
||||
_empty = None # sentinel for "no filter"
|
||||
|
||||
# Filter by direction
|
||||
result = list_external_api_logs(
|
||||
direction="website", target_type=_empty, target_id=_empty,
|
||||
success=_empty, status_code=_empty, path=_empty,
|
||||
limit=100, offset=0, db=session, _=None,
|
||||
)
|
||||
assert len(result) == 1 and result[0].direction == "website"
|
||||
|
||||
# Filter by success
|
||||
result = list_external_api_logs(
|
||||
direction=_empty, target_type=_empty, target_id=_empty,
|
||||
success=False, status_code=_empty, path=_empty,
|
||||
limit=100, offset=0, db=session, _=None,
|
||||
)
|
||||
assert len(result) == 1 and result[0].success is False
|
||||
|
||||
# Filter by status_code
|
||||
result = list_external_api_logs(
|
||||
direction=_empty, target_type=_empty, target_id=_empty,
|
||||
success=_empty, status_code=500, path=_empty,
|
||||
limit=100, offset=0, db=session, _=None,
|
||||
)
|
||||
assert len(result) == 1 and result[0].status_code == 500
|
||||
|
||||
# Filter by path
|
||||
result = list_external_api_logs(
|
||||
direction=_empty, target_type=_empty, target_id=_empty,
|
||||
success=_empty, status_code=_empty, path="/path/0",
|
||||
limit=100, offset=0, db=session, _=None,
|
||||
)
|
||||
assert len(result) == 1 and "/path/0" in result[0].path
|
||||
|
||||
# Filter by target_id
|
||||
result = list_external_api_logs(
|
||||
direction=_empty, target_type=_empty, target_id=2,
|
||||
success=_empty, status_code=_empty, path=_empty,
|
||||
limit=100, offset=0, db=session, _=None,
|
||||
)
|
||||
assert len(result) == 1 and result[0].target_id == 2
|
||||
|
||||
|
||||
def test_route_list_pagination(session):
|
||||
"""list_external_api_logs respects limit/offset."""
|
||||
from app.routers.external_api_logs import list_external_api_logs
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
for i in range(10):
|
||||
session.add(ExternalApiLog(
|
||||
direction="upstream", target_type="upstream",
|
||||
method="GET", path=f"/p/{i}", url_host="h",
|
||||
status_code=200, success=True, duration_ms=i, created_at=now,
|
||||
))
|
||||
session.commit()
|
||||
|
||||
_e = None
|
||||
|
||||
assert len(list_external_api_logs(
|
||||
direction=_e, target_type=_e, target_id=_e,
|
||||
success=_e, status_code=_e, path=_e,
|
||||
limit=3, offset=0, db=session, _=None,
|
||||
)) == 3
|
||||
assert len(list_external_api_logs(
|
||||
direction=_e, target_type=_e, target_id=_e,
|
||||
success=_e, status_code=_e, path=_e,
|
||||
limit=3, offset=3, db=session, _=None,
|
||||
)) == 3
|
||||
assert len(list_external_api_logs(
|
||||
direction=_e, target_type=_e, target_id=_e,
|
||||
success=_e, status_code=_e, path=_e,
|
||||
limit=3, offset=20, db=session, _=None,
|
||||
)) == 0
|
||||
|
||||
|
||||
def test_route_summary_hours_window(session):
|
||||
"""get_external_api_log_summary respects hours parameter."""
|
||||
from app.routers.external_api_logs import get_external_api_log_summary
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
# Recent (30 minutes ago — well within 1-hour window)
|
||||
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
|
||||
method="GET", path="/recent", url_host="h",
|
||||
status_code=200, success=True, duration_ms=50,
|
||||
created_at=now - timedelta(minutes=30)))
|
||||
# Old (48 hours ago — outside 1-hour window)
|
||||
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
|
||||
method="GET", path="/old", url_host="h",
|
||||
status_code=200, success=True, duration_ms=50,
|
||||
created_at=now - timedelta(hours=48)))
|
||||
session.commit()
|
||||
|
||||
# hours=1 → only recent
|
||||
result = get_external_api_log_summary(
|
||||
slow_ms=None, hours=1, limit=50, db=session, _=None,
|
||||
)
|
||||
assert len(result) == 1 and result[0].path == "/recent"
|
||||
|
||||
# hours=72 → both
|
||||
result = get_external_api_log_summary(
|
||||
slow_ms=None, hours=72, limit=50, db=session, _=None,
|
||||
)
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
def test_route_summary_limit(session):
|
||||
"""get_external_api_log_summary respects limit parameter."""
|
||||
from app.routers.external_api_logs import get_external_api_log_summary
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
for i in range(10):
|
||||
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
|
||||
method="GET", path=f"/path-{i}", url_host="h",
|
||||
status_code=200, success=True, duration_ms=50 + i, created_at=now))
|
||||
session.commit()
|
||||
|
||||
result = get_external_api_log_summary(
|
||||
slow_ms=None, hours=24, limit=3, db=session, _=None,
|
||||
)
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
def test_route_summary_slow_threshold(session):
|
||||
"""get_external_api_log_summary respects slow_ms parameter."""
|
||||
from app.routers.external_api_logs import get_external_api_log_summary
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
|
||||
method="GET", path="/fast", url_host="h",
|
||||
status_code=200, success=True, duration_ms=100, created_at=now))
|
||||
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
|
||||
method="GET", path="/fast2", url_host="h",
|
||||
status_code=200, success=True, duration_ms=200, created_at=now))
|
||||
session.add(ExternalApiLog(direction="upstream", target_type="upstream",
|
||||
method="GET", path="/slow", url_host="h",
|
||||
status_code=200, success=True, duration_ms=5000, created_at=now))
|
||||
session.commit()
|
||||
|
||||
result = get_external_api_log_summary(
|
||||
slow_ms=1000, hours=24, limit=50, db=session, _=None,
|
||||
)
|
||||
slow_paths = [r for r in result if r.slow_count > 0]
|
||||
assert len(slow_paths) == 1
|
||||
assert slow_paths[0].path == "/slow"
|
||||
@@ -462,6 +462,64 @@ export const customPagesApi = {
|
||||
delete: (id: number) => api.delete(`/api/custom-pages/${id}`),
|
||||
}
|
||||
|
||||
// ——— External API Logs ———
|
||||
export interface ExternalApiLogData {
|
||||
id: number
|
||||
direction: string
|
||||
target_type: string
|
||||
target_id: number | null
|
||||
target_name: string | null
|
||||
method: string
|
||||
path: string
|
||||
url_host: string
|
||||
status_code: number | null
|
||||
success: boolean
|
||||
duration_ms: number
|
||||
error_type: string | null
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ExternalApiLogSummaryItem {
|
||||
direction: string
|
||||
target_type: string
|
||||
target_id: number | null
|
||||
target_name: string | null
|
||||
method: string
|
||||
path: string
|
||||
call_count: number
|
||||
success_count: number
|
||||
fail_count: number
|
||||
avg_duration_ms: number
|
||||
max_duration_ms: number
|
||||
slow_count: number
|
||||
}
|
||||
|
||||
export const externalApiLogsApi = {
|
||||
list: (params?: {
|
||||
direction?: string
|
||||
target_type?: string
|
||||
target_id?: number
|
||||
method?: string
|
||||
success?: boolean
|
||||
status_code?: number
|
||||
path?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}) => api.get<ExternalApiLogData[]>('/api/external-api-logs', { params }),
|
||||
count: (params?: {
|
||||
direction?: string
|
||||
target_type?: string
|
||||
target_id?: number
|
||||
method?: string
|
||||
success?: boolean
|
||||
status_code?: number
|
||||
path?: string
|
||||
}) => api.get<{ total: number }>('/api/external-api-logs/count', { params }),
|
||||
summary: (params?: { slow_ms?: number }) =>
|
||||
api.get<ExternalApiLogSummaryItem[]>('/api/external-api-logs/summary', { params }),
|
||||
}
|
||||
|
||||
// ——— Auth Capture ———
|
||||
export interface BrowserImportSession {
|
||||
session_id: string
|
||||
|
||||
@@ -47,6 +47,13 @@
|
||||
<small>响应记录、筛选、追踪异常</small>
|
||||
</span>
|
||||
</router-link>
|
||||
<router-link to="/external-api-logs" class="nav-item" active-class="active" @click="closeMobileNav">
|
||||
<span class="nav-icon"><el-icon><Monitor /></el-icon></span>
|
||||
<span class="nav-copy">
|
||||
<strong>接口日志</strong>
|
||||
<small>外部请求耗时、慢接口汇总</small>
|
||||
</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const router = createRouter({
|
||||
{ path: 'websites', component: () => import('@/views/Websites.vue') },
|
||||
{ path: 'webhooks', component: () => import('@/views/Webhooks.vue') },
|
||||
{ path: 'logs', component: () => import('@/views/NotificationLogs.vue') },
|
||||
{ path: 'external-api-logs', component: () => import('@/views/ExternalApiLogs.vue') },
|
||||
{ path: 'custom-pages', component: () => import('@/views/CustomPages.vue') },
|
||||
{ path: 'page/:id', redirect: '/custom-pages' },
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user