"""PR-D2 (платёжный периметр): каждая точка инициализации `sentry_sdk.init(...)` в проекте обязана проводить ОБА канала мониторинга — `before_send` (error-события) и `before_send_transaction` (performance-трейсы). Мотивирующий инцидент (соседний продукт, Птица, вчера): закрыли только error-канал через `before_send`, а `before_send_transaction` остался вообще без обработчика — очистка body/PII там не применялась. Инициализация происходит на module-level внутри `if settings.glitchtip_dsn:` — поведенческий тест потребовал бы реального импорта модуля с DSN, выставленным ДО импорта (модуль кэшируется, monkeypatch settings после импорта на init уже не влияет), плюс `sentry_sdk.init` — процесс-глобальный singleton (повторные вызовы из разных тестов друг друга затирают). Вместо этого — статический разбор AST: детерминирован, не трогает process-global state, не зависит от порядка тестов. НЕ grep/substring по тексту файла: `before_send_transaction` уже упоминается в docstring-комментариях этих же файлов (объясняющих МОТИВ) — substring-поиск дал бы ложный PASS без единой реальной проводки в `sentry_sdk.init(...)`. Разбор именно keyword-аргументов AST Call-узла `sentry_sdk.init(...)` не подвержен этому false positive. """ from __future__ import annotations import ast from pathlib import Path import pytest _APP_DIR = Path(__file__).resolve().parent.parent / "app" # Все известные точки инициализации sentry_sdk в проекте (backend API, scraper # scheduler, telegram support-bridge). Список сверяется отдельным тестом ниже # против грепа по всему `app/`, чтобы новая точка инициализации не прошла мимо # этого файла молча. _SENTRY_INIT_FILES = ["main.py", "scheduler_main.py", "tgbot_main.py"] def _sentry_init_calls(source: str, filename: str) -> list[ast.Call]: """Все AST Call-узлы вида `sentry_sdk.init(...)` в модуле.""" tree = ast.parse(source, filename=filename) calls = [] for node in ast.walk(tree): if not isinstance(node, ast.Call): continue func = node.func if ( isinstance(func, ast.Attribute) and func.attr == "init" and isinstance(func.value, ast.Name) and func.value.id == "sentry_sdk" ): calls.append(node) return calls @pytest.mark.parametrize("filename", _SENTRY_INIT_FILES) def test_sentry_init_wires_both_channels(filename: str) -> None: source = (_APP_DIR / filename).read_text(encoding="utf-8") calls = _sentry_init_calls(source, filename) assert calls, f"{filename}: sentry_sdk.init(...) call not found (файл переехал?)" for call in calls: kwarg_names = {kw.arg for kw in call.keywords if kw.arg is not None} assert "before_send" in kwarg_names, ( f"{filename}: sentry_sdk.init(...) не передаёт before_send — " "error-канал уходит в GlitchTip без scrub" ) assert "before_send_transaction" in kwarg_names, ( f"{filename}: sentry_sdk.init(...) не передаёт before_send_transaction — " "transaction-канал уходит в GlitchTip без scrub (ровно вчерашний баг Птицы)" ) def test_sentry_init_before_send_and_transaction_use_same_handler() -> None: """`before_send` и `before_send_transaction` обязаны указывать на ОДИН и тот же обработчик (одинаковое имя переменной/функции в keyword-значении) — иначе возможен регресс, при котором кто-то поправит один канал и забудет второй, хотя формально оба параметра присутствуют.""" for filename in _SENTRY_INIT_FILES: source = (_APP_DIR / filename).read_text(encoding="utf-8") calls = _sentry_init_calls(source, filename) for call in calls: kwargs = {kw.arg: kw.value for kw in call.keywords if kw.arg is not None} before_send = kwargs.get("before_send") before_send_txn = kwargs.get("before_send_transaction") assert before_send is not None and before_send_txn is not None # Оба значения — ссылки на имя (ast.Name), сравниваем идентификатор. assert isinstance(before_send, ast.Name) assert isinstance(before_send_txn, ast.Name) assert before_send.id == before_send_txn.id, ( f"{filename}: before_send={before_send.id!r} != " f"before_send_transaction={before_send_txn.id!r} — разные обработчики " "на двух каналах, ровно тот класс бага, что и голый пропуск канала" ) def test_all_sentry_init_call_sites_are_enumerated() -> None: """Если кто-то добавит НОВУЮ точку инициализации sentry_sdk.init(...) где-то ещё в app/ — этот тест должен упасть, а не молча пропустить её мимо теста выше (список `_SENTRY_INIT_FILES` — руками поддерживаемый allowlist).""" found_files = set() for py_file in _APP_DIR.rglob("*.py"): source = py_file.read_text(encoding="utf-8") if _sentry_init_calls(source, str(py_file)): found_files.add(py_file.relative_to(_APP_DIR).as_posix()) expected = set(_SENTRY_INIT_FILES) assert found_files == expected, ( f"Точки инициализации sentry_sdk.init(...) разошлись со списком в тесте: " f"найдено {sorted(found_files)}, ожидалось {sorted(expected)}. Новую точку " "нужно добавить в _SENTRY_INIT_FILES ЭТОГО файла и проверить оба канала." )