Add rotating file logs, access noise filters, and longer RTSP start timeout.

Persist app logs under logs/app with 7-day rotation, suppress routine health/pending access lines, raise default VIDEO_OPEN_TIMEOUT_SEC to 90s, and document consumable codes plus client timeout guidance.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-26 13:11:30 +08:00
parent 5fd0851a44
commit ae6300b8b2
12 changed files with 584 additions and 71 deletions

View File

@@ -28,13 +28,22 @@ POSTGRES_PORT=45432
# http://<GPU服务器局域网IP>:38080 作为服务端 Base URL。
# API_PORT=38080
# --- 应用日志loguru 持久化;目录已在 .gitignore---
# APP_LOG_DIR=logs/app
# APP_LOG_FILE_NAME=server.log
# APP_LOG_FILE_ENABLED=true
# APP_LOG_ROTATION=7 days
# APP_LOG_RETENTION=7 days
# APP_LOG_LEVEL=INFO
# --- GPU 推理(可选覆盖,默认自动选 cuda:0---
# CONSUMABLE_CLASSIFIER_DEVICE=cuda:0
# HAND_DETECTION_DEVICE=cuda:0
# --- VideoRTSP 与按路后端(须与客户端 camera_ids 一致)---
# 单路 RTSP 首次打开超时(秒);四路须在「该值 + 5」秒内全部就绪,穿透/公网链路可调大(默认 45
# VIDEO_OPEN_TIMEOUT_SEC=45
# 单路 RTSP 首次打开超时(秒);开录 API 在「该值 + 5」秒内确认主摄就绪,失败最多重试 3 次
# 客户端 HTTP 超时建议 ≥ 300s默认 90+5 秒 × 3 次)。穿透/公网链路可调大。
# VIDEO_OPEN_TIMEOUT_SEC=90
# VIDEO_DEFAULT_BACKEND=rtsp
# VIDEO_CAMERA_BACKEND_OVERRIDES_JSON={"or-cam-01":"rtsp","or-cam-02":"hikvision_sdk"}
# 站点 JSON术间↔摄像头↔语音终端只在这里维护voice_or_room_bindings须同时含 video_rtsp_urls可为 []。

View File

@@ -0,0 +1,33 @@
"""Uvicorn access log 降噪:隐藏常态轮询/探活,保留异常与业务相关请求。"""
from __future__ import annotations
import logging
def should_log_uvicorn_access_line(message: str) -> bool:
"""Return False to suppress a uvicorn access log line."""
msg = message or ""
if "GET" in msg and " 200 " in msg and "/health" in msg:
return False
if "GET" in msg and " 404 " in msg and "/pending-confirmation" in msg:
return False
return True
class _UvicornAccessNoiseFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
try:
msg = record.getMessage()
except Exception:
return True
return should_log_uvicorn_access_line(msg)
def install_uvicorn_access_log_filters() -> None:
"""Install filters on uvicorn.access (idempotent for repeated calls)."""
access_logger = logging.getLogger("uvicorn.access")
for existing in access_logger.filters:
if isinstance(existing, _UvicornAccessNoiseFilter):
return
access_logger.addFilter(_UvicornAccessNoiseFilter())

View File

