feat: логирование вебхука до БД + файловый лог с ротацией
All checks were successful
CI / test (push) Successful in 47s

- Каждый входящий POST /ingress/grafana: INFO-строка (status, кол-во алертов,
  первые лейблы) и DEBUG-блок с полным JSON телом (до 8КБ)
  — видно даже если БД упала с 500
- LOG_FILE в .env / env: RotatingFileHandler 10MB×5 файлов
- LOG_LEVEL=debug теперь показывает полные тела вебхуков
- basicConfig уровень DEBUG (uvicorn.access / asyncio приглушены)

Made-with: Cursor
This commit is contained in:
Alexandr
2026-04-03 15:59:17 +03:00
parent 80645713a0
commit c9b97814a5
5 changed files with 86 additions and 8 deletions

View File

@ -3,6 +3,11 @@
HTTP_ADDR=0.0.0.0:8080
LOG_LEVEL=info
# Запись логов в файл с авто-ротацией (10 МБ × 5 файлов = ~50 МБ).
# Пусто = не писать в файл (логи только в stdout и страницу /ui/logs).
# Пример для docker-compose (volume /logs): LOG_FILE=/logs/onguard24.log
# LOG_FILE=
# Опционально: общий секрет для вебхуков (если у источника в JSON не задан свой webhook_secret)
# GRAFANA_WEBHOOK_SECRET=

View File

@ -34,6 +34,9 @@ class Settings(BaseSettings):
forgejo_url: str = Field(default="", validation_alias="FORGEJO_URL")
forgejo_token: str = Field(default="", validation_alias="FORGEJO_TOKEN")
log_level: str = Field(default="info", validation_alias="LOG_LEVEL")
# Путь к лог-файлу. Пусто = не писать в файл. Пример: /var/log/onguard24/app.log
# Файл ротируется: 10 МБ × 5 штук (~50 МБ суммарно).
log_file: str = Field(default="", validation_alias="LOG_FILE")
# Устаревшее: автосоздание инцидента на каждый вебхук (без учёта irm_alerts). По умолчанию выкл.
auto_incident_from_alert: bool = Field(
default=False,

View File

@ -82,6 +82,42 @@ def service_hint_from_grafana_body(body: dict, header_service: str | None) -> st
return None
def _log_incoming_webhook(body: object, raw: bytes, path_slug: str | None) -> None:
"""Логирует каждый входящий вебхук: краткое резюме INFO + полное тело DEBUG."""
slug_tag = f"[/{path_slug}]" if path_slug else "[/]"
raw_len = len(raw)
if not isinstance(body, dict):
logger.info("grafana webhook %s raw=%dB (non-dict body)", slug_tag, raw_len)
logger.debug("grafana webhook %s raw body: %s", slug_tag, raw[:4000].decode(errors="replace"))
return
alerts = body.get("alerts") or []
n_alerts = len(alerts) if isinstance(alerts, list) else 0
status = body.get("status", "?")
title = str(body.get("title") or body.get("ruleName") or "")[:120]
first_labels: dict = {}
if isinstance(alerts, list) and alerts and isinstance(alerts[0], dict):
first_labels = alerts[0].get("labels") or {}
logger.info(
"grafana webhook %s status=%s alerts=%d title=%r labels=%s raw=%dB",
slug_tag,
status,
n_alerts,
title,
json.dumps(first_labels, ensure_ascii=False)[:300],
raw_len,
)
try:
pretty = json.dumps(body, ensure_ascii=False, indent=2)
if len(pretty) > 8000:
pretty = pretty[:8000] + "\n…(обрезано)"
except Exception:
pretty = raw[:8000].decode(errors="replace")
logger.debug("grafana webhook %s full body:\n%s", slug_tag, pretty)
async def _grafana_webhook_impl(
request: Request,
pool,
@ -98,6 +134,10 @@ async def _grafana_webhook_impl(
body = json.loads(raw.decode() or "{}")
except json.JSONDecodeError:
body = {}
# Логируем входящий вебхук ДО любой обработки — чтобы видеть при любой ошибке
_log_incoming_webhook(body, raw, path_slug)
if not isinstance(body, dict):
body = {}
else:

View File

@ -78,14 +78,42 @@ class RingBufferHandler(logging.Handler):
self.handleError(record)
def install_log_handler(loop: asyncio.AbstractEventLoop) -> None:
def install_log_handler(
loop: asyncio.AbstractEventLoop,
log_file: str = "",
) -> None:
"""Вызывается один раз при старте: регистрирует handler на корневом логгере."""
set_event_loop(loop)
handler = RingBufferHandler()
handler.setFormatter(
logging.Formatter("%(name)s %(message)s")
fmt = logging.Formatter(
"%(asctime)s %(levelname)-8s %(name)s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
handler.setLevel(logging.DEBUG)
root = logging.getLogger()
# Кольцевой буфер (SSE-страница логов)
if not any(isinstance(h, RingBufferHandler) for h in root.handlers):
root.addHandler(handler)
ring_h = RingBufferHandler()
ring_h.setFormatter(logging.Formatter("%(name)s %(message)s"))
ring_h.setLevel(logging.DEBUG)
root.addHandler(ring_h)
# Файл с ротацией (если задан LOG_FILE)
if log_file.strip():
import os
from logging.handlers import RotatingFileHandler
log_path = log_file.strip()
os.makedirs(os.path.dirname(log_path) if os.path.dirname(log_path) else ".", exist_ok=True)
if not any(isinstance(h, RotatingFileHandler) for h in root.handlers):
file_h = RotatingFileHandler(
log_path,
maxBytes=10 * 1024 * 1024, # 10 МБ
backupCount=5,
encoding="utf-8",
)
file_h.setFormatter(fmt)
file_h.setLevel(logging.DEBUG)
root.addHandler(file_h)
logging.getLogger("onguard24").info(
"file logging enabled: %s (rotate 10MB×5)", log_path
)

View File

@ -17,8 +17,10 @@ from onguard24.root_html import render_root_page
from onguard24.status_snapshot import build as build_status
from onguard24.ui_logs import router as logs_router
logging.basicConfig(level=logging.INFO)
logging.basicConfig(level=logging.DEBUG)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("asyncio").setLevel(logging.WARNING)
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
log = logging.getLogger("onguard24")
@ -34,8 +36,8 @@ def parse_addr(http_addr: str) -> tuple[str, int]:
@asynccontextmanager
async def lifespan(app: FastAPI):
install_log_handler(asyncio.get_event_loop())
settings = get_settings()
install_log_handler(asyncio.get_event_loop(), log_file=settings.log_file)
pool = await create_pool(settings)
bus = InMemoryEventBus()
register_module_events(bus, pool)