feat: 重构接口日志页面与接口监控看板,支持HTTP方法过滤和实时轮询
This commit is contained in:
@@ -15,6 +15,9 @@ class Settings(BaseSettings):
|
|||||||
tz: str = "Asia/Shanghai"
|
tz: str = "Asia/Shanghai"
|
||||||
# consecutive failures before upstream goes unhealthy
|
# consecutive failures before upstream goes unhealthy
|
||||||
unhealthy_threshold: int = 3
|
unhealthy_threshold: int = 3
|
||||||
|
# external API log retention
|
||||||
|
external_api_log_retention_days: int = 30
|
||||||
|
external_api_log_slow_ms: int = 3000
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cors_origin_list(self) -> list[str]:
|
def cors_origin_list(self) -> list[str]:
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def get_db():
|
|||||||
def init_db():
|
def init_db():
|
||||||
"""Create all tables."""
|
"""Create all tables."""
|
||||||
# import models so SQLAlchemy registers them
|
# 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)
|
Base.metadata.create_all(bind=engine)
|
||||||
_ensure_indexes()
|
_ensure_indexes()
|
||||||
_migrate_custom_pages()
|
_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_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_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_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.database import SessionLocal
|
||||||
from app.utils.auth import hash_password, verify_password, validate_password_supported
|
from app.utils.auth import hash_password, verify_password, validate_password_supported
|
||||||
from app.services.scheduler import start_scheduler, stop_scheduler
|
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")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -97,6 +97,7 @@ app.include_router(logs.router)
|
|||||||
app.include_router(custom_pages.router)
|
app.include_router(custom_pages.router)
|
||||||
app.include_router(websites.router)
|
app.include_router(websites.router)
|
||||||
app.include_router(auth_capture.router)
|
app.include_router(auth_capture.router)
|
||||||
|
app.include_router(external_api_logs.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
# models package
|
# 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,
|
auth_config=auth_config,
|
||||||
timeout=float(upstream.timeout_seconds),
|
timeout=float(upstream.timeout_seconds),
|
||||||
on_auth_config_update=lambda updated: _persist_auth_config_update(db, upstream, updated),
|
on_auth_config_update=lambda updated: _persist_auth_config_update(db, upstream, updated),
|
||||||
|
target_id=upstream.id,
|
||||||
|
target_name=upstream.name,
|
||||||
) as client:
|
) as client:
|
||||||
client.login()
|
client.login()
|
||||||
for prefix in website_sync._fetch_remote_managed_prefixes(db, uid):
|
for prefix in website_sync._fetch_remote_managed_prefixes(db, uid):
|
||||||
@@ -628,6 +630,8 @@ def generate_keys_by_groups(
|
|||||||
auth_config=auth_config,
|
auth_config=auth_config,
|
||||||
timeout=float(u.timeout_seconds),
|
timeout=float(u.timeout_seconds),
|
||||||
on_auth_config_update=lambda updated: _persist_auth_config_update(db, u, updated),
|
on_auth_config_update=lambda updated: _persist_auth_config_update(db, u, updated),
|
||||||
|
target_id=u.id,
|
||||||
|
target_name=u.name,
|
||||||
) as client:
|
) as client:
|
||||||
try:
|
try:
|
||||||
client.login()
|
client.login()
|
||||||
@@ -679,6 +683,8 @@ def _test_upstream_core(db: Session, u: Upstream) -> UpstreamBatchActionItem:
|
|||||||
auth_config=auth_config,
|
auth_config=auth_config,
|
||||||
timeout=float(u.timeout_seconds),
|
timeout=float(u.timeout_seconds),
|
||||||
on_auth_config_update=lambda updated: _persist_auth_config_update(db, u, updated),
|
on_auth_config_update=lambda updated: _persist_auth_config_update(db, u, updated),
|
||||||
|
target_id=u.id,
|
||||||
|
target_name=u.name,
|
||||||
) as client:
|
) as client:
|
||||||
client.login()
|
client.login()
|
||||||
groups = client.get_available_groups(u.groups_endpoint)
|
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,
|
auth_config=auth_config,
|
||||||
timeout=float(u.timeout_seconds),
|
timeout=float(u.timeout_seconds),
|
||||||
on_auth_config_update=lambda updated: _persist_auth_config_update(db, u, updated),
|
on_auth_config_update=lambda updated: _persist_auth_config_update(db, u, updated),
|
||||||
|
target_id=u.id,
|
||||||
|
target_name=u.name,
|
||||||
) as client:
|
) as client:
|
||||||
client.login()
|
client.login()
|
||||||
groups = client.get_available_groups(u.groups_endpoint)
|
groups = client.get_available_groups(u.groups_endpoint)
|
||||||
|
|||||||
@@ -140,6 +140,8 @@ def _client(row: Website) -> Sub2ApiWebsiteClient:
|
|||||||
auth_type=row.auth_type,
|
auth_type=row.auth_type,
|
||||||
auth_config=json.loads(row.auth_config_json or "{}"),
|
auth_config=json.loads(row.auth_config_json or "{}"),
|
||||||
timeout=float(row.timeout_seconds),
|
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 webhook_service
|
||||||
from app.services import website_sync
|
from app.services import website_sync
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
from app.routers.external_api_logs import clean_expired_logs
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -77,6 +78,8 @@ def _check_upstream(upstream_id: int) -> None:
|
|||||||
auth_config=auth_config,
|
auth_config=auth_config,
|
||||||
timeout=float(upstream.timeout_seconds),
|
timeout=float(upstream.timeout_seconds),
|
||||||
on_auth_config_update=lambda updated: _persist_auth_config_update(upstream, updated),
|
on_auth_config_update=lambda updated: _persist_auth_config_update(upstream, updated),
|
||||||
|
target_id=upstream.id,
|
||||||
|
target_name=upstream.name,
|
||||||
) as client:
|
) as client:
|
||||||
try:
|
try:
|
||||||
client.login()
|
client.login()
|
||||||
@@ -258,6 +261,8 @@ def _sync_upstream_keys(upstream_id: int, snapshot: dict[str, Any], captured_at:
|
|||||||
auth_config=auth_config,
|
auth_config=auth_config,
|
||||||
timeout=float(upstream.timeout_seconds),
|
timeout=float(upstream.timeout_seconds),
|
||||||
on_auth_config_update=lambda updated: _persist_auth_config_update(upstream, updated),
|
on_auth_config_update=lambda updated: _persist_auth_config_update(upstream, updated),
|
||||||
|
target_id=upstream.id,
|
||||||
|
target_name=upstream.name,
|
||||||
) as client:
|
) as client:
|
||||||
client.login()
|
client.login()
|
||||||
remote_key_ids = website_sync._fetch_remote_managed_key_ids(db, client, upstream_id)
|
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()
|
upstreams = db.query(Upstream).filter(Upstream.enabled == True).all()
|
||||||
for u in upstreams:
|
for u in upstreams:
|
||||||
refresh_upstream(u.id, u.check_interval_seconds, u.enabled)
|
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))
|
logger.info("scheduler started with %d upstream job(s)", len(upstreams))
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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:
|
def stop_scheduler() -> None:
|
||||||
if _scheduler.running:
|
if _scheduler.running:
|
||||||
_scheduler.shutdown(wait=True)
|
_scheduler.shutdown(wait=True)
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.utils.number import decimal_string
|
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):
|
class UpstreamError(RuntimeError):
|
||||||
@@ -429,6 +434,8 @@ class UpstreamClient:
|
|||||||
auth_config: dict[str, Any],
|
auth_config: dict[str, Any],
|
||||||
timeout: float = 30.0,
|
timeout: float = 30.0,
|
||||||
on_auth_config_update: Callable[[dict[str, Any]], None] | None = None,
|
on_auth_config_update: Callable[[dict[str, Any]], None] | None = None,
|
||||||
|
target_id: int | None = None,
|
||||||
|
target_name: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.api_prefix = api_prefix.strip("/")
|
self.api_prefix = api_prefix.strip("/")
|
||||||
@@ -436,6 +443,8 @@ class UpstreamClient:
|
|||||||
self.auth_config = auth_config
|
self.auth_config = auth_config
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.on_auth_config_update = on_auth_config_update
|
self.on_auth_config_update = on_auth_config_update
|
||||||
|
self.target_id = target_id
|
||||||
|
self.target_name = target_name
|
||||||
self._token: str = ""
|
self._token: str = ""
|
||||||
self._cookies: dict[str, str] = {}
|
self._cookies: dict[str, str] = {}
|
||||||
self._new_api_user: str = ""
|
self._new_api_user: str = ""
|
||||||
@@ -549,6 +558,51 @@ class UpstreamClient:
|
|||||||
if changed and self.on_auth_config_update:
|
if changed and self.on_auth_config_update:
|
||||||
self.on_auth_config_update(dict(self.auth_config))
|
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:
|
def _refresh_sub2api_bearer_token(self) -> bool:
|
||||||
if not self._is_sub2api_bearer():
|
if not self._is_sub2api_bearer():
|
||||||
return False
|
return False
|
||||||
@@ -556,7 +610,7 @@ class UpstreamClient:
|
|||||||
if not refresh_token:
|
if not refresh_token:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
resp = self._client.request(
|
resp = self._do_request(
|
||||||
"POST",
|
"POST",
|
||||||
self._url("/auth/refresh"),
|
self._url("/auth/refresh"),
|
||||||
json={"refresh_token": refresh_token},
|
json={"refresh_token": refresh_token},
|
||||||
@@ -587,7 +641,7 @@ class UpstreamClient:
|
|||||||
allow_refresh: bool = True,
|
allow_refresh: bool = True,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> httpx.Response:
|
) -> httpx.Response:
|
||||||
resp = self._client.request(
|
resp = self._do_request(
|
||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
headers=self._headers(auth),
|
headers=self._headers(auth),
|
||||||
@@ -602,7 +656,7 @@ class UpstreamClient:
|
|||||||
and self._is_sub2api_bearer()
|
and self._is_sub2api_bearer()
|
||||||
and self._refresh_sub2api_bearer_token()
|
and self._refresh_sub2api_bearer_token()
|
||||||
):
|
):
|
||||||
resp = self._client.request(
|
resp = self._do_request(
|
||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
headers=self._headers(auth),
|
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")
|
raise UpstreamError("Nox-API endpoint requires Nox-Api-User; please fill user id and retry")
|
||||||
url = self._url(path)
|
url = self._url(path)
|
||||||
if body is not None:
|
if body is not None:
|
||||||
resp = self._send_request(
|
resp = self._send_request(method, url, json=body, auth=auth)
|
||||||
method,
|
|
||||||
url,
|
|
||||||
json=body,
|
|
||||||
auth=auth,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
resp = self._send_request(
|
resp = self._send_request(method, url, auth=auth)
|
||||||
method,
|
|
||||||
url,
|
|
||||||
auth=auth,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
ct = resp.headers.get("content-type", "")
|
ct = resp.headers.get("content-type", "")
|
||||||
if not resp.content:
|
if not resp.content:
|
||||||
@@ -689,11 +734,7 @@ class UpstreamClient:
|
|||||||
return items, meta
|
return items, meta
|
||||||
|
|
||||||
def _request_new_api_token_list(self, path: str, params: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
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(
|
resp = self._send_request("GET", self._url(path), params=params)
|
||||||
"GET",
|
|
||||||
self._url(path),
|
|
||||||
params=params,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
self._ensure_api_success(data, "list New-API tokens")
|
self._ensure_api_success(data, "list New-API tokens")
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.utils.number import fixed_decimal_number, fixed_decimal_string
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -136,12 +139,16 @@ class Sub2ApiWebsiteClient:
|
|||||||
auth_type: str,
|
auth_type: str,
|
||||||
auth_config: dict[str, Any],
|
auth_config: dict[str, Any],
|
||||||
timeout: float = 30.0,
|
timeout: float = 30.0,
|
||||||
|
target_id: int | None = None,
|
||||||
|
target_name: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.api_prefix = api_prefix.strip("/")
|
self.api_prefix = api_prefix.strip("/")
|
||||||
self.auth_type = auth_type
|
self.auth_type = auth_type
|
||||||
self.auth_config = auth_config
|
self.auth_config = auth_config
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
self.target_id = target_id
|
||||||
|
self.target_name = target_name
|
||||||
self._client = httpx.Client(timeout=timeout)
|
self._client = httpx.Client(timeout=timeout)
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
@@ -171,21 +178,63 @@ class Sub2ApiWebsiteClient:
|
|||||||
return headers
|
return headers
|
||||||
|
|
||||||
def _request(self, method: str, path: str, body: Any = None) -> Any:
|
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:
|
try:
|
||||||
resp = self._client.request(method, self._url(path), json=body, headers=self._headers())
|
try:
|
||||||
resp.raise_for_status()
|
resp = self._client.request(method, url, json=body, headers=self._headers())
|
||||||
except httpx.HTTPStatusError as exc:
|
except httpx.TimeoutException as exc:
|
||||||
raise WebsiteError(_friendly_http_error(exc)) from exc
|
error_type, error_msg = type(exc).__name__, str(exc)[:500]
|
||||||
except httpx.TimeoutException as exc:
|
raise WebsiteError(_friendly_connection_error(exc)) from exc
|
||||||
raise WebsiteError(_friendly_connection_error(exc)) from exc
|
except httpx.ConnectError as exc:
|
||||||
except httpx.ConnectError as exc:
|
error_type, error_msg = type(exc).__name__, str(exc)[:500]
|
||||||
raise WebsiteError(_friendly_connection_error(exc)) from exc
|
raise WebsiteError(_friendly_connection_error(exc)) from exc
|
||||||
if not resp.content:
|
except httpx.HTTPStatusError as exc:
|
||||||
return None
|
error_type, error_msg = type(exc).__name__, str(exc)[:500]
|
||||||
text = resp.text
|
status_code = exc.response.status_code
|
||||||
if "application/json" not in resp.headers.get("content-type", "") and text.lstrip().startswith("<"):
|
raise WebsiteError(_friendly_http_error(exc)) from exc
|
||||||
raise WebsiteError(f"{method} {path} 返回了 HTML,请检查接口地址是否正确")
|
status_code = getattr(resp, "status_code", None)
|
||||||
return resp.json()
|
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]]:
|
def get_groups(self, endpoint: str = "/groups") -> list[dict[str, Any]]:
|
||||||
"""拉取分组列表,尝试 endpoint 和 fallback /groups/all。"""
|
"""拉取分组列表,尝试 endpoint 和 fallback /groups/all。"""
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ def _client_for(website: Website) -> Sub2ApiWebsiteClient:
|
|||||||
auth_type=website.auth_type,
|
auth_type=website.auth_type,
|
||||||
auth_config=json.loads(website.auth_config_json or "{}"),
|
auth_config=json.loads(website.auth_config_json or "{}"),
|
||||||
timeout=float(website.timeout_seconds),
|
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_type=website.auth_type,
|
||||||
auth_config=json.loads(website.auth_config_json or "{}"),
|
auth_config=json.loads(website.auth_config_json or "{}"),
|
||||||
timeout=float(website.timeout_seconds),
|
timeout=float(website.timeout_seconds),
|
||||||
|
target_id=website.id,
|
||||||
|
target_name=website.name,
|
||||||
) as client:
|
) as client:
|
||||||
accounts = client.list_accounts() or []
|
accounts = client.list_accounts() or []
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -838,6 +842,8 @@ def reconcile_upstream_keys_full(db: Session, upstream_id: int) -> bool:
|
|||||||
auth_config=auth_config,
|
auth_config=auth_config,
|
||||||
timeout=float(upstream.timeout_seconds),
|
timeout=float(upstream.timeout_seconds),
|
||||||
on_auth_config_update=lambda updated: _persist_upstream_auth_config(upstream, updated),
|
on_auth_config_update=lambda updated: _persist_upstream_auth_config(upstream, updated),
|
||||||
|
target_id=upstream.id,
|
||||||
|
target_name=upstream.name,
|
||||||
) as client:
|
) as client:
|
||||||
client.login()
|
client.login()
|
||||||
# 获取远端 Key 列表(支持自定义 managed_prefix)
|
# 获取远端 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}`),
|
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 ———
|
// ——— Auth Capture ———
|
||||||
export interface BrowserImportSession {
|
export interface BrowserImportSession {
|
||||||
session_id: string
|
session_id: string
|
||||||
|
|||||||
@@ -47,6 +47,13 @@
|
|||||||
<small>响应记录、筛选、追踪异常</small>
|
<small>响应记录、筛选、追踪异常</small>
|
||||||
</span>
|
</span>
|
||||||
</router-link>
|
</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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const router = createRouter({
|
|||||||
{ path: 'websites', component: () => import('@/views/Websites.vue') },
|
{ path: 'websites', component: () => import('@/views/Websites.vue') },
|
||||||
{ path: 'webhooks', component: () => import('@/views/Webhooks.vue') },
|
{ path: 'webhooks', component: () => import('@/views/Webhooks.vue') },
|
||||||
{ path: 'logs', component: () => import('@/views/NotificationLogs.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: 'custom-pages', component: () => import('@/views/CustomPages.vue') },
|
||||||
{ path: 'page/:id', redirect: '/custom-pages' },
|
{ path: 'page/:id', redirect: '/custom-pages' },
|
||||||
],
|
],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user