Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 50 additions & 40 deletions backend/app/core/error_reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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},
)
3 changes: 2 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions bot/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}

Expand Down
11 changes: 9 additions & 2 deletions bot/app/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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("실패 알림 전송도 실패")
51 changes: 40 additions & 11 deletions bot/app/utils/stack_trace_parser.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,56 @@
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)

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,
Expand Down
Loading