Два источника шума в error-ленте и логах:
1. GlitchTip группа TRADE-IN-3GG: 167 событий за 29.08-12.09 — 503
"payments are disabled" из payments.py._require_enabled, которые бьёт
внутренний IP смоук-проверки (кнопки оплаты во фронте нет). sentry_sdk
StarletteIntegration репортит любой HTTPException с кодом из 5xx как
error-событие, даже когда FastAPI штатно обработал исключение и вернул
корректный ответ. Добавлен before_send-фильтр
drop_payments_disabled_event (app/observability/sentry_scrub.py),
матчащий по (status_code=503, detail="payments are disabled") через
hint["exc_info"] — не по коду 503 в целом, чтобы не проглотить другие
503. Подключён во всех трёх точках инициализации sentry_sdk.init
(app/main.py — единственный реальный источник события,
scheduler_main.py и tgbot_main.py — belt-and-suspenders для
единообразия, по образцу scrub_payment_request_body). Само поведение
ручки не меняется — 503 остаётся, фильтруется только репортинг в
трекер.
2. proxy_pool._probe_proxy: httpx.ProxyError (407 от прокси-провайдера)
не попадал ни под TimeoutException, ни под ConnectError и падал в
generic except Exception с exc_info=True — 184 строки полного
traceback в сутки на штатный провал health-пробы, хотя итоговая
сводка checked/ok/failed и так его учитывает. Добавлена отдельная
ветка except httpx.ProxyError с логом в одну строку (узел + причина
текстом исключения, без трейса). Логика самой пробы, аренды узлов и
правил пула не изменена.
Тесты: tests/test_sentry_scrub.py (drop_payments_disabled_event — дропает
целевой 503, пропускает прочие ошибки и прочие 503/detail-комбинации),
tests/services/test_proxy_pool.py (ProxyError логируется одной строкой
без exc_info, счётчики healthcheck не ломаются).
Refs #3471
Прод 07.09 02:36 UTC, первый настоящий drain после #3363: hard-cancel из
scheduler_main оборвал дрейн, и два бэкфилла (cian_detail_backfill 6167,
cian_history_backfill 6173) остались в scrape_runs со статусом 'running' —
boot-reap следующего контейнера сделал их 'zombie' (boot_reaped=true), метки
interrupted не было. interrupted=1 при дрейне писали только kit-пайплайны и
DKP-импорт: у задач, чьё тело живёт в app, ставить её было некому.
Метка ставится в единственной точке, через которую проходит любая detached
run-задача — SchedulerContext.drain_inflight: и по истечении
_CHILD_DRAIN_TIMEOUT_S, и в обработчике CancelledError (тот самый прод-путь).
run_id берётся из нового реестра {task: run_id}, который заполняет _dispatch
сразу после claim'а; claim-логика не тронута. Статус строки перечитывается
перед записью, поэтому успевший финализироваться сам прогон не
перезаписывается, а counters читаются из строки и дописываются — app-копия
mark_done их ЗАМЕНЯЕТ (#3390), голая {"interrupted": 1} стёрла бы чекпоинт.
scheduler_main: _await_scheduler возвращает признак hard-cancel'а, и строка
«scheduler drained cleanly (SIGTERM)» больше не печатается сразу за WARNING'ом
о превышении grace — на проде эти две строки стояли подряд и противоречили
друг другу.
Запас времени на запись: docker stop_grace_period 120s − _DRAIN_TIMEOUT_S 100s
= 20 с после hard-cancel'а, запись синхронная (несколько statement'ов).
Ключ шифрования кук и сами куки уезжали в GlitchTip: сервисы сессий передают
их bind-параметрами в pgp_sym_encrypt(:cookies_json, :key), а SQLAlchemy при
ошибке печатает ВСЕ параметры в тексте StatementError.
Правка на уровне движка (backend + tradein-mvp: db.py, auth_db.py,
alembic/env.py) кроет все сайты вызова разом, включая четвёртую копию в
scraper-kit и всё будущее.
scheduler_main.py был единственным из трёх sentry_sdk.init без
include_local_variables=False — процесс скрейпера, в кадрах лежат прокси-креды.
НЕ закрывает: текст ошибки самого драйвера (Postgres DETAIL со значением) и
сырые psycopg-подключения мимо движков — отдельный класс.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Слияние main принесло собственные Sentry-скрубберы (redact_telegram_bot_token +
stabilize_retry_error_fingerprint) в app/main.py и app/scheduler_main.py — конфликт
разрешён композицией, а не выбором стороны: обработчик перед отправкой в GlitchTip
теперь прогоняет событие через всю цепочку в указанном порядке:
scrub_payment_request_body → scrub_pii_event → redact_telegram_bot_token →
stabilize_retry_error_fingerprint (main.py), и без redact_telegram_bot_token в
scheduler_main.py (тот процесс не держит TelegramClient) — оба канала,
before_send и before_send_transaction, используют один и тот же обработчик.
tests/test_sentry_scrub.py: тесты обеих сторон объединены без потерь — PR-D2
платёжный composed-тест (body-wipe + PII-scrub + token-redaction) и весь блок
RetryError fingerprint-стабилизации из main сосуществуют в одном файле.
83% of tracker issues (7460 total) were pure noise drowning real signal:
- basic_auth 401 (3738 issues, 2019 distinct titles) — ops/glitchtip-auth-
forwarder sent EVERY 401 from bots scanning gendsgn.ru (GET /wp-admin/
install.php etc.) as an individual GlitchTip event, remote_ip baked into
message/tags inflated cardinality. Not an application error — expected
bot-scan traffic against a basic_auth-protected site.
- RetryError (2462 issues) — geocoder.py's three tenacity @retry-wrapped
Nominatim helpers (lookup/suggest/reverse) raised tenacity.RetryError on
exhaustion without reraise=True; RetryError.__str__() embeds a Future
repr() with a memory address that differs every call, so GlitchTip
grouped each exhausted retry as a distinct issue instead of one.
Fix at the source, not post-hoc issue cleanup:
- forwarder.py: before_send drops events tagged event_type in
{basic_auth_failed, basic_auth_storm}; forwarder's own capture_exception
(real script bugs) carries no such tag and passes through untouched.
- geocoder.py: reraise=True on all three @retry decorators — propagates
the real underlying exception (stable type + stacktrace) instead of the
unstable RetryError wrapper.
- sentry_scrub.stabilize_retry_error_fingerprint: belt-and-suspenders
before_send hook, composed into both app/main.py and scheduler_main.py
(geocoder runs in both processes — FastAPI request path and the
overnight geocode_missing_listings batch). Collapses any RetryError that
still slips through into one persistent issue per cause-exception type
name only — never IP/address/listing-id.
Content-ful categories (OperationalError, city-sweep, harvest_quarter,
cian/avito/yandex sweep failures, scrape_freshness_check — ~700 issues)
are untouched: filters key off event_type tag / exception type name only.
PR-D2 платёжного контура МЕРЫ — закрывает утечки до открытия публичных путей
(PR-D3/D4), сам ничего не открывает: _PUBLIC_PATHS (rbac.py), Caddyfile,
roles.yaml, auth_session.py не тронуты.
- sentry_scrub.py: новая scrub_payment_request_body — вырезает
event.request.data целиком для /api/v1/trade-in/payments/* (sentry_sdk 2.64
кладёт полное тело запроса в request.data, send_default_pii=False это НЕ
гейтит — тот флаг управляет только куками). Плюс расширен _PII_KEYS:
customer_email/customer_phone/pan/expdate/cardid/rebillid/token/terminalkey.
- main.py, scheduler_main.py, tgbot_main.py (все 3 точки инициализации
sentry_sdk.init в проекте) — тот же обработчик проведён в ОБА канала,
before_send и before_send_transaction. Мотивирующий инцидент: на соседнем
продукте вчера закрыли только error-канал, transaction остался без
обработчика вообще.
- ratelimit.py: точный путь notify — свой щедрый SlidingWindowLimiter
(3000/60с per-IP, идиома support.py) вместо общего лимитера, но НЕ полное
отключение — backstop против шторма запросов остаётся, подпись проверяется
уже после разбора тела (PR-D3). Только notify, не checkout (тот с сессией).
- request_audit.py: notify — в audit skip-набор (defense-in-depth: middleware
внешний относительно rbac_guard и читает сырой X-Authenticated-User —
спуфнутый заголовок иначе писал бы фальшивые события с атрибуцией admin).
- smoke-mera-perimeter.sh: негативные проверки-канарейки — notify/checkout
сейчас закрыты 404 (meraocenka.ru, Caddy не проксирует) и 401
(gendsgn.ru, rbac ещё не открыл) с обеих сторон периметра.
Тесты: scrub на произвольной глубине + payment-path body-wipe, AST-разбор
(не substring — комментарии в этих же файлах сами упоминают
before_send_transaction) на проводку обоих каналов во всех точках
инициализации, 400 запросов notify без единого 429 + контроль что общий
лимитер по-прежнему активен на других путях, notify вне user_events даже со
спуфнутым X-Authenticated-User: admin.
Топология подтверждена перед удалением (docker-compose.prod.yml): tradein-backend
(uvicorn app.main:app) — SCHEDULER_ENABLE=false; tradein-scraper (python -m
app.scheduler_main) — SCHEDULER_ENABLE=true + USE_KIT_SCHEDULER=true. Kit-путь
(_run_kit_scheduler → scraper_kit.orchestration.scheduler + product_handlers)
самодостаточен: не импортирует ничего из app.services.scheduler.scheduler_loop
или app.services.scrape_pipeline. Все НЕ-sweep джобы, которые kit-scheduler
диспетчерит через build_product_handlers, идут напрямую в app.tasks.*/
app.services.* (либо lazy-импортят import_rosreestr_dkp/_execute_cian_backfill
из scheduler.py) — мимо удаляемой legacy-машинерии.
app/services/scheduler.py: 2098 → 418 строк. Удалено: scheduler_loop,
get_due_schedules, reap_zombies, _claim_run, _defer_next_run_at, _spawn_tracked/
_drain_inflight/_inflight_tasks, все 27 trigger_*_run-функций, импорт
app.services.scrape_pipeline, константы SCHEDULER_TICK_SEC/ZOMBIE_THRESHOLD_HOURS
(достижимы были только через удалённый scheduler_loop-путь). Оставлено (живые
импортёры вне удалённого): compute_next_run_at + has_running_run (admin.py),
import_rosreestr_dkp + _execute_cian_backfill (lazy-импорты в
product_handlers.py — job-тела kit-handler'ов).
main.py: убран `from app.services.scheduler import scheduler_loop` + lifespan-блок
запуска (`if settings.scheduler_enable: asyncio.create_task(scheduler_loop())`);
прод-backend всегда шёл с SCHEDULER_ENABLE=false, так что это был мёртвый код.
scheduler_main.py: убрана ship-dark развилка #2192 (USE_KIT_SCHEDULER=false →
legacy scheduler_loop fallback) — _run_kit_scheduler() теперь безусловный путь.
Поле settings.use_kit_scheduler оставлено в конфиге (Settings extra="ignore"
защищает от startup-краха на leftover env var), но на ветвление не влияет.
app.services.scrape_pipeline: 0 runtime-импортёров в app/+scripts/+packages/
после этого PR (только тесты, которые Part E удалит вместе с самим файлом) —
подтверждено grep. scrape_pipeline.py не тронут (Part E).
Тесты: удалены test_house_imv_backfill_scheduler.py (100% legacy-триггер,
backfill_house_imv сервис покрыт в test_house_imv_backfill_browser_flag.py /
test_backfill_wave2.py) и test_kit_registry_completeness.py (parity-инвариант
против удалённого dispatch, дублирует test_scraper_kit_scheduler_parity.py).
Точечно вырезаны "Scheduler wiring" секции (trigger_fn_exists/dispatch_branch_
wired/runs_in_executor) из ~10 файлов, тестирующих сами task-функции — сами
task-тесты (SQL-shape, миграции, fake-db поведение) оставлены нетронутыми.
test_scheduler.py: 825 → ~90 строк (остались только compute_next_run_at-тесты).
test_scraper_kit_scheduler_parity.py: убрана golden-parity секция против
удалённого scheduler_loop (SOURCE_TO_OLD_TRIGGER/_drive_old_one_tick/
test_routing_parity_per_source), остальное (claim/reap_zombies/dispatch/
registry-shape тесты kit-модуля) сохранено — источник этих инвариантов не
app.services.scheduler, а сам scraper_kit.orchestration.scheduler.
test_scheduler_main.py: 2 теста, патчившие app.services.scheduler.scheduler_loop,
переведены на монкипатч sm._run_kit_scheduler (единственный путь после этого PR).
test_sweep_imv_phase.py:171-371 (6 прямых импортов run_avito_city_sweep из
scrape_pipeline) намеренно НЕ тронуты — Part E.
Verify: полный pytest 3179 passed / 6 skipped / 1 known-unrelated fail
(test_search_cache_hit, #2208, не связан с этим PR); ruff 0.7.4 чист на всех
изменённых файлах; `python -c "import app.main; import app.scheduler_main"` OK.
В ветке `task.done()` без shutdown глушилось реальное исключение задачи. Зовём
task.result() → пробрасываем настоящий сбой scheduler_loop наружу (loud crash,
как делал прежний `await task`), вместо тихого "exited cleanly".
Деплой (docker recreate tradein-scraper) шлёт SIGTERM с stop_grace_period=120s
(Phase 1). Раньше scheduler_main отвечал hard task.cancel() → бегущий scrape-unit
получал CancelledError посреди браузер-карточки → карточка гибла без COMMIT.
Теперь SIGTERM/SIGINT лишь выставляют кооперативный флаг; tick-loop и длинные
задачи опрашивают его на СВОИХ существующих between-unit checkpoint'ах,
докоммичивают текущий unit и выходят сами.
- app/core/shutdown.py: новый standalone-модуль (asyncio.Event + request_shutdown
/ shutdown_requested / wait_for_shutdown), без app-зависимостей → нет циклов.
- scheduler_main._run: SIGTERM → request_shutdown() вместо task.cancel(); ждём
добровольного drain'а, safety-net wait_for(100s < docker grace) с fallback на
hard-cancel для некооперирующей задачи.
- scheduler_loop: проверка флага в начале тика и после каждого dispatch — не
reap/claim новые run'ы во время shutdown, выходим из loop'а.
- avito_detail_backfill: break на границе карточки рядом с budget-guard; snapshot
pending-query идемпотентен → следующий run сам резюмит остаток.
- import_rosreestr_dkp: расширен is_cancelled checkpoint — drain делает mark_done
(partial), не mark_cancelled; user-cancel семантика без изменений.
Без SIGTERM поведение идентично прежнему. reap_zombies не трогает drained-run'ы
(они mark_done, не 'running').
Tests: tests/core/test_shutdown.py, scheduler_main drain+timeout-fallback,
avito_detail_backfill partial-drain. 26 + 29 scheduler passed.
Причина: docker restart tradein-backend (при каждом API-деплое) убивал бегущие
sweep'ы avito/cian/rosreestr — они могут идти часами, и их прерывание середине
батча создавало zombie-runs и пропуски данных.
Что сделано:
- scheduler_main.py — standalone entrypoint для tradein-scraper; SIGTERM/SIGINT
отменяют scheduler_loop чисто; GlitchTip init без Starlette/FastAPI интеграций.
- docker-compose.prod.yml — новый сервис scraper (тот же image, команда python -m
app.scheduler_main, только tradein-net); SCHEDULER_ENABLE=false в backend.
- deploy-tradein.yml — селективный up -d --no-deps: scraper пересоздаётся только
при изменениях scraper/infra путей (SCRAPER_CHANGED), не при каждом backend-деплое.
- tests/test_scheduler_main.py — импорт, kill-switch _should_run(), чистая отмена _run().