"""Per-sitetag freshness watchdog — alert gdy aktywny tube przestał dawać nowe sceny. Globalny monitor źródeł (ingest_runs per `Source`) tego NIE łapie, bo wszystkie tube scrapery dzielą jeden `Source` = "tube-scraper" — pojedynczy origin może zamarznąć (np. freshporno: scraper browsował z roota `/`, który KVS rotuje → cold-session dostawała stary zestaw → new=0/skipped=N przez 2 dni), a zagregowany run nadal raportuje success. Sygnał per-origin: `max(created_at)` na playback_sources danego `tube:`. Jak zamrożony > próg → alert (report 14f3a655 2026-06-15). Pokrywamy DWIE klasy scraperów, każda z własnym progiem: - **browse** (`ALL_BROWSE_SCRAPERS`) — crawlowane codziennie z listingu, próg 48h. - **search** (`ALL_DIRECT_SCRAPERS`) — performer-driven, nierówna kadencja (~30d refresh per performer), próg wyższy (domyślnie 7d). Bez tego pokrycia kilka search-tubów (sxyland, latestpornvideo, perverzija, fpoxxx, mypornerleak, porndish) zamarzło cicho 2026-05-07/06-07/06-13 i nic nie krzyknęło do Sentry. Tag obecny w obu listach (xvideoscom, epornercom — i browse i search) liczymy jako browse (ostrzejszy próg). Patrz [[reference_kvs_root_rotates_use_latest_updates]] dla klasy błędu, którą to łapie. """ from __future__ import annotations import logging from datetime import UTC, datetime from typing import Any from sqlalchemy import text from app.db import session_scope log = logging.getLogger(__name__) def run_ingest_freshness_watchdog( *, max_age_hours: int = 48, search_max_age_hours: int = 168, min_history: int = 100, ) -> dict[str, Any]: """Sprawdź każdy aktywny scraper: czy origin dostał nową scenę < próg dla swojej klasy. Skanujemy sitetagi z `ALL_BROWSE_SCRAPERS` (próg `max_age_hours`, domyślnie 48h) oraz z `ALL_DIRECT_SCRAPERS` (performer-driven search, próg `search_max_age_hours`, domyślnie 7d — nierówna kadencja, 48h dawałoby false-positivy). Tag w obu listach liczymy jako browse (ostrzejszy próg). Nie skanujemy wszystkich origin-ów, żeby nie alarmować o legacy/jednorazowych źródłach. `min_history` odsiewa świeżo dodane tuby bez ustalonej kadencji (za mało scen, by wiedzieć czy cisza to anomalia). Stale origin → Sentry `capture_message` ze stabilnym fingerprintem per origin (wiek + próg w extra, nie w tytule — inaczej każdy run = nowe issue). Zwraca {checked, stale:[{origin, age_hours, total, kind, max_age_hours}]}. """ from app.connectors.direct_scrapers import ( ALL_BROWSE_SCRAPERS, ALL_DIRECT_SCRAPERS, ) browse_tags = {cls.sitetag for cls in ALL_BROWSE_SCRAPERS} # Search-tuby dzielące tag z browse (xvideoscom, epornercom) idą pod browse-próg. search_tags = {cls.sitetag for cls in ALL_DIRECT_SCRAPERS} - browse_tags # (sitetag, próg_h, klasa) — klasa tylko do logów/extra w Sentry. checks: list[tuple[str, int, str]] = [ (tag, max_age_hours, "browse") for tag in sorted(browse_tags) ] + [(tag, search_max_age_hours, "search") for tag in sorted(search_tags)] now = datetime.now(UTC) stale: list[dict[str, Any]] = [] with session_scope() as s: for tag, threshold, kind in checks: origin = f"tube:{tag}" row = s.execute( text( "SELECT max(created_at) AS newest, count(*) AS total " "FROM playback_sources WHERE origin = :o" ), {"o": origin}, ).one() newest, total = row.newest, row.total if total < min_history or newest is None: continue age_h = (now - newest).total_seconds() / 3600.0 if age_h >= threshold: stale.append( { "origin": origin, "age_hours": round(age_h, 1), "total": total, "kind": kind, "max_age_hours": threshold, } ) if stale: log.warning( "ingest-watchdog: %d/%d origin(s) bez nowych scen (browse>%dh / search>%dh): %s", len(stale), len(checks), max_age_hours, search_max_age_hours, ", ".join(f"{x['origin']}({x['age_hours']}h,{x['kind']})" for x in stale), ) try: import sentry_sdk for x in stale: with sentry_sdk.push_scope() as scope: scope.level = "warning" scope.set_tag("ingest_origin", x["origin"]) scope.set_tag("ingest_scraper_kind", x["kind"]) scope.set_extra("age_hours", x["age_hours"]) scope.set_extra("total_scenes", x["total"]) scope.set_extra("max_age_hours", x["max_age_hours"]) # Fingerprint per origin → jedno trwałe issue na zamrożony tube, # nie fragmentowane przez zmienny wiek. scope.fingerprint = ["ingest-stale-origin", x["origin"]] sentry_sdk.capture_message( f"ingest-watchdog: {x['origin']} ({x['kind']}) bez nowych scen " f"({x['age_hours']:.0f}h, próg {x['max_age_hours']}h)" ) except Exception: # pragma: no cover - Sentry off / brak DSN log.exception("ingest-watchdog: Sentry capture failed") else: log.info( "ingest-watchdog: wszystkie %d origin świeże (browse<%dh / search<%dh)", len(checks), max_age_hours, search_max_age_hours, ) return {"checked": len(checks), "stale": stale}