o2o-site-AEO/solution/backend/common/collect_diagnostics.py
Mina Choi b4085a0e0f [fix] solution: 온보딩 생성·크롤링 진단·장애 알림 묶음
운영 번들 자동 로그인 자격증명 유출, 온보딩 COPY 잡이 Gemini 429 로 죽던 것,
크롤링 실패가 로그에만 남던 것을 한 번에 정리한다. 실측(2026-09-15 밤, 킹서버):
사진분석 배치가 Gemini 분당 쿼터를 다 써서 같은 키를 쓰는 온보딩 COPY 잡도 같이
429 를 맞고 DEAD 로 갔다 — 확인된 fact 만으로도 편집·발행이 되는데 잡을 죽일
이유가 없었다.

- solution/frontend: `VITE_AUTO_LOGIN_ID`·`PW` 를 운영 진입점에 안 넘긴다(자동 로그인은
  dev 서버 전용) + `Step5Generating` 겉모습을 이전 카드 스타일로, 데이터는 실제 잡
  진행(useGenerationJob) 그대로
- solution/backend: copy_service — Gemini 호출 실패해도 잡을 안 죽이고 fact 만으로 계속.
  db_session_manager — 유니크 제약 충돌(정상 경로) 로그를 ERROR → WARN.
  worker/runner + alert_service + teams_webhook — 잡 dead-letter·발행 실패·큐 정체를
  Teams 로 알림(영구 저장 + 재시도 + dedupe). `/readyz` 추가.
  collect_diagnostics(신규) — 크롤링 채널별 실패를 jobs.result 에 구조화해서 싣는다.
- postgres-init: 0015(users token_version) · 0016(alert_outbox) 마이그레이션

검증: 백엔드 pytest 759 passed. tsc(solution/frontend) 통과. Teams 알림 실채널 수신 확인.
2026-09-16 16:25:02 +09:00

60 lines
2.2 KiB
Python

"""수집(크롤링) 중 실패를 jobs.result 에 구조화해서 싣는다 — 워커 로그 grep 없이 확인용.
★ contextvars 로 든다 — 실패 지점이 흩어진 여러 함수에 리스트를 관통시키지 않는다.
자세한 배경은 DEVLOG.md 참고.
"""
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import asdict, dataclass
from common.logger import LOG
_current: ContextVar[list["CollectIssue"] | None] = ContextVar("_collect_issues", default=None)
# jobs.result 는 DB 에 그대로 쌓인다 — 예외 메시지가 길어지는(HTML 응답 전체를 문 등) 경우가
# 있어 상한을 둔다. 잘린 메시지도 원인 파악엔 충분하고, 전체는 여전히 로그에 남는다.
_MAX_MESSAGE = 500
_MAX_TARGET = 200
@dataclass
class CollectIssue:
stage: str # 어느 단계에서(예: "naver_place" · "tour_api" · "yanolja" · "static_html")
target: str # 무엇을 하다가(URL·검색어 등)
error_type: str # 예외 클래스명
message: str # 예외 메시지
@contextmanager
def collecting():
"""run_collect() 진입부에서 한 번 연다. 중첩 호출은 바깥 것을 그대로 쓴다."""
token = _current.set([])
try:
yield
finally:
_current.reset(token)
def note_issue(stage: str, target: str, ex: Exception) -> CollectIssue:
"""실패 한 건을 기록하고 기존과 같은 형식으로 로그도 남긴다.
collecting() 없이 불러도 죽지 않는다 — 그때는 기록만 안 되고 로그는 그대로 남는다
(단발 호출·테스트 호환)."""
issue = CollectIssue(
stage=stage,
target=target[:_MAX_TARGET],
error_type=type(ex).__name__,
message=str(ex)[:_MAX_MESSAGE],
)
issues = _current.get()
if issues is not None:
issues.append(issue)
LOG.w(f"[collect] {stage} 실패(계속) {issue.target}: {issue.error_type}: {issue.message}")
return issue
def snapshot() -> list[dict]:
"""지금까지 쌓인 실패 목록. run_collect() 가 끝에서 jobs.result 에 싣는다."""
issues = _current.get()
return [asdict(i) for i in issues] if issues else []