운영 번들 자동 로그인 자격증명 유출, 온보딩 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 알림 실채널 수신 확인.
60 lines
2.2 KiB
Python
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 []
|