o2o-negosium-original/lps/common/alerts.py
민헌 c81df5bd88 refactor(lps): 설정을 TOML 단일 소스로 통합 — env/.env 이중 관리 제거
설정이 .env(compose 주입)·config.toml·코드 곳곳의 os.environ 직독 3계층에
흩어져 관리가 어려웠다. TOML 하나로 통합한다(협의 결정).

- 신설 [WorkerConfig](동시성·폴백·프로필·데드라인·유예·Chrome·하트비트),
  [AlertConfig](웹훅·쿨다운·임계 10종). [WebServerConfig].api_keys(guard),
  [DecodoConfig].ip_request_budget/port_cooldown_sec 추가 — 흩어져 있던
  LPS_* env 20여 개를 섹션으로 흡수.
- server_configs 의 env override 계층(DB_*·시크릿·NAVER_KEYS 등) 삭제.
  남는 env 는 APP_ENV(부트스트랩)·PROCESS_COUNT/WORKER_CONCURRENCY(실행
  스크립트 대화형 입력 전용)·LPS_LIVE(테스트 옵트인)뿐.
- Docker: env 주입 → config.docker.toml 마운트 + APP_ENV=docker.
  이미지 무시크릿 유지, 마운트 누락 시 FileNotFoundError 즉시 실패.
  .env.example 삭제, config.docker.toml.example 신설.
- negodata 호출부: guard 키를 env 직독에서 [WebServerConfig].lps_api_key
  (+기존 관례대로 env override)로 이동.
- 실행 스크립트: 프로필·폴백·예산 프롬프트 제거(toml 소스 안내),
  동시성/프로세스 수만 임시 override 로 유지.
- docs 7종·example toml 의 env 표기를 toml 키로 일괄 갱신.
- 전체 145 passed + APP_ENV=docker 로딩·API 기동 스모크 확인.

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

81 lines
3.9 KiB
Python

"""임계 알림 관리자 — 쿨다운(스팸 방지)·회복 알림. 워커(ops-monitor)·API(풀 모니터) 공용.
기존 방식(임계 초과 시 매 틱 웹훅)은 조건이 지속되면 30초마다 같은 알림이 반복 발송됐다.
AlertManager 는 룰 키별로 상태를 관리한다:
발화: 비활성→활성 전환 시 1회 + 이후 쿨다운([AlertConfig].cooldown_min, 기본 30분)마다 리마인드
회복: 활성→비활성 전환 시 '해소' 알림 1회
채널: WARN/INFO 로그(항상) + Slack 호환 웹훅([AlertConfig].webhook 있을 때만, 실패 무시).
sender/clock 주입으로 네트워크·시간 없이 단위 테스트 가능.
"""
import time
import httpx
from common.logger import LOG
from config.server_configs import alert_config
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 alert_config.webhook
self._cooldown = cooldown_sec if cooldown_sec is not None else alert_config.cooldown_min * 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 = alert_config.pool_pct
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