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:
@@ -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
|
||||
|
||||
# --- Video:RTSP 与按路后端(须与客户端 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;可为 []。
|
||||
|
||||
33
backend/app/access_log_filters.py
Normal file
33
backend/app/access_log_filters.py
Normal 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())
|
||||
@@ -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:
|
||||
|
||||
@@ -6,7 +6,7 @@ SURGERY_RECORDING_RETRY_DELAY_SECONDS: float = 1.0
|
||||
|
||||
# --- RTSP 连接与抽帧、推理门控(不含 URL,URL 在 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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
79
backend/app/logging_setup.py
Normal file
79
backend/app/logging_setup.py
Normal 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 logging(uvicorn 等)转发到 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
30
backend/tests/test_access_log_filters.py
Normal file
30
backend/tests/test_access_log_filters.py
Normal 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
|
||||
Reference in New Issue
Block a user