feat: логирование вебхука до БД + файловый лог с ротацией
All checks were successful
CI / test (push) Successful in 47s
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:
@ -3,6 +3,11 @@
|
|||||||
HTTP_ADDR=0.0.0.0:8080
|
HTTP_ADDR=0.0.0.0:8080
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Запись логов в файл с авто-ротацией (10 МБ × 5 файлов = ~50 МБ).
|
||||||
|
# Пусто = не писать в файл (логи только в stdout и страницу /ui/logs).
|
||||||
|
# Пример для docker-compose (volume /logs): LOG_FILE=/logs/onguard24.log
|
||||||
|
# LOG_FILE=
|
||||||
|
|
||||||
# Опционально: общий секрет для вебхуков (если у источника в JSON не задан свой webhook_secret)
|
# Опционально: общий секрет для вебхуков (если у источника в JSON не задан свой webhook_secret)
|
||||||
# GRAFANA_WEBHOOK_SECRET=
|
# GRAFANA_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,9 @@ class Settings(BaseSettings):
|
|||||||
forgejo_url: str = Field(default="", validation_alias="FORGEJO_URL")
|
forgejo_url: str = Field(default="", validation_alias="FORGEJO_URL")
|
||||||
forgejo_token: str = Field(default="", validation_alias="FORGEJO_TOKEN")
|
forgejo_token: str = Field(default="", validation_alias="FORGEJO_TOKEN")
|
||||||
log_level: str = Field(default="info", validation_alias="LOG_LEVEL")
|
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). По умолчанию выкл.
|
# Устаревшее: автосоздание инцидента на каждый вебхук (без учёта irm_alerts). По умолчанию выкл.
|
||||||
auto_incident_from_alert: bool = Field(
|
auto_incident_from_alert: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
|
|||||||
@ -82,6 +82,42 @@ def service_hint_from_grafana_body(body: dict, header_service: str | None) -> st
|
|||||||
return None
|
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(
|
async def _grafana_webhook_impl(
|
||||||
request: Request,
|
request: Request,
|
||||||
pool,
|
pool,
|
||||||
@ -98,6 +134,10 @@ async def _grafana_webhook_impl(
|
|||||||
body = json.loads(raw.decode() or "{}")
|
body = json.loads(raw.decode() or "{}")
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
body = {}
|
body = {}
|
||||||
|
|
||||||
|
# Логируем входящий вебхук ДО любой обработки — чтобы видеть при любой ошибке
|
||||||
|
_log_incoming_webhook(body, raw, path_slug)
|
||||||
|
|
||||||
if not isinstance(body, dict):
|
if not isinstance(body, dict):
|
||||||
body = {}
|
body = {}
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -78,14 +78,42 @@ class RingBufferHandler(logging.Handler):
|
|||||||
self.handleError(record)
|
self.handleError(record)
|
||||||
|
|
||||||
|
|
||||||
def install_log_handler(loop: asyncio.AbstractEventLoop) -> None:
|
def install_log_handler(
|
||||||
|
loop: asyncio.AbstractEventLoop,
|
||||||
|
log_file: str = "",
|
||||||
|
) -> None:
|
||||||
"""Вызывается один раз при старте: регистрирует handler на корневом логгере."""
|
"""Вызывается один раз при старте: регистрирует handler на корневом логгере."""
|
||||||
set_event_loop(loop)
|
set_event_loop(loop)
|
||||||
handler = RingBufferHandler()
|
fmt = logging.Formatter(
|
||||||
handler.setFormatter(
|
"%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
||||||
logging.Formatter("%(name)s %(message)s")
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
)
|
)
|
||||||
handler.setLevel(logging.DEBUG)
|
|
||||||
root = logging.getLogger()
|
root = logging.getLogger()
|
||||||
|
|
||||||
|
# Кольцевой буфер (SSE-страница логов)
|
||||||
if not any(isinstance(h, RingBufferHandler) for h in root.handlers):
|
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
|
||||||
|
)
|
||||||
|
|||||||
@ -17,8 +17,10 @@ from onguard24.root_html import render_root_page
|
|||||||
from onguard24.status_snapshot import build as build_status
|
from onguard24.status_snapshot import build as build_status
|
||||||
from onguard24.ui_logs import router as logs_router
|
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("httpx").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||||
log = logging.getLogger("onguard24")
|
log = logging.getLogger("onguard24")
|
||||||
|
|
||||||
|
|
||||||
@ -34,8 +36,8 @@ def parse_addr(http_addr: str) -> tuple[str, int]:
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
install_log_handler(asyncio.get_event_loop())
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
install_log_handler(asyncio.get_event_loop(), log_file=settings.log_file)
|
||||||
pool = await create_pool(settings)
|
pool = await create_pool(settings)
|
||||||
bus = InMemoryEventBus()
|
bus = InMemoryEventBus()
|
||||||
register_module_events(bus, pool)
|
register_module_events(bus, pool)
|
||||||
|
|||||||
Reference in New Issue
Block a user