o2o-negosium-original/lps/services/metrics.py
민헌 0cd817a61d fix(lps): 대역폭 비용 정확화 — CDP 실제 전송 바이트로 계측
지금까지 last_bytes=page.content()(렌더된 DOM 크기)라 실제 네트워크 전송량이 아니었다
— 리소스 차단 효과가 안 보이고 ESM DOM(~5MB) 과대계상 → DECODO 비용이 부정확했다.

- browser_base: CDP 세션(new_cdp_session) 부착, Network.loadingFinished 의 encodedDataLength
  누적 → last_bytes=실제 전송 바이트. 컨텍스트당 1회 부착, 검색마다 리셋. 미지원 시 DOM 폴백.
- 실측(gmarket): 콜드(차단해제) 3.44MB → 웜(차단활성) 1.07MB(~3x↓) — 동적 차단 효과가 이제 숫자로 보임.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:27:11 +09:00

91 lines
3.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""검색 1건의 리소스/비용/시간 계측(관측용).
한 상품 검색이 소모하는 것을 잡 단위로 집계한다:
- 시간: 전체 소요 + 소스별 소요(ms)
- AI: 호출 수 + 토큰(prompt/completion) + 추정 비용($)
- 크롤: 외부 fetch 수 + 처리 HTML 바이트 + 크롤한 몰
핸들러가 각 호출을 타이밍하고, AI 클라이언트/어댑터가 노출하는 last_usage/last_bytes 를 읽어 누적한다.
결과는 job.result.metrics 로 적재돼 API/FE 에서 검색 원가를 확인할 수 있다.
바이트는 어댑터가 **CDP Network.loadingFinished 의 실제 전송 바이트(encodedDataLength)** 로 측정한다
(DOM 크기가 아님 → 리소스 차단 효과가 정확히 반영됨). DECODO 는 프록시 경유 바이트로만 과금하므로
proxy_bytes(=네이버 직접 제외) × cost_per_gb 로 대역폭 비용을 산정한다.
"""
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,
}