feat: 增加结构化日志和服务就绪检查

This commit is contained in:
2026-07-10 12:14:19 +08:00
parent ebaf3defa2
commit d7d9514348
7 changed files with 184 additions and 4 deletions

View File

@@ -1,8 +1,13 @@
from __future__ import annotations
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from sqlalchemy import text
from app.core.config import get_settings
from app.core.database import SessionLocal
from app.core.responses import api_success
from app.services.redis_client import get_sync_redis_client
router = APIRouter()
@@ -10,3 +15,31 @@ router = APIRouter()
@router.get("/health")
def health() -> dict:
return api_success({"status": "ok"})
@router.get("/ready")
def ready():
checks = {"database": _database_ready(), "redis": _redis_ready()}
redis_required = get_settings().readiness_requires_redis
ready_state = checks["database"] and (checks["redis"] or not redis_required)
payload = api_success({"status": "ready" if ready_state else "not_ready", "checks": checks})
return payload if ready_state else JSONResponse(status_code=503, content=payload)
def _database_ready() -> bool:
try:
with SessionLocal() as db:
db.execute(text("SELECT 1"))
return True
except Exception:
return False
def _redis_ready() -> bool:
client = get_sync_redis_client()
if client is None:
return False
try:
return bool(client.ping())
except Exception:
return False

View File

@@ -12,6 +12,7 @@ class Settings(BaseSettings):
app_name: str = "AI Knowledge Base V2 Backend"
app_env: str = "local"
debug: bool = True
log_level: str = "INFO"
api_prefix: str = "/api"
cors_origins: list[str] = Field(
default_factory=lambda: [
@@ -26,6 +27,7 @@ class Settings(BaseSettings):
database_url: str = "mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4"
redis_url: str = ""
readiness_requires_redis: bool = True
db_pool_size: int = 10
db_max_overflow: int = 20
db_pool_timeout: int = 30

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
from contextvars import ContextVar
from datetime import UTC, datetime
import json
import logging
from logging.config import dictConfig
from time import perf_counter
from uuid import uuid4
request_id_context: ContextVar[str] = ContextVar("request_id", default="-")
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"timestamp": datetime.now(UTC).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"request_id": getattr(record, "request_id", None) or request_id_context.get(),
}
for key in ("method", "path", "status_code", "duration_ms", "client_ip"):
value = getattr(record, key, None)
if value is not None:
payload[key] = value
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
def configure_logging(level: str = "INFO") -> None:
dictConfig(
{
"version": 1,
"disable_existing_loggers": False,
"formatters": {"json": {"()": JsonFormatter}},
"handlers": {"console": {"class": "logging.StreamHandler", "formatter": "json", "stream": "ext://sys.stdout"}},
"loggers": {
"app": {"handlers": ["console"], "level": level, "propagate": False},
"app.access": {"handlers": ["console"], "level": level, "propagate": False},
},
}
)
class RequestObservabilityMiddleware:
def __init__(self, app) -> None:
self.app = app
self.logger = logging.getLogger("app.access")
async def __call__(self, scope, receive, send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
headers = {key.lower(): value for key, value in scope.get("headers", [])}
incoming_id = headers.get(b"x-request-id", b"").decode("ascii", errors="ignore").strip()
request_id = incoming_id[:64] if incoming_id else uuid4().hex
token = request_id_context.set(request_id)
started_at = perf_counter()
status_code = 500
response_finished = False
async def send_with_request_id(message) -> None:
nonlocal status_code, response_finished
if message["type"] == "http.response.start":
status_code = int(message["status"])
message.setdefault("headers", []).append((b"x-request-id", request_id.encode("ascii")))
elif message["type"] == "http.response.body" and not message.get("more_body", False):
response_finished = True
await send(message)
try:
await self.app(scope, receive, send_with_request_id)
except Exception:
self._log(scope, request_id, 500, started_at, logging.ERROR, "request_failed", exc_info=True)
raise
finally:
if response_finished:
self._log(scope, request_id, status_code, started_at, logging.INFO, "request_completed")
request_id_context.reset(token)
def _log(self, scope, request_id: str, status_code: int, started_at: float, level: int, message: str, **kwargs) -> None:
client = scope.get("client")
self.logger.log(
level,
message,
extra={
"request_id": request_id,
"method": scope.get("method"),
"path": scope.get("path"),
"status_code": status_code,
"duration_ms": round((perf_counter() - started_at) * 1000, 2),
"client_ip": client[0] if client else "unknown",
},
**kwargs,
)

View File

@@ -9,6 +9,7 @@ from app.api.router import api_router
from app.core.config import get_settings
from app.core.database import create_tables
from app.core.exception_handlers import register_exception_handlers
from app.core.observability import RequestObservabilityMiddleware, configure_logging
from app.services.secret_service import SecretService
@@ -22,9 +23,11 @@ async def lifespan(app: FastAPI):
settings = get_settings()
configure_logging(settings.log_level)
app = FastAPI(title=settings.app_name, version="0.1.0", lifespan=lifespan)
register_exception_handlers(app)
app.add_middleware(RequestObservabilityMiddleware)
app.add_middleware(
CORSMiddleware,