o2o-negosium-original/lps/common/alerts.py
민헌 8b9e81777c feat(lps): 알림 확장 — AlertManager 쿨다운·회복, DB풀 포화·소스별 장기실패 룰
기존 ops-monitor 는 임계 초과가 지속되면 30초마다 같은 웹훅을 반복 발송했고
(쿨다운 없음), 해소 여부도 알 수 없었다. 감시 항목도 큐 지표 4종뿐이었다.

- common/alerts.py AlertManager 신설: 룰 키별 상태 관리 — 발화 1회 +
  쿨다운(LPS_ALERT_COOLDOWN_MIN, 기본 30분)마다 리마인드, 해소 시 회복
  알림 1회. sender/clock 주입으로 네트워크·대기 없이 단위 테스트.
- 워커 ops-monitor 를 AlertManager 로 이관(기존 4룰 유지) + 신규 2룰:
  db_pool(풀 포화율 ≥ LPS_ALERT_POOL_PCT 90%) ·
  source_fail:<src>(최근 30분 시도 ≥ LPS_ALERT_SOURCE_FAIL_30M(5) & 성공 0
  — 쿼터 소진·셀렉터 드리프트·전면 차단 신호).
- DBSessionManager.pool_status(): 전 엔진 합산 checked_out/capacity/pct.
- SearchAdapter 에 시간 윈도우 성공/실패 카운터(recent_stats) — 누적
  카운터로는 '최근 30분 성공 0건'을 볼 수 없어 추가. 쿠팡(브라우저)·
  네이버(API) 성공/실패 지점에 배선.
- API 자체 풀 모니터: lifespan 백그라운드 태스크(run_pool_monitor) —
  대량 폴링으로 풀을 고갈시키는 주범이 API 자신일 수 있다.
  /v1/lps/ops 에 pool_checked_out/pool_capacity/pool_pct 노출(스모크 확인).
- 테스트 9건 추가(발화·쿨다운·회복·룰 독립·윈도우 카운터·풀 현황), 전체 135 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:42:12 +09:00

82 lines
3.9 KiB
Python

"""임계 알림 관리자 — 쿨다운(스팸 방지)·회복 알림. 워커(ops-monitor)·API(풀 모니터) 공용.
기존 방식(임계 초과 시 매 틱 웹훅)은 조건이 지속되면 30초마다 같은 알림이 반복 발송됐다.
AlertManager 는 룰 키별로 상태를 관리한다:
발화: 비활성→활성 전환 시 1회 + 이후 쿨다운(LPS_ALERT_COOLDOWN_MIN, 기본 30분)마다 리마인드
회복: 활성→비활성 전환 시 '해소' 알림 1회
채널: WARN/INFO 로그(항상) + Slack 호환 웹훅(LPS_ALERT_WEBHOOK 있을 때만, 실패 무시).
sender/clock 주입으로 네트워크·시간 없이 단위 테스트 가능.
"""
import os
import time
import httpx
from common.logger import LOG
class AlertManager:
def __init__(self, origin: str = "worker", webhook: str | None = None,
cooldown_sec: float | None = None, sender=None, clock=time.monotonic):
self.origin = origin # 알림 출처(worker/api) — 메시지에 표기
self._webhook = webhook if webhook is not None else os.environ.get("LPS_ALERT_WEBHOOK")
self._cooldown = cooldown_sec if cooldown_sec is not None \
else int(os.environ.get("LPS_ALERT_COOLDOWN_MIN", "30")) * 60
self._sender = sender # async def(text: str) — 테스트 주입용(없으면 웹훅)
self._clock = clock
self._state: dict[str, dict] = {} # key → {"active": bool, "last_sent": float}
async def check(self, key: str, active: bool, message: str, snap: dict | None = None):
"""룰 1개 평가. active 가 True 로 지속돼도 쿨다운 안에는 재발송하지 않는다."""
st = self._state.setdefault(key, {"active": False, "last_sent": 0.0})
now = self._clock()
if active:
due = (not st["active"]) or (now - st["last_sent"] >= self._cooldown)
st["active"] = True
if due:
st["last_sent"] = now
LOG.w(f"[alert:{key}] {message}")
await self._send(f":rotating_light: LPS({self.origin}) [{key}] {message}", snap)
elif st["active"]:
st["active"] = False
LOG.i(f"[alert:{key}] 해소 — {message}")
await self._send(f":white_check_mark: LPS({self.origin}) [{key}] 해소 — {message}", snap)
def is_active(self, key: str) -> bool:
return self._state.get(key, {}).get("active", False)
async def _send(self, text: str, snap: dict | None):
if self._sender is not None:
await self._sender(text)
return
if not self._webhook:
return
try:
async with httpx.AsyncClient(timeout=5) as c:
await c.post(self._webhook, json={"text": text + (f"\n```{snap}```" if snap else "")})
except Exception:
pass # 알림 실패가 모니터 루프를 막지 않는다
async def run_pool_monitor(stop, interval: float = 60.0, alerts: AlertManager | None = None):
"""DB 커넥션 풀 포화 감시 루프 — API 프로세스용 경량 모니터(lifespan 에서 기동).
대량 폴링으로 풀을 고갈시키는 주범이 API 자신일 수 있어, 워커 ops-monitor 와 별도로 감시한다."""
import asyncio
from common.database.db_session_manager import DB_SESSION_MNG
alerts = alerts or AlertManager(origin="api")
threshold = int(os.environ.get("LPS_ALERT_POOL_PCT", "90"))
while not stop.is_set():
try:
st = DB_SESSION_MNG.pool_status()
await alerts.check("db_pool", st["pct"] >= threshold,
f"DB 풀 포화 {st['pct']}% (checked_out {st['checked_out']}/{st['capacity']})", st)
except Exception as ex:
LOG.e_no_callstack(f"[pool-monitor] {type(ex).__name__}: {ex}")
try:
await asyncio.wait_for(stop.wait(), timeout=interval)
except asyncio.TimeoutError:
pass