"""Хуки before_send / before_send_transaction для GlitchTip/Sentry SDK. `scrub_sensitive_query` — redact-ит api keys / tokens из URL-spans перед отправкой — чтобы секреты (apiKey=..., api_key=..., token=...) не утекали в GlitchTip через HttpxIntegration performance-spans. `scrub_pii_event` — redact-ит consumer-PII (client_name / client_phone / client_email / phone / email / name / company / message) из events перед отправкой. `send_default_pii=False` в sentry_sdk.init (проверено на sentry-sdk 2.58) НЕ покрывает эти поля — это user-data, попадающий в request.data / extra / contexts (pilot-заявки — `PilotRequestInput` в `app/api/v1/pilot.py` несёт все 6 полей включая свободный текст `company`/ `message`, куда чаще всего прилетают телефоны/имена/адреса; чат — свободный вопрос в `app/schemas/chat.py`), а не PII-заголовки/cookies, которые режет сам флаг. Портировано из trade-in (`tradein-mvp/backend/app/observability/ sentry_scrub.py`, #396) — тот же набор ключей (client_name/client_phone/ client_email — Птица их не использует сегодня, но одинаковый механизм на оба продукта проще сопровождать), плюс `company`/`message`, специфичные для `PilotRequestInput` (#2457-review). `scrub_event` — composed-хендлер (PII-scrub + URL-secret redact), которым надо вешать ОБА канала — `before_send` И `before_send_transaction`. Starlette-интеграция кладёт тело запроса в `request_info["data"]` на transaction-scope точно так же, как на error-scope (scope-обработчики для transactions НЕ пропускаются — пропуск бывает только на availability-чеках). Если повесить PII-scrub только на `before_send`, а `before_send_transaction` оставить на голом `scrub_sensitive_query` — PII продолжит течь через transaction-канал при любом `glitchtip_traces_sample_rate > 0` (#2457-review, воспроизведено: pilot-заявка с реальными данными → ~1/20 попадает в транзакцию с полным телом). """ from __future__ import annotations import logging import re from typing import Any from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.types import Event logger = logging.getLogger(__name__) # Собственный сбой скраба НЕ должен становиться событием мониторинга (#2753). # LoggingIntegration (event_level=ERROR) превратила бы строку журнала об отказе # в новое событие, которое снова пойдёт через этот же обработчик; при # детерминированном сбое это рекурсия — защиты от неё в SDK нет (проверено: # 1000+ вложенных трассировок за минуту, процесс не завершается). Диагностика # остаётся в stdout контейнера: текст трассировки значений переменных не несёт. ignore_logger(__name__) _REDACTED = "[REDACTED]" # Ключи consumer-PII (нижний регистр; сверка case-insensitive). Набор МЕРЫ # (client_name/client_phone/client_email/phone/email/name, #396) + company/ # message — специфичные для PilotRequestInput (app/api/v1/pilot.py) поля # свободного текста (#2457-review). _PII_KEYS = frozenset( { "client_name", "client_phone", "client_email", "phone", "email", "name", "company", "message", } ) _SENSITIVE_PARAM_RE = re.compile( r"((?:api[_-]?[Kk]ey|token|access[_-]?token|secret)=)([^&\s]+)", re.IGNORECASE, ) def _redact(value: Any) -> Any: """Заменить значения чувствительных query-параметров на [REDACTED].""" if isinstance(value, str): return _SENSITIVE_PARAM_RE.sub(r"\1[REDACTED]", value) return value def scrub_sensitive_query(event: Event, _hint: dict[str, Any]) -> Event | None: """Redact api keys / tokens из URL spans перед отправкой в GlitchTip. Обрабатывает: - span.data["url"], span.data["http.url"], span.data["http.target"] - span["description"] - event["request"]["url"] """ for span in event.get("spans") or []: data = span.get("data") if isinstance(data, dict): for key in ("url", "http.url", "http.target"): if key in data: data[key] = _redact(data[key]) if "description" in span: span["description"] = _redact(span["description"]) request = event.get("request") if isinstance(request, dict) and "url" in request: request["url"] = _redact(request["url"]) return event def _scrub(obj: Any) -> None: """Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place).""" if isinstance(obj, dict): for key, value in obj.items(): if isinstance(key, str) and key.lower() in _PII_KEYS: obj[key] = _REDACTED else: _scrub(value) elif isinstance(obj, list): for item in obj: _scrub(item) def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None: """Redact consumer-PII (см. `_PII_KEYS`) из event (error ИЛИ transaction) перед отправкой в GlitchTip. Обходит `request.data` / `extra` / `contexts` рекурсивно (dict/list), заменяет значения PII-ключей на [REDACTED] in-place. Возвращает event (не None) — иначе SDK дропнет отчёт целиком. """ if not isinstance(event, dict): return event request = event.get("request") if isinstance(request, dict): _scrub(request.get("data")) _scrub(event.get("extra")) _scrub(event.get("contexts")) return event def scrub_event(event: Event, hint: dict[str, Any]) -> Event | None: """Composed `before_send` / `before_send_transaction` handler: PII-scrub + URL query-secret redact. Вешать ОДИНАКОВО на оба канала — см. module docstring (#2457-review): transaction-scope несёт `request.data` точно так же, как error-scope. try/except — предохранитель: sentry_sdk оборачивает вызов `before_send` в `capture_internal_exceptions`, который при исключении внутри хендлера ТОЛЬКО логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что редактор упал, — event просто не уйдёт). Наблюдаемость важнее полноты покрытия редактора: лучше отправить событие в состоянии "сколько успели отредактировать до сбоя", чем не отправить вообще и молча остаться без сигнала в мониторинге. """ try: scrub_pii_event(event, hint) scrub_sensitive_query(event, hint) except Exception as exc: # Ни трассировки, ни str(exc): и то и другое способно нести значения из # ЕЩЁ НЕ ОЧИЩЕННОГО event — то есть страховка утекла бы ровно то, что # защищает (#2753). Имя класса исключения данных не несёт. Событием # мониторинга эта строка не станет — см. ignore_logger выше. logger.error( "sentry_scrub.scrub_event: handler failed (%s), sending event as-is", type(exc).__name__, ) return event