o2o-negosium-original/lps/services/metrics.py
민헌 c955a8f367 feat(lps): 프록시 전송오류 IP회전 + 시작 프리플라이트 + DECODO/컴포넌트별 비용
프록시(DECODO) 포트/IP 사망(407/ERR_TUNNEL/ERR_HTTP_RESPONSE_CODE_FAILURE)이
봇차단과 구분 없이 예외로 튕겨 같은 죽은 포트로 재시도만 하다 DEAD 되던 문제를 고친다.

- browser_base: is_proxy_error(순수함수) + search 루프에서 프록시 전송오류 시 IP 회전 재시도
  (max_proxy_retries=2). 봇감지 회전과 통합. uses_proxy 프로퍼티.
- 쿠팡 어댑터를 BrowserSearchAdapter 로 통합 — 중복 machinery 제거, 회전 로직 한 곳에서 공유
  (detect_block 순수함수는 유지, 테스트 호환).
- proxy.healthcheck(): 시작 프리플라이트 — 살아있는 포트 선점 + egress IP 로그(빠른 실패·가시성).
  worker_main 기동 시 호출.
- 비용: DecodoConfig.cost_per_gb 추가. metrics 에 proxy_bytes(네이버 직접 제외) + 컴포넌트별
  cost{ai_usd, proxy_usd, total_usd}. FE 원가 타일에 AI/DECODO 분해·프록시 바이트.
- 테스트: is_proxy_error 8종 + 비용 분해 1종.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 15:33:37 +09:00

90 lines
3.8 KiB
Python

"""검색 1건의 리소스/비용/시간 계측(관측용).
한 상품 검색이 소모하는 것을 잡 단위로 집계한다:
- 시간: 전체 소요 + 소스별 소요(ms)
- AI: 호출 수 + 토큰(prompt/completion) + 추정 비용($)
- 크롤: 외부 fetch 수 + 처리 HTML 바이트 + 크롤한 몰
핸들러가 각 호출을 타이밍하고, AI 클라이언트/어댑터가 노출하는 last_usage/last_bytes 를 읽어 누적한다.
결과는 job.result.metrics 로 적재돼 API/FE 에서 검색 원가를 확인할 수 있다.
⚠️ HTML 바이트는 '처리한 응답 본문' 기준(대역폭 근사). 쿠팡은 리소스 차단이라 실제와 근접하지만,
오픈마켓(리소스 미차단)은 CSS/JS/이미지가 빠져 실제 대역폭보다 작다(하한). 정확 대역폭은 백로그(CDP).
"""
import time
# 모델별 1M 토큰당 단가(USD). input=prompt, output=completion. 모르면 0(비용 미추정).
_PRICING = {
"gpt-4o-mini": (0.150, 0.600),
"gpt-4o": (2.50, 10.00),
"gpt-4.1-mini": (0.40, 1.60),
}
def estimate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
inp, out = _PRICING.get(model, (0.0, 0.0))
return round(prompt_tokens / 1_000_000 * inp + completion_tokens / 1_000_000 * out, 6)
class SearchMetrics:
"""검색 1건의 누적 계측기. 스레드/코루틴 공유 X — 잡마다 새로 만든다."""
def __init__(self, model: str = "", proxy_cost_per_gb: float = 0.0):
self._start = time.monotonic()
self._model = model
self._proxy_rate = proxy_cost_per_gb # DECODO $/GB
self.ai_calls = 0
self.ai_prompt = 0
self.ai_completion = 0
self.fetches = 0
self.html_bytes = 0
self.proxy_bytes = 0 # 프록시(DECODO) 경유 바이트만 — 네이버(직접) 제외
self.source_ms: dict[str, int] = {}
self.crawled_malls: list[str] = []
def add_ai(self, usage):
"""OpenAI resp.usage(prompt_tokens/completion_tokens) 누적. None 이면 무시."""
if not usage:
return
self.ai_calls += 1
self.ai_prompt += getattr(usage, "prompt_tokens", 0) or 0
self.ai_completion += getattr(usage, "completion_tokens", 0) or 0
def add_fetch(self, source: str, html_bytes: int, ms: int, crawl: bool = False, via_proxy: bool = False):
"""외부 fetch 1건(소스·바이트·소요) 누적. crawl=폴백 크롤, via_proxy=DECODO 경유(비용 귀속)."""
self.fetches += 1
html_bytes = html_bytes or 0
self.html_bytes += html_bytes
if via_proxy:
self.proxy_bytes += html_bytes
self.source_ms[source] = self.source_ms.get(source, 0) + ms
if crawl and source not in self.crawled_malls:
self.crawled_malls.append(source)
def snapshot(self) -> dict:
ai_usd = estimate_cost(self._model, self.ai_prompt, self.ai_completion)
proxy_usd = round(self.proxy_bytes / (1024 ** 3) * self._proxy_rate, 6)
return {
"duration_ms": int((time.monotonic() - self._start) * 1000),
"ai": {
"calls": self.ai_calls,
"prompt_tokens": self.ai_prompt,
"completion_tokens": self.ai_completion,
"est_cost_usd": ai_usd,
},
"crawl": {
"fetches": self.fetches,
"html_bytes": self.html_bytes,
"proxy_bytes": self.proxy_bytes,
"malls_crawled": self.crawled_malls,
},
# 컴포넌트별 비용($) + 총합. proxy=DECODO 대역폭(근사=처리 바이트, 하한).
"cost": {
"ai_usd": ai_usd,
"proxy_usd": proxy_usd,
"total_usd": round(ai_usd + proxy_usd, 6),
},
"source_ms": self.source_ms,
}