diff --git a/.env.example b/.env.example index c83e615..91340e2 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/onguard24/config.py b/onguard24/config.py index 1c225dc..5a1ea0f 100644 --- a/onguard24/config.py +++ b/onguard24/config.py @@ -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, diff --git a/onguard24/ingress/grafana.py b/onguard24/ingress/grafana.py index 0b285c6..e60ccf3 100644 --- a/onguard24/ingress/grafana.py +++ b/onguard24/ingress/grafana.py @@ -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: diff --git a/onguard24/log_buffer.py b/onguard24/log_buffer.py index e0654eb..db07a33 100644 --- a/onguard24/log_buffer.py +++ b/onguard24/log_buffer.py @@ -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 + ) diff --git a/onguard24/main.py b/onguard24/main.py index b962bff..a004f52 100644 --- a/onguard24/main.py +++ b/onguard24/main.py @@ -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)