All checks were successful
Extract compute_freshness(db) from the /admin/scrape/freshness handler (endpoint unchanged) + daily beat task scrape_freshness_check that alerts via sentry_sdk.capture_message when a source is stale/failed (error on critical-failed, else warning). Registered in celery include + beat (09:00 MSK). Refs #73
107 lines
4.8 KiB
Python
107 lines
4.8 KiB
Python
"""Celery-beat data-freshness monitor: алёрт в GlitchTip при устаревших/упавших источниках.
|
||
|
||
GET /api/v1/admin/scrape/freshness считает свежесть по 5 run-таблицам, но только ЧИТАЕТ
|
||
(чтобы UI-поллинг не спамил issue). Эта beat-таска периодически запускает ту же агрегацию
|
||
(compute_freshness — единый источник истины) и пушит Sentry/GlitchTip-сообщение, когда есть
|
||
проблемные источники.
|
||
|
||
Какие источники считаем алёртабельными:
|
||
- status == "failed" (нет успехов вообще / возраст >= stale_days) — серьёзно;
|
||
- status == "stale" (возраст между fresh_days и stale_days) — предупреждение.
|
||
Severity сообщения: "error", если хотя бы один КРИТИЧНЫЙ источник (registry.critical=True)
|
||
полностью упал (failed); иначе "warning". Если sentry в worker-процессе не инициализирован
|
||
(нет glitchtip_dsn) — capture_message молча no-op, поэтому дополнительно логируем WARNING.
|
||
|
||
Mirror conventions (zone_regulation_refresh.py / backend.md): SessionLocal()+try/finally;
|
||
broad except не валит beat-loop (любая ошибка → log + возврат summary-dict).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any, Literal
|
||
|
||
import sentry_sdk
|
||
|
||
from app.api.v1.admin_scrape import compute_freshness
|
||
from app.core.db import SessionLocal
|
||
from app.workers.celery_app import celery_app
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Статусы из compute_freshness, которые считаем поводом для алёрта.
|
||
_ALERTABLE = {"stale", "failed"}
|
||
|
||
|
||
def _format_age(source: dict[str, Any]) -> str:
|
||
"""Человекочитаемый возраст источника для тела сообщения."""
|
||
age = source.get("age_days")
|
||
if age is None:
|
||
return "нет успешных прогонов"
|
||
return f"{age}d"
|
||
|
||
|
||
@celery_app.task(name="tasks.scrape_freshness_check.scrape_freshness_check")
|
||
def scrape_freshness_check() -> dict[str, Any]:
|
||
"""Проверяет свежесть источников и шлёт GlitchTip-алёрт при stale/failed.
|
||
|
||
Returns:
|
||
Summary-dict для observability/тестов:
|
||
{'checked': N, 'alertable': M, 'stale': S, 'failed': F,
|
||
'overall_status': str, 'alerted': bool, 'level': str|None}
|
||
При внутренней ошибке: {'error': '<msg>'} — исключение НЕ пробрасывается,
|
||
чтобы не сорвать beat-loop.
|
||
"""
|
||
db = SessionLocal()
|
||
try:
|
||
payload = compute_freshness(db)
|
||
except Exception as exc: # любая ошибка агрегации не должна валить beat
|
||
logger.exception("scrape_freshness_check: compute_freshness упал: %s", exc)
|
||
return {"error": str(exc)}
|
||
finally:
|
||
db.close()
|
||
|
||
sources: list[dict[str, Any]] = payload.get("sources", [])
|
||
overall = payload.get("overall_status", "unknown")
|
||
|
||
alertable = [s for s in sources if s.get("status") in _ALERTABLE]
|
||
stale = [s for s in alertable if s.get("status") == "stale"]
|
||
failed = [s for s in alertable if s.get("status") == "failed"]
|
||
|
||
summary: dict[str, Any] = {
|
||
"checked": len(sources),
|
||
"alertable": len(alertable),
|
||
"stale": len(stale),
|
||
"failed": len(failed),
|
||
"overall_status": overall,
|
||
"alerted": False,
|
||
"level": None,
|
||
}
|
||
|
||
if not alertable:
|
||
logger.info(
|
||
"scrape_freshness_check: все источники свежие (checked=%d, overall=%s)",
|
||
len(sources),
|
||
overall,
|
||
)
|
||
return summary
|
||
|
||
# Severity: критичный источник упал полностью → error, иначе warning.
|
||
critical_failed = [s for s in failed if s.get("critical")]
|
||
level: Literal["error", "warning"] = "error" if critical_failed else "warning"
|
||
|
||
parts = [f"{s['source']} [{s['status']}, {_format_age(s)}]" for s in alertable]
|
||
message = (
|
||
f"Data freshness alert (overall={overall}): "
|
||
f"{len(failed)} failed, {len(stale)} stale — " + ", ".join(parts)
|
||
)
|
||
|
||
# capture_message — no-op, если SDK не инициализирован (нет glitchtip_dsn);
|
||
# поэтому всегда дублируем в лог (WARNING/ERROR по severity).
|
||
sentry_sdk.capture_message(message, level=level)
|
||
log = logger.error if level == "error" else logger.warning
|
||
log("scrape_freshness_check: %s", message)
|
||
|
||
summary["alerted"] = True
|
||
summary["level"] = level
|
||
return summary
|