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

@@ -16,4 +16,4 @@ COPY . .
EXPOSE 8100 EXPOSE 8100
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8100 --workers ${BACKEND_WORKERS:-1}"] CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8100 --workers ${BACKEND_WORKERS:-1} --no-access-log"]

View File

@@ -1,8 +1,13 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter 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.core.responses import api_success
from app.services.redis_client import get_sync_redis_client
router = APIRouter() router = APIRouter()
@@ -10,3 +15,31 @@ router = APIRouter()
@router.get("/health") @router.get("/health")
def health() -> dict: def health() -> dict:
return api_success({"status": "ok"}) 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_name: str = "AI Knowledge Base V2 Backend"
app_env: str = "local" app_env: str = "local"
debug: bool = True debug: bool = True
log_level: str = "INFO"
api_prefix: str = "/api" api_prefix: str = "/api"
cors_origins: list[str] = Field( cors_origins: list[str] = Field(
default_factory=lambda: [ 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" database_url: str = "mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4"
redis_url: str = "" redis_url: str = ""
readiness_requires_redis: bool = True
db_pool_size: int = 10 db_pool_size: int = 10
db_max_overflow: int = 20 db_max_overflow: int = 20
db_pool_timeout: int = 30 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.config import get_settings
from app.core.database import create_tables from app.core.database import create_tables
from app.core.exception_handlers import register_exception_handlers from app.core.exception_handlers import register_exception_handlers
from app.core.observability import RequestObservabilityMiddleware, configure_logging
from app.services.secret_service import SecretService from app.services.secret_service import SecretService
@@ -22,9 +23,11 @@ async def lifespan(app: FastAPI):
settings = get_settings() settings = get_settings()
configure_logging(settings.log_level)
app = FastAPI(title=settings.app_name, version="0.1.0", lifespan=lifespan) app = FastAPI(title=settings.app_name, version="0.1.0", lifespan=lifespan)
register_exception_handlers(app) register_exception_handlers(app)
app.add_middleware(RequestObservabilityMiddleware)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,

View File

@@ -5,7 +5,9 @@ import asyncio
import json import json
import pytest import pytest
import httpx
from fastapi import HTTPException from fastapi import HTTPException
from fastapi import FastAPI
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
@@ -19,6 +21,7 @@ from app.services.chat_queue_service import ChatQueueConfig, QueueRequest
from app.services.chat_service import ChatService from app.services.chat_service import ChatService
from app.services.secret_service import ENCRYPTED_PREFIX, MASKED_SECRET, SecretService from app.services.secret_service import ENCRYPTED_PREFIX, MASKED_SECRET, SecretService
from app.services.security_state_service import SecurityStateService from app.services.security_state_service import SecurityStateService
from app.core.observability import RequestObservabilityMiddleware
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -113,3 +116,32 @@ def test_sensitive_system_config_never_returns_plaintext():
config = SystemConfig(config_key="feishu_app_secret", config_value="plain-secret") config = SystemConfig(config_key="feishu_app_secret", config_value="plain-secret")
result = _config_dict(config) result = _config_dict(config)
assert result["configValue"] == MASKED_SECRET assert result["configValue"] == MASKED_SECRET
def test_request_id_is_returned_without_breaking_response():
app = FastAPI()
app.add_middleware(RequestObservabilityMiddleware)
@app.get("/test")
def endpoint():
return {"ok": True}
async def make_request():
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
return await client.get("/test", headers={"X-Request-ID": "trace-123"})
response = asyncio.run(make_request())
assert response.status_code == 200
assert response.headers["X-Request-ID"] == "trace-123"
assert response.json() == {"ok": True}
def test_readiness_reports_dependency_state(monkeypatch):
from app.api import health
monkeypatch.setattr(health, "_database_ready", lambda: True)
monkeypatch.setattr(health, "_redis_ready", lambda: True)
result = health.ready()
assert result["data"]["status"] == "ready"
assert result["data"]["checks"] == {"database": True, "redis": True}

View File

@@ -40,6 +40,8 @@ services:
DEBUG: "false" DEBUG: "false"
DATABASE_URL: ${DATABASE_URL:?必须配置经过 URL 编码的 DATABASE_URL} DATABASE_URL: ${DATABASE_URL:?必须配置经过 URL 编码的 DATABASE_URL}
REDIS_URL: ${REDIS_URL:?必须配置经过 URL 编码的 REDIS_URL} REDIS_URL: ${REDIS_URL:?必须配置经过 URL 编码的 REDIS_URL}
READINESS_REQUIRES_REDIS: "true"
LOG_LEVEL: INFO
BACKEND_WORKERS: ${BACKEND_WORKERS:-4} BACKEND_WORKERS: ${BACKEND_WORKERS:-4}
DB_POOL_SIZE: ${DB_POOL_SIZE:-20} DB_POOL_SIZE: ${DB_POOL_SIZE:-20}
DB_MAX_OVERFLOW: ${DB_MAX_OVERFLOW:-20} DB_MAX_OVERFLOW: ${DB_MAX_OVERFLOW:-20}
@@ -58,6 +60,12 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8100/api/ready', timeout=3)"]
interval: 15s
timeout: 5s
retries: 10
start_period: 30s
user-client: user-client:
build: build:
@@ -79,9 +87,12 @@ services:
volumes: volumes:
- ./deploy/gateway.prod.conf:/etc/nginx/conf.d/default.conf:ro - ./deploy/gateway.prod.conf:/etc/nginx/conf.d/default.conf:ro
depends_on: depends_on:
- backend backend:
- user-client condition: service_healthy
- admin-web user-client:
condition: service_started
admin-web:
condition: service_started
volumes: volumes:
ai_kb_v2_mysql_data: ai_kb_v2_mysql_data: