"""각 lab 컨테이너에서 import 하는 공용 액세스/이벤트 로그 모듈. 사용법: from _shared.access_log import install as install_access_log install_access_log(app) """ from collections import deque from datetime import datetime from typing import Optional import logging import time from fastapi import APIRouter, Request from fastapi.applications import FastAPI from starlette.middleware.base import BaseHTTPMiddleware # 컨테이너당 최근 500개를 in-memory 로 유지. 재시작 시 휘발. _BUFFER: deque = deque(maxlen=500) EXCLUDED_PATHS = { "/health", "/healthz", "/ping", "/favicon.ico", "/docs", "/redoc", "/openapi.json", "/logs/recent", } EXCLUDED_PREFIXES = ("/static/",) EXCLUDED_METHODS = {"OPTIONS", "HEAD"} def _should_log(request: Request) -> bool: if request.method in EXCLUDED_METHODS: return False path = request.url.path if path in EXCLUDED_PATHS: return False if any(path.startswith(p) for p in EXCLUDED_PREFIXES): return False return True class AccessLogMiddleware(BaseHTTPMiddleware): async def dispatch(self, request, call_next): start = time.time() response = await call_next(request) if not _should_log(request): return response elapsed_ms = int((time.time() - start) * 1000) status = response.status_code if status < 400: level = "info" elif status < 500: level = "warning" else: level = "error" _BUFFER.append({ "ts": datetime.utcnow().isoformat() + "Z", "level": level, "source": "access", "method": request.method, "path": request.url.path, "status": status, "ms": elapsed_ms, "message": f"{request.method} {request.url.path} → {status} ({elapsed_ms}ms)", }) return response class BufferLogHandler(logging.Handler): """root logger 에 부착하면 모든 logger.info/warning/error 가 buffer 에 흐름.""" def emit(self, record: logging.LogRecord) -> None: try: _BUFFER.append({ "ts": datetime.utcfromtimestamp(record.created).isoformat() + "Z", "level": record.levelname.lower(), "source": "log", "logger": record.name, "message": record.getMessage(), }) except Exception: # buffer 에 못 넣는다고 서비스가 죽으면 안 됨 pass router = APIRouter() @router.get("/logs/recent") def logs_recent(limit: int = 200, since: Optional[str] = None, path_prefix: Optional[str] = None): items = list(_BUFFER) if since: items = [x for x in items if x["ts"] > since] if path_prefix: items = [ x for x in items if x["source"] == "log" or x.get("path", "").startswith(path_prefix) ] return {"logs": items[-limit:]} def install(app: FastAPI, logger_root: str = "") -> None: """서비스 main.py 에서 호출하는 단일 설치 함수. - AccessLogMiddleware 등록 - /logs/recent 라우터 등록 - root logger 에 BufferLogHandler 부착 (모든 child logger 자동 전파) """ app.add_middleware(AccessLogMiddleware) app.include_router(router) root = logging.getLogger(logger_root) if not any(isinstance(h, BufferLogHandler) for h in root.handlers): root.addHandler(BufferLogHandler())