"""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': ''} — исключение НЕ пробрасывается, чтобы не сорвать 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