gendesign/backend/app/workers/tasks/scrape_freshness_check.py
bot-backend 55811ff7cc
All checks were successful
Deploy / changes (push) Successful in 15s
Deploy / build-backend (push) Successful in 1m58s
Deploy / build-worker (push) Successful in 3m9s
Deploy / build-frontend (push) Successful in 3m19s
Deploy / deploy (push) Successful in 1m24s
feat(workers): data-freshness monitor (Celery beat → GlitchTip)
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
2026-06-25 11:29:34 +00:00

107 lines
4.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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