@@ -123,7 +123,6 @@ async def _call_recording_with_retries(
@router.get("/health", response_model=HealthResponse, tags=["health"])
async def health() -> HealthResponse | JSONResponse:
logger.debug("Health check")
try:
await check_database()
except SQLAlchemyError as exc:

View File

@@ -6,7 +6,7 @@ SURGERY_RECORDING_RETRY_DELAY_SECONDS: float = 1.0
# --- RTSP 连接与抽帧、推理门控(不含 URLURL 在 Settings---
# 运行时以 Settings.video_open_timeout_sec环境变量 VIDEO_OPEN_TIMEOUT_SEC为准此处为未注入 Settings 时的回退默认。
VIDEO_OPEN_TIMEOUT_SEC: float = 45.0
VIDEO_OPEN_TIMEOUT_SEC: float = 90.0
VIDEO_READ_FAILURE_RECONNECT_THRESHOLD: int = 15
VIDEO_RECONNECT_BACKOFF_SECONDS: float = 1.0
VIDEO_INFERENCE_INTERVAL_SEC: float = 2.0

View File

@@ -115,6 +115,12 @@ class _ServerGroup(_SettingsGroup):
"server_host",
"server_port",
"server_reload",
"app_log_dir",
"app_log_file_name",
"app_log_file_enabled",
"app_log_rotation",
"app_log_retention",
"app_log_level",
)
@@ -143,12 +149,37 @@ class Settings(BaseSettings):
validation_alias=AliasChoices("server_reload", "UVICORN_RELOAD"),
)
app_log_dir: str = Field(
default="logs/app",
validation_alias=AliasChoices("APP_LOG_DIR", "app_log_dir"),
)
app_log_file_name: str = Field(
default="server.log",
validation_alias=AliasChoices("APP_LOG_FILE_NAME", "app_log_file_name"),
)
app_log_file_enabled: bool = Field(
default=True,
validation_alias=AliasChoices("APP_LOG_FILE_ENABLED", "app_log_file_enabled"),
)
app_log_rotation: str = Field(
default="7 days",
validation_alias=AliasChoices("APP_LOG_ROTATION", "app_log_rotation"),
)
app_log_retention: str = Field(
default="7 days",
validation_alias=AliasChoices("APP_LOG_RETENTION", "app_log_retention"),
)
app_log_level: str = Field(
default="INFO",
validation_alias=AliasChoices("APP_LOG_LEVEL", "app_log_level"),
)
video_default_backend: Literal["rtsp", "hikvision_sdk", "auto"] = "rtsp"
video_camera_backend_overrides_json: str = ""
video_rtsp_url_template: str = ""
#: 单路 RTSP 首次打开超时(秒);术间「全部摄像头就绪」等待为该值 + 5s。穿透/公网链路可调大。
video_open_timeout_sec: float = Field(
default=45.0,
default=90.0,
ge=5.0,
le=900.0,
validation_alias=AliasChoices("VIDEO_OPEN_TIMEOUT_SEC", "video_open_timeout_sec"),

View File

@@ -0,0 +1,79 @@
"""Loguru + stdlib 桥接stderr、持久化文件7 天轮转)。"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
from loguru import logger
from app.config import Settings
_LOG_FORMAT = (
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan> - <level>{message}</level>"
)
_FILE_LOG_FORMAT = (
"{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function} - {message}"
)
class InterceptHandler(logging.Handler):
"""将 stdlib logginguvicorn 等)转发到 loguru。"""
def emit(self, record: logging.LogRecord) -> None:
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
frame = logging.currentframe()
depth = 2
while frame is not None and frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
def _resolve_app_log_path(settings: Settings) -> Path:
repo_root = Path(__file__).resolve().parent.parent
log_dir = Path(settings.app_log_dir).expanduser()
if not log_dir.is_absolute():
log_dir = repo_root / log_dir
log_dir.mkdir(parents=True, exist_ok=True)
return log_dir / settings.app_log_file_name
def configure_application_logging(settings: Settings) -> Path | None:
"""配置 loguru sink 并桥接 uvicorn/fastapi stdlib 日志。返回文件路径(若启用)。"""
logger.remove()
logger.add(sys.stderr, format=_LOG_FORMAT, level=settings.app_log_level)
log_path: Path | None = None
if settings.app_log_file_enabled:
log_path = _resolve_app_log_path(settings)
logger.add(
str(log_path),
format=_FILE_LOG_FORMAT,
level=settings.app_log_level,
rotation=settings.app_log_rotation,
retention=settings.app_log_retention,
encoding="utf-8",
enqueue=True,
)
logger.info(
"Application log file enabled: {} (rotation={}, retention={})",
log_path,
settings.app_log_rotation,
settings.app_log_retention,
)
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
for name in ("uvicorn", "uvicorn.error", "uvicorn.access", "fastapi"):
lib_logger = logging.getLogger(name)
lib_logger.handlers = [InterceptHandler()]
lib_logger.propagate = False
return log_path

View File

@@ -116,6 +116,11 @@ services:
RTSP_PRIMARY_CAMERA_ID: ${RTSP_PRIMARY_CAMERA_ID:-or-cam-03}
RTSP_RECORD_ALL_CAMERAS: ${RTSP_RECORD_ALL_CAMERAS:-false}
RTSP_SEGMENT_DURATION_SEC: ${RTSP_SEGMENT_DURATION_SEC:-120}
VIDEO_OPEN_TIMEOUT_SEC: ${VIDEO_OPEN_TIMEOUT_SEC:-90}
APP_LOG_DIR: ${APP_LOG_DIR:-logs/app}
APP_LOG_FILE_ENABLED: ${APP_LOG_FILE_ENABLED:-true}
APP_LOG_ROTATION: ${APP_LOG_ROTATION:-7 days}
APP_LOG_RETENTION: ${APP_LOG_RETENTION:-7 days}
# api 在 Compose 桥接网内反代 mediamtx-hls:8888宿主机调试 HLS 仍用 127.0.0.1:18888
DEMO_HLS_PREVIEW_UPSTREAM: ${DEMO_HLS_PREVIEW_UPSTREAM:-http://mediamtx-hls:8888}
DEMO_HLS_PREVIEW_CONFIG_DIR: ${DEMO_HLS_PREVIEW_CONFIG_DIR:-/hls-preview-config}
@@ -128,6 +133,7 @@ services:
- "${API_PORT:-38080}:8000"
volumes:
- hls_preview_config:/hls-preview-config
- ./logs:/app/logs
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
db:

View File

@@ -1,5 +1,3 @@
import logging
import sys
from contextlib import asynccontextmanager
from pathlib import Path
@@ -8,39 +6,12 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from loguru import logger
from app.access_log_filters import install_uvicorn_access_log_filters
from app.api import router as api_router
from app.config import settings
from app.database import check_database, engine
from app.dependencies import build_container
def _configure_uvicorn_access_log_filters() -> None:
"""第三方或 Demo 若轮询 pending-confirmation无条目时 404 为常态;压低 uvicorn access 刷屏。"""
class _SuppressPendingPoll404(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
try:
msg = record.getMessage()
except Exception:
return True
if "/pending-confirmation" in msg and "GET" in msg and " 404 " in msg:
return False
return True
logging.getLogger("uvicorn.access").addFilter(_SuppressPendingPoll404())
def configure_logging() -> None:
"""集中配置 loguru sink由 create_app 显式调用,避免 import-time 副作用。"""
logger.remove()
logger.add(
sys.stderr,
format=(
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan> - <level>{message}</level>"
),
)
from app.logging_setup import configure_application_logging
@asynccontextmanager
@@ -89,8 +60,8 @@ async def lifespan(app: FastAPI):
def create_app() -> FastAPI:
configure_logging()
_configure_uvicorn_access_log_filters()
configure_application_logging(settings)
install_uvicorn_access_log_filters()
application = FastAPI(
title="Operation Room Monitor",
lifespan=lifespan,

View File

@@ -0,0 +1,30 @@
from app.access_log_filters import should_log_uvicorn_access_line
def test_suppress_health_200() -> None:
line = '127.0.0.1:52854 - "GET /health HTTP/1.1" 200 OK'
assert should_log_uvicorn_access_line(line) is False
def test_keep_health_503() -> None:
line = '127.0.0.1:52854 - "GET /health HTTP/1.1" 503 Service Unavailable'
assert should_log_uvicorn_access_line(line) is True
def test_suppress_pending_poll_404() -> None:
line = (
'192.168.3.140:12345 - "GET /client/surgeries/123456/pending-confirmation HTTP/1.1" 404 Not Found'
)
assert should_log_uvicorn_access_line(line) is False
def test_keep_pending_200() -> None:
line = (
'192.168.3.140:12345 - "GET /client/surgeries/123456/pending-confirmation HTTP/1.1" 200 OK'
)
assert should_log_uvicorn_access_line(line) is True
def test_keep_start_post() -> None:
line = '192.168.3.85:9999 - "POST /client/surgeries/start HTTP/1.1" 503 Service Unavailable'
assert should_log_uvicorn_access_line(line) is True