From a7d06706058f46002ea664c5d96e25aff7a276c6 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Mon, 30 Mar 2026 22:47:53 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20error-bot=20PR=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Docker 컨테이너 traceback 경로(/app/app/...)를 프로젝트 상대 경로로 정규화 - HTTPException(500+) 발생 시 실제 스택트레이스를 캡처하여 error-bot에 전송 - 파이프라인 실패(파싱/파일읽기/내부오류) 시 Discord 알림 추가 --- backend/app/core/error_reporter.py | 90 ++++++++++++++++------------- backend/app/main.py | 3 +- bot/app/config.py | 1 + bot/app/pipeline.py | 11 +++- bot/app/utils/stack_trace_parser.py | 51 ++++++++++++---- 5 files changed, 102 insertions(+), 54 deletions(-) diff --git a/backend/app/core/error_reporter.py b/backend/app/core/error_reporter.py index 9a85926..66d8e39 100644 --- a/backend/app/core/error_reporter.py +++ b/backend/app/core/error_reporter.py @@ -3,7 +3,8 @@ from datetime import datetime, timezone import httpx -from fastapi import Request, Response +from fastapi import FastAPI, HTTPException, Request, Response +from fastapi.responses import JSONResponse from starlette.middleware.base import ( BaseHTTPMiddleware, RequestResponseEndpoint, @@ -14,57 +15,66 @@ logger = logging.getLogger(__name__) +async def _send_to_error_bot( + request: Request, + error_type: str, + error_message: str, + stack_trace: str, +) -> None: + """error-bot에 에러 리포트를 전송.""" + if not settings.error_bot_url: + return + + payload = { + "errorType": error_type, + "errorMessage": error_message, + "stackTrace": stack_trace, + "requestUrl": f"{request.method} {request.url.path}", + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + try: + async with httpx.AsyncClient(timeout=5.0) as client: + url = f"{settings.error_bot_url}/webhook/error" + await client.post(url, json=payload) + except Exception: + logger.warning("error-bot에 에러 리포트 전송 실패", exc_info=True) + + class ErrorReporterMiddleware(BaseHTTPMiddleware): - """500 에러 발생 시 error-bot에 에러 리포트를 전송.""" + """미들웨어 밖으로 전파되는 unhandled exception을 error-bot에 전송.""" async def dispatch( self, request: Request, call_next: RequestResponseEndpoint ) -> Response: try: - response = await call_next(request) + return await call_next(request) except Exception as exc: tb = traceback.format_exc() - await self._report_error( + await _send_to_error_bot( request, type(exc).__name__, str(exc), tb ) raise - if response.status_code >= 500: - method = request.method - path = request.url.path - code = response.status_code - await self._report_error( - request, - error_type=f"HTTP {code}", - error_message=f"{method} {path} returned {code}", - stack_trace="(응답 코드 기반 감지)", - ) - return response +def register_error_handlers(app: FastAPI) -> None: + """HTTPException(500+) 스택트레이스를 캡처하는 handler 등록.""" - async def _report_error( - self, - request: Request, - error_type: str, - error_message: str, - stack_trace: str, - ) -> None: - if not settings.error_bot_url: - return - - payload = { - "errorType": error_type, - "errorMessage": error_message, - "stackTrace": stack_trace, - "requestUrl": f"{request.method} {request.url.path}", - "timestamp": datetime.now(timezone.utc).isoformat(), - } - - try: - async with httpx.AsyncClient(timeout=5.0) as client: - url = f"{settings.error_bot_url}/webhook/error" - await client.post(url, json=payload) - except Exception: - logger.warning( - "error-bot에 에러 리포트 전송 실패", exc_info=True + @app.exception_handler(HTTPException) + async def http_exception_handler( + request: Request, exc: HTTPException + ) -> JSONResponse: + if exc.status_code >= 500 and exc.__traceback__: + tb = "".join( + traceback.format_exception(type(exc), exc, exc.__traceback__) + ) + await _send_to_error_bot( + request, + error_type=type(exc).__name__, + error_message=str(exc.detail), + stack_trace=tb, ) + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail}, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 99c36aa..a55e5f0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,7 +14,7 @@ from app.config import settings from app.core.background_sync import sync_all_users from app.core.database import Base, engine -from app.core.error_reporter import ErrorReporterMiddleware +from app.core.error_reporter import ErrorReporterMiddleware, register_error_handlers from app.mail.routers.classify import router as classify_router from app.mail.routers.gmail import router as gmail_router from app.mail.routers.inbox import router as inbox_router @@ -63,6 +63,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: if settings.error_bot_url: app.add_middleware(ErrorReporterMiddleware) + register_error_handlers(app) app.include_router(auth_router) diff --git a/bot/app/config.py b/bot/app/config.py index 86ee131..2dd6bfd 100644 --- a/bot/app/config.py +++ b/bot/app/config.py @@ -11,6 +11,7 @@ class Settings(BaseSettings): bot_port: int = 8001 import_depth: int = 2 local_source_path: str = "" + container_workdir: str = "/app" model_config = {"env_file": ".env"} diff --git a/bot/app/pipeline.py b/bot/app/pipeline.py index be1ed92..33d37ee 100644 --- a/bot/app/pipeline.py +++ b/bot/app/pipeline.py @@ -100,16 +100,19 @@ async def process_error(report: ErrorReport) -> None: await send_error_alert(report) # 2. 스택트레이스 파싱 - entries = parse_stack_trace(report.stackTrace, settings.project_root) + entries = parse_stack_trace(report.stackTrace, settings.project_root, settings.container_workdir) if not entries: logger.warning("스택트레이스에서 프로젝트 코드를 찾지 못함") + await send_failure_alert(report, "스택트레이스에서 프로젝트 코드를 찾지 못함") return # 3. 소스코드 조회 (로컬 파일 읽기 — 빠르므로 executor 불필요) file_paths = [e["file"] for e in entries] files = read_files(file_paths) if not files: + reason = f"파일 조회 실패: {', '.join(file_paths[:5])}" logger.warning("파일을 조회하지 못함: %s", file_paths) + await send_failure_alert(report, reason) return # 3-1. import 기반 관련 파일 추가 fetch (N depth) @@ -215,5 +218,9 @@ async def process_error(report: ErrorReport) -> None: # 8. Discord PR 완료 알림 await send_pr_alert(pr_url, summary) - except Exception: + except Exception as e: logger.exception("에러 처리 중 실패") + try: + await send_failure_alert(report, f"파이프라인 내부 오류: {e}") + except Exception: + logger.exception("실패 알림 전송도 실패") diff --git a/bot/app/utils/stack_trace_parser.py b/bot/app/utils/stack_trace_parser.py index c19eb18..a2a15b4 100644 --- a/bot/app/utils/stack_trace_parser.py +++ b/bot/app/utils/stack_trace_parser.py @@ -1,13 +1,47 @@ import re -def parse_stack_trace(stack_trace: str, project_root: str) -> list[dict]: +def _normalize_path(file_path: str, project_root: str, container_workdir: str) -> str | None: + """traceback 파일 경로를 프로젝트 상대 경로로 정규화. + + 로컬 개발: "backend/app/mail/services/gmail.py" → 그대로 + Docker: "/app/app/mail/services/gmail.py" → "backend/app/mail/services/gmail.py" + """ + # Case 1: 이미 project_root가 포함됨 (로컬 개발 환경) + if project_root in file_path: + idx = file_path.find(project_root) + return file_path[idx:] + + # Case 2: Docker 컨테이너 경로 → 프로젝트 상대 경로로 변환 + # project_root = "backend/app" → prefix = "backend/", module_dir = "app" + parts = project_root.rstrip("/").split("/") + if len(parts) < 2: + return None + prefix = "/".join(parts[:-1]) + "/" # "backend/" + module_dir = parts[-1] # "app" + + # Docker WORKDIR 접두사 제거: /app/app/mail/... → app/mail/... + stripped = file_path + cwd = container_workdir.rstrip("/") + "/" + if stripped.startswith(cwd): + stripped = stripped[len(cwd):] + + # module_dir로 시작하는지 확인 (app/mail/... 형태) + if stripped.startswith(module_dir + "/"): + return prefix + stripped + + return None + + +def parse_stack_trace( + stack_trace: str, project_root: str, container_workdir: str = "/app" +) -> list[dict]: """ Python traceback에서 프로젝트 코드만 추출. - 예: project_root = "backend/app" - 입력: 'File "backend/app/mail/services/gmail.py", line 45, in sync_gmail' - 출력: [{"file": "backend/app/mail/services/gmail.py", "line": 45, "method": "sync_gmail"}] + 로컬: File "backend/app/mail/services/gmail.py", line 45, in sync_gmail + Docker: File "/app/app/mail/services/gmail.py", line 45, in sync_gmail + → [{"file": "backend/app/mail/services/gmail.py", "line": 45, "method": "sync_gmail"}] """ pattern = r'File "([^"]+)", line (\d+), in (\w+)' matches = re.findall(pattern, stack_trace) @@ -15,13 +49,8 @@ def parse_stack_trace(stack_trace: str, project_root: str) -> list[dict]: results = [] seen = set() for file_path, line, method in matches: - # project_root에 포함된 파일만 필터 - if project_root not in file_path: - continue - # 경로 정규화: 절대 경로에서 프로젝트 상대 경로 추출 - idx = file_path.find(project_root) - relative_path = file_path[idx:] - if relative_path not in seen: + relative_path = _normalize_path(file_path, project_root, container_workdir) + if relative_path and relative_path not in seen: seen.add(relative_path) results.append({ "file": relative_path,