기존 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>
82 lines
3.9 KiB
Python
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
|