feat: 增加结构化日志和服务就绪检查
This commit is contained in:
@@ -16,4 +16,4 @@ COPY . .
|
||||
|
||||
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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
99
ai_knowledge_base_v2/apps/backend/app/core/observability.py
Normal file
99
ai_knowledge_base_v2/apps/backend/app/core/observability.py
Normal 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,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -5,7 +5,9 @@ import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
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.secret_service import ENCRYPTED_PREFIX, MASKED_SECRET, SecretService
|
||||
from app.services.security_state_service import SecurityStateService
|
||||
from app.core.observability import RequestObservabilityMiddleware
|
||||
|
||||
|
||||
@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")
|
||||
result = _config_dict(config)
|
||||
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}
|
||||
|
||||
@@ -40,6 +40,8 @@ services:
|
||||
DEBUG: "false"
|
||||
DATABASE_URL: ${DATABASE_URL:?必须配置经过 URL 编码的 DATABASE_URL}
|
||||
REDIS_URL: ${REDIS_URL:?必须配置经过 URL 编码的 REDIS_URL}
|
||||
READINESS_REQUIRES_REDIS: "true"
|
||||
LOG_LEVEL: INFO
|
||||
BACKEND_WORKERS: ${BACKEND_WORKERS:-4}
|
||||
DB_POOL_SIZE: ${DB_POOL_SIZE:-20}
|
||||
DB_MAX_OVERFLOW: ${DB_MAX_OVERFLOW:-20}
|
||||
@@ -58,6 +60,12 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
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:
|
||||
build:
|
||||
@@ -79,9 +87,12 @@ services:
|
||||
volumes:
|
||||
- ./deploy/gateway.prod.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- backend
|
||||
- user-client
|
||||
- admin-web
|
||||
backend:
|
||||
condition: service_healthy
|
||||
user-client:
|
||||
condition: service_started
|
||||
admin-web:
|
||||
condition: service_started
|
||||
|
||||
volumes:
|
||||
ai_kb_v2_mysql_data:
|
||||
|
||||
Reference in New Issue
Block a user