gendesign/tradein-mvp/backend/app/observability/sentry_scrub.py
bot-backend 56194c606a
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Failing after 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 46s
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 4m42s
fix(mera/b2c): семь дефектов публичного периметра, найденных состязательным ревью
Ревью четырьмя независимыми линзами (периметр, семантика Caddy, политика ПДн
против кода, фронт) + по два проверяющих на каждую находку. Ниже — то, что
пережило проверку и воспроизведено на живом коде, а не выведено из чтения.

## Caddy: открытый редирект и потерянные ссылки

Захват хвоста регекспом (`^/trade-in/mera-public/(.+)$` → `redir /{re…1}`) —
открытый редирект. Захват берётся из РАСКОДИРОВАННОГО пути, поэтому
`/trade-in/mera-public/%5Cevil.example/pay` даёт цель `/\evil.example/pay`, а
браузеры трактуют `/\` как `//` — Location уводит на чужой хост. Готовая
фишинговая заготовка с домена, который напечатан внутри оферты и уходит
модератору эквайера. Заменено поимённым списком путей: такой адрес просто не
матчится.

Адреса со слэшем на конце (`/oferta/`, и длинные `…/oferta/`) отдавали 404 —
ровно те ссылки, ради сохранности которых редирект и делался. Добавлена
нормализация, цепочка замкнута (проверено: 2 перехода → 200).

Query-строка терялась: размещённые ссылки с UTM приходили бы в аналитику как
прямой заход. `uri strip_prefix` + `{uri}` переносит её. Обёртка `route`
обязательна — без неё `redir` выполняется раньше `uri` и Location равен
исходному адресу (бесконечный цикл, поймано на стенде).

`/v3` — черновое превью с маркетинговыми плейсхолдерами — было открыто на
боевом домене молча. Теперь названо вслух и запинено тестом.

## Гейты, которых не было

`caddy validate` не звал НИ ОДИН workflow, а deploy применяет конфиг не через
`reload` (тот отказался бы принять битый), а через `up -d --force-recreate` —
опечатка уводит контейнер в crash-loop и роняет ВСЕ домены. Добавлен гейт в
ci.yml, тем же образом caddy:2, что и на проде.

Проверка «роут ↔ Caddy» была односторонней и пропускала обратную ошибку —
путь, открытый наружу, о котором приложение не знает. Так и уехал `/v3`.
Теперь двусторонняя, плюс проверка, что для каждой страницы есть 301.

## Бюджет внешнего геокодера

Per-IP окна ограничивают одного клиента, но не сумму: 40/мин с адреса — это
57 600 в сутки при бесплатном тире DaData в 10 000, ОБЩЕМ с закрытым контуром.
Подтверждено на проде: достаточно упомянуть не-екатеринбургский город, чтобы
локальный тир отключился и запрос гарантированно ушёл во внешний сервис. То
есть один скрипт оставлял без подсказок платящих пилотов.

Per-IP снижен до 20/мин, добавлен общий суточный потолок 2000 и потолок
одновременных подсказок (4): кадастровый тир уходит в FDW-скан чужой базы,
держит соединение около секунды, а пул общий с B2B — полтора десятка
параллельных публичных запросов клали бы закрытый контур.

## «Адрес нигде не сохраняется» — теперь правда целиком

Две утечки, обе воспроизведены:

1. ЖУРНАЛЫ. Геокодер печатает введённую строку открытым текстом на каждый
   вызов, прод пишет stdout в persistent journald — адрес ложился на диск
   рядом с IP того же запроса в access-логе Caddy. Закрыто фильтром логов на
   время публичного запроса (contextvar, переживает await и to_thread).
   Закрытый контур логи сохраняет: они нужны для разбора жалоб пилотов.
2. МОНИТОРИНГ. sentry_sdk кладёт в событие ПОЛНОЕ тело запроса — а тело
   публичной ручки это ровно `{"q": "<адрес>"}`; `send_default_pii=False` тут
   не гейт, он про куки. Плюс брэдкрамб httpx несёт адрес в query геокодера.
   Закрыто `scrub_public_address`.

Текст п. 5.4 политики расширен до «ни в журналы веб-сервера, ни в технические
журналы, ни в мониторинг» — ровно то, что теперь обеспечено кодом.

## Фронт

- Отмена запроса подсказок откладывалась внутрь следующего debounce-такта и
  не наступала вовсе, если человек переставал печатать: ответ по старой строке
  долетал и ложился в список. Контроллер создаётся сразу, отменяется в cleanup.
- Список схлопывался на каждое нажатие — клик по намеченному пункту
  промахивался. Старая выдача висит, пока не пришла новая.
- «Комнат» с лэндинга — свободный текст: «студия» не совпадала ни с одним
  option, селект показывал пустоту, parseInt давал NaN, на сервер уходил
  rooms: null → 422 с текстом «сломалось на нашей стороне». Нормализация
  вынесена чистой функцией и покрыта тестами.
- У пробы покрытия не было ни таймаута, ни отмены: оборванное соединение
  оставляло кнопку в «Смотрим данные…» навсегда. 15 с + понятный текст.
- Ошибка подсказок глушилась в пустой список — тупик без объяснения.
- Комбобокс: Tab проваливался в кнопки подсказок, список не закрывался по
  уходу фокуса и перекрывал поля, Escape оставлял висячий aria-activedescendant.

## Проверено

Локальный стенд (реальный site-блок Caddy + заглушка): 18 маршрутов, включая
`%5C`, `//`, `%2F` — все три теперь 404. vitest 55 passed, backend 17 passed по
публичному API, tsc, lint, build, isolation guard 41 файл, caddy validate.

Мутации: снять редакцию логов → падает тест журналов; не вырезать тело запроса
→ падает тест мониторинга; убрать /estimate из Caddy → падает тест маршрутов.
2026-08-16 10:01:47 +03:00

385 lines
26 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.

"""Хуки before_send для GlitchTip/Sentry SDK (tradein-local, #396, #tgsupport).
Redact-ит consumer-PII (client_name / client_phone / client_email и пр.)
из error events до отправки в GlitchTip — estimator/trade-in flow таскает
эти поля, а send_default_pii=False их не покрывает (это user-data в
request.data / extra / contexts, не PII-заголовки).
`redact_telegram_bot_token` — отдельный хук (#tgsupport review): Telegram Bot
API токен живёт в URL-пути (`https://api.telegram.org/bot<id>:<secret>/...`),
а не в query/userinfo, поэтому НЕ покрывается sentry_sdk `sanitize_url` (тот
режет только `user:pass@` и query-параметры). Токен утекает ДВУМЯ путями,
которые `_scrub`/`scrub_pii_event` (ключ-based, PII-словарь) не ловят:
1. `include_local_variables=True` (sentry_sdk default) кладёт locals
stack-фрейма (`self._base`, `url` в `TelegramClient._request`) в
traceback → полный токен открытым текстом.
2. `HttpxIntegration` кладёт полный request URL в span `data` (виден при
любом ненулевом `traces_sample_rate`), а не только в traceback.
Поэтому редактор — НЕ ключ-based, а regex full-text по КАЖДОЙ строке во всём
event (глубокий обход dict/list/tuple) — токен может всплыть в любом поле.
"""
from __future__ import annotations
import re
from typing import Any
from sentry_sdk.types import Event
from tenacity import RetryError
_REDACTED = "[REDACTED]"
# Ключи consumer-PII (нижний регистр; сверка case-insensitive).
# PR-D2 (payments perimeter hardening): + платёжные поля Т-Банка (customer_email/
# customer_phone из checkout, pan/expdate/cardid/rebillid/token/terminalkey из
# notify) — belt-and-suspenders поверх `scrub_payment_request_body` ниже, которая
# вырезает `request.data` для /payments/* целиком: этот словарь всё равно нужен
# для extra/contexts И на случай, если платёжное поле когда-нибудь попадёт в
# error event НЕ через request.data (напр. кто-то положит его в extra вручную).
_PII_KEYS = frozenset(
{
"client_name",
"client_phone",
"client_email",
"phone",
"email",
"name",
"customer_email",
"customer_phone",
"pan",
"expdate",
"cardid",
"rebillid",
"token",
"terminalkey",
}
)
# Сегмент пути платёжного периметра (notify + checkout + любой будущий
# /payments/* суб-путь) — PR-D2, готовит почву под PR-D3 (эндпоинты ещё не
# существуют). Матчим по сегменту, не по конкретному эндпоинту, чтобы не
# требовать правки этого файла на каждый новый платёжный путь.
_PAYMENTS_URL_SEGMENT = "/api/v1/trade-in/payments/"
# Telegram Bot API токен в пути URL: /bot<numeric_id>:<secret-part>/<method>.
# Матчим ровно этот сегмент (не весь URL) — сохраняет остальной путь/query
# читаемым для диагностики (метод API, error code и т.п.).
_TG_BOT_TOKEN_RE = re.compile(r"/bot\d+:[A-Za-z0-9_-]+")
_TG_BOT_TOKEN_REPLACEMENT = "/bot[REDACTED]"
# Тот же токен БЕЗ префикса `/bot` — форма `<numeric_id>:<secret>` сама по себе
# (напр. локаль `token` в конструкторе TelegramClient, или если его кто-то
# засунет в log-сообщение). Сейчас единственный путь такой формы в event —
# locals стек-фрейма, а они выключены через include_local_variables=False в
# tgbot_main. Но именно на отказ того флага этот редактор и страхует: без этой
# ветки рубеж был бы один, а не два. Формат токена BotFather: 8-12 цифр `:` 35
# символов base64url — нижние границы взяты с запасом, чтобы не промахнуться
# на нестандартных id, но остаться уже, чем `\d+:\S+` (тот бил бы по любым
# `id:value` в логах, напр. `chat_id:12345`).
_TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b")
# Query-string секреты в исходящих URL сторонних API (аудит-фикс, #security-audit):
# исторически — mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др.,
# admin.rotate_proxy_ip) несла провайдерский API-ключ в query
# (`?...&proxy_key=...`). Ручка и переменные удалены (#2616 шаг 2/3, мёртвая
# подписка) — редактор оставлен как generic safety net (не ключ-based, любой
# будущий query-секрет с распространённым именем параметра тоже покрыт).
# Два независимых пути утечки в GlitchTip, зеркалящих TG-токен выше:
# 1. `HttpxIntegration.send()` парсит URL через `parse_url(str(request.url),
# sanitize=False)` (ЯВНЫЙ opt-out из sentry_sdk `sanitize_url`, который иначе
# сам вырезал бы query-параметры) и кладёт полный URL в span `data["url"]` —
# сейчас неактивно (`traces_sample_rate=0.0` в app/main.py/scheduler_main.py →
# span не сэмплится/не уходит), но молча перестанет спасать, если трейсинг
# когда-нибудь включат.
# 2. `include_local_variables=True` (sentry_sdk default в app/main.py — в отличие
# от tgbot_main.py, где явно False) кладёт stack-frame locals в traceback
# открытым текстом (был прецедент: `rotate_url`/`exc` в удалённом
# admin.rotate_proxy_ip).
# Как и TG-токен — full-text regex по КАЖДОЙ строке event (не ключ-based): секрет
# может всплыть где угодно (frame locals, breadcrumb, exception message). НЕ
# завязано на конкретного провайдера — покрывает любой query-параметр из
# общеупотребимого набора секретных имён (api_key/proxy_key/token/secret/password/
# access_token/auth) — живой пример: ASOCKS_API_TOKEN (потенциально
# другой провайдер, другое имя параметра).
_URL_SECRET_QUERY_RE = re.compile(
r"(?i)([?&](?:api[_-]?key|proxy[_-]?key|token|secret|password|pwd|"
r"access[_-]?token|auth)=)[^&\s\"'<>]+"
)
_URL_SECRET_QUERY_REPLACEMENT = r"\g<1>" + _REDACTED
# httpx error-message URL query stabilization (GlitchTip-noise review round 2,
# claim #1). `httpx.HTTPStatusError.__str__()` (raised by `response.raise_for_status()`)
# bakes the FULL request URL — INCLUDING query string — into the exception message:
# "Client error '403 Forbidden' for url 'https://nominatim.openstreetmap.org/
# search?q=<адрес>&format=json&limit=3'" (воспроизведено эмпирически: httpx.Response
# с params={"q": "<адрес>"} → raise_for_status() → именно этот текст). После
# app/services/geocoder.py `reraise=True` (стабилизирует ТИП исключения — RetryError
# → httpx.HTTPStatusError, см. комментарий у `_nominatim_lookup`) ИМЕННО этот текст
# становится GlitchTip title/value каждого события. `q=<адрес>` — переменная часть
# на КАЖДЫЙ вызов (ночной `geocode_missing_listings` — сотни разных адресов за
# прогон), значит per-address issue-explosion не устранён `reraise=True`, а просто
# переехал с RetryError на HTTPStatusError (тот же механизм: GlitchTip группирует по
# нестабильному тексту сообщения — это же подтверждают исходные 2 462 RetryError-issue,
# невозможные при группировке чисто по stacktrace/culprit).
#
# Отдельная регулярка от `_URL_SECRET_QUERY_RE` намеренно: та бьёт по ИМЕНИ известных
# secret-параметров (security-редактор), здесь — ЛЮБОЙ query string в httpx-стиле
# сообщении "for url '...'" (grouping-стабильность, не секретность — `q` не секрет).
# Режем query целиком (не только конкретные параметры) — host+path остаются
# стабильными для группировки, "for url '...'" — единственная форма, которую бьёт
# regex (не трогает произвольные строки с `?`, см. тест
# test_scrub_pii_event_httpx_url_query_stabilization_leaves_unrelated_text_untouched).
_HTTPX_ERROR_URL_QUERY_RE = re.compile(r"(for url '[^'?]*)\?[^']*(')")
_HTTPX_ERROR_URL_QUERY_REPLACEMENT = r"\g<1>?" + _REDACTED + r"\g<2>"
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 _regex_redact_inplace(obj: Any, pattern: re.Pattern[str], replacement: str) -> None:
"""Рекурсивно (IN-PLACE, как `_scrub`) прогоняет `pattern.sub(replacement, ...)`
по КАЖДОЙ строке event (не ключ-based) — общий обход, переиспользуемый и для
URL-секретов (`_URL_SECRET_QUERY_RE`), и для стабилизации httpx error-message
URL (`_HTTPX_ERROR_URL_QUERY_RE`): в обоих случаях переменные данные утекают
через httpx span `url`/`query` data и через текст исключений (`str(exc)` httpx
содержит полный request URL), а не только через известные PII-поля формы.
Мутирует dict/list на месте (НЕ пересоздаёт структуру, в отличие от
`_redact_strings`) — сохраняет identity верхнеуровневого `event`, на что
опирается контракт `scrub_pii_event`/`before_send` и существующие тесты
(`out is event`).
"""
if isinstance(obj, dict):
for key, value in obj.items():
if isinstance(value, str):
redacted = pattern.sub(replacement, value)
if redacted != value:
obj[key] = redacted
else:
_regex_redact_inplace(value, pattern, replacement)
elif isinstance(obj, list):
for i, value in enumerate(obj):
if isinstance(value, str):
redacted = pattern.sub(replacement, value)
if redacted != value:
obj[i] = redacted
else:
_regex_redact_inplace(value, pattern, replacement)
# tuple намеренно не обрабатываем: sentry_sdk event — это JSON-совместимая
# структура (dict/list/str/int/...), tuple там не встречается, а даже если бы
# встретился — он immutable, in-place правка невозможна (см. `_scrub`, тот же
# выбор для dict/list).
def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
"""Redact consumer-PII + URL query-string секретов/nondeterministic-данных из
error event перед отправкой.
Композиция (все — in-place, сохраняют identity `event`): (1) ключ-based
dict-scrub consumer-PII полей формы (как раньше), (2) full-text regex-проход
по ВСЕМУ event, вырезающий значения секрет-подобных query-параметров в любой
строке (proxy/API-ключи в исходящих URL сторонних сервисов, напр. mobileproxy
changeip — #security-audit), (3) full-text regex-проход, стабилизирующий httpx
error-message URL (`for url '...?...'`) — убирает переменный query string
(адрес геокодинга и т.п.), от которого GlitchTip group-title плодит issue на
каждый вызов (GlitchTip-noise review round 2, claim #1; см. комментарий у
`_HTTPX_ERROR_URL_QUERY_RE`). (2) и (3) не завязаны на конкретные ключи полей —
ловят секрет/переменные данные в frame locals, breadcrumb, exception message
и т.д., где они могут оказаться независимо от
include_local_variables/traces_sample_rate. Возвращает event (не None).
"""
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"))
_regex_redact_inplace(event, _URL_SECRET_QUERY_RE, _URL_SECRET_QUERY_REPLACEMENT)
_regex_redact_inplace(event, _HTTPX_ERROR_URL_QUERY_RE, _HTTPX_ERROR_URL_QUERY_REPLACEMENT)
return event
def scrub_payment_request_body(event: Event, _hint: dict[str, Any]) -> Event | None:
"""Вырезать `event['request']['data']` целиком для платёжных путей (PR-D2).
Ключ-based `scrub_pii_event` НЕ спасает платёжную нотификацию: sentry_sdk
2.64 (`integrations/starlette.py`) кладёт ПОЛНОЕ тело запроса в
`event.request.data`, и `send_default_pii=False` этот путь не гейтит — тот
флаг управляет только куками, не телом запроса (проверено живьём на соседнем
продукте). Тело нотификации Т-Банка несёт `Token`/`Pan`/`ExpDate`/`CardId`/
`RebillId`/`DATA` — банк сам выбирает имена полей, перечислить их все заранее
нельзя, поэтому единственная безопасная стратегия для этого пути — не
отправлять тело целиком, а не пытаться вычистить отдельные ключи.
Матчим по сегменту `/api/v1/trade-in/payments/` (не по конкретному
эндпоинту) — покрывает notify, checkout и любой будущий суб-путь одним
фильтром, без правки этого файла на каждое расширение платёжного API.
Сравнение регистронезависимое: `_PUBLIC_PATHS` (rbac) — точное множество без
учёта регистра только у Caddy, не у Python, так что нестандартный регистр
пути технически может долететь до обработчика и породить событие.
Композировать с `scrub_pii_event`/`redact_telegram_bot_token`, а не вместо
них — этот шаг закрывает только `request.data`, extra/contexts и
traceback-locals остаются на ответственности остальных шагов композиции.
"""
if not isinstance(event, dict):
return event
request = event.get("request")
if isinstance(request, dict):
url = request.get("url")
if isinstance(url, str) and _PAYMENTS_URL_SEGMENT in url.lower():
request.pop("data", None)
return event
_PUBLIC_API_URL_SEGMENT = "/api/public/"
#: Хосты геокодеров: их URL несёт введённый адрес прямо в query.
_GEOCODER_HOSTS = ("nominatim.openstreetmap.org", "suggestions.dadata.ru", "dadata.ru")
_ANY_URL_QUERY_RE = re.compile(r"^([^?]*)\?.*$")
def scrub_public_address(event: Event, _hint: dict[str, Any]) -> Event | None:
"""Убрать введённый анонимом адрес из события GlitchTip.
На `meraocenka.ru/estimate` и в политике обработки ПДн сказано, что адрес
нигде не сохраняется. GlitchTip — внешний сервис, и до этой правки адрес
доезжал туда двумя путями (оба воспроизведены состязательным ревью
16.08.2026, не выведены из чтения кода):
1. `event.request.data`. sentry_sdk кладёт в событие ПОЛНОЕ тело запроса,
а `send_default_pii=False` этот путь не гейтит — он про куки и IP, не
про тело. Тело публичной ручки — это ровно `{"q": "<адрес>"}`.
Ключ-based `scrub_pii_event` не помогает: `_PII_KEYS` перечисляет
имена вроде `client_phone`, а поле здесь называется `q`.
2. Брэдкрамб исходящего HTTP-запроса к геокодеру: `HttpxIntegration`
кладёт URL целиком, а адрес там в query (`?q=Малышева+30`).
Стратегия та же, что у платёжного тела: не вычищать отдельные ключи, а
убирать целиком — состав полей задаёт не только наш код (у геокодеров свои
параметры), поэтому перечислить безопасное заранее нельзя.
Композировать с остальными шагами, а не вместо них.
"""
if not isinstance(event, dict):
return event
request = event.get("request")
if isinstance(request, dict):
url = request.get("url")
if isinstance(url, str) and _PUBLIC_API_URL_SEGMENT in url.lower():
request.pop("data", None)
crumbs = event.get("breadcrumbs")
values = crumbs.get("values") if isinstance(crumbs, dict) else crumbs
if isinstance(values, list):
for crumb in values:
if not isinstance(crumb, dict):
continue
data = crumb.get("data")
if not isinstance(data, dict):
continue
url = data.get("url")
if isinstance(url, str) and any(h in url for h in _GEOCODER_HOSTS):
data["url"] = _ANY_URL_QUERY_RE.sub(r"\g<1>?" + _REDACTED, url)
return event
def _redact_strings(obj: Any) -> Any:
"""Рекурсивно проходит dict/list/tuple и прогоняет обе токен-регулярки по КАЖДОЙ
строке (не только по конкретным ключам) — токен может оказаться в locals
stack-фрейма, span data, breadcrumb message, request.url и т.д. Возвращает
НОВУЮ структуру (не мутирует `obj` — в отличие от `_scrub`, чтобы не зависеть
от того, какие контейнеры sentry_sdk считает mutable в своём event dict)."""
if isinstance(obj, str):
redacted = _TG_BOT_TOKEN_RE.sub(_TG_BOT_TOKEN_REPLACEMENT, obj)
return _TG_BOT_TOKEN_BARE_RE.sub(_REDACTED, redacted)
if isinstance(obj, dict):
return {k: _redact_strings(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_redact_strings(v) for v in obj]
if isinstance(obj, tuple):
return tuple(_redact_strings(v) for v in obj)
return obj
def redact_telegram_bot_token(event: Event, _hint: dict[str, Any]) -> Event | None:
"""Full-text regex redaction Telegram Bot API токена по ВСЕМУ event (#tgsupport).
Ловит оба вектора утечки токена в GlitchTip, которые ключ-based `scrub_pii_event`
не покрывает: locals stack-фреймов (`include_local_variables=True`) и httpx-span
`data` (полный request URL). Композировать с `scrub_pii_event`, не вместо него —
разные классы секретов (PII полей формы vs bot-токен в URL).
"""
if not isinstance(event, dict):
return event
return _redact_strings(event) # type: ignore[return-value]
# ── RetryError fingerprint stabilization (GlitchTip noise-reduction) ────────
# tenacity.RetryError.__str__() тащит repr() последнего Future
# (`RetryError[<Future at 0x7f... state=finished raised HTTPStatusError>]`) —
# memory address объекта, случайный на каждый вызов процесса. Пока geocoder.py
# ретраил Nominatim без `reraise=True`, каждое исчерпание ретраев (Nominatim
# недоступен/rate-limit/403) улетало в GlitchTip как RetryError с этим
# нестабильным текстом → одна и та же причина плодила отдельный issue на КАЖДОЕ
# исчерпание (2 462 issue из 7 461 в трекере на момент фикса). `reraise=True`
# в app/services/geocoder.py устраняет RetryError на этом пути (пробрасывает
# реальное исключение) — но реальное исключение (httpx.HTTPStatusError) само
# несёт нестабильный текст (URL с адресом в query), поэтому group-стабильность
# для geocoder держит НЕ эта функция, а `_HTTPX_ERROR_URL_QUERY_RE` в
# `scrub_pii_event` (см. её комментарий, GlitchTip-noise review round 2 claim #1).
#
# Функция ниже — belt-and-suspenders для ЛЮБОГО кода, который ретраит через
# tenacity БЕЗ `reraise=True` (живой пример на момент фикса: `BaseScraper._http_get`
# в packages/scraper-kit — retry-декоратор НЕ reraise'ит, сознательно оставлен на
# этот фолбэк, а не на URL-стабилизацию: ретраятся listing detail URL БЕЗ query
# string — переменная часть там в ПУТИ (offer id), которую `_HTTPX_ERROR_URL_QUERY_RE`
# не покрывает; см. review round 2 claim #3). Схлопывает RetryError в ОДИН
# persistent issue per (culprit, класс исключения-причины) — culprit обязателен:
# БЕЗ него RetryError с одинаковым типом причины из НЕСВЯЗАННЫХ подсистем (напр.
# geocoder и scraper_kit одновременно ретраят httpx и оба ловят HTTPStatusError)
# схлопнулись бы в ОДИН issue — потеря сигнала хуже исходного шума (review round 2
# claim #2). Источник culprit — `event["logger"]`: sentry_sdk `LoggingIntegration`
# ставит его в имя logger'а (`logging.getLogger(__name__)`, напр.
# "app.services.geocoder" vs "scraper_kit.providers.yandex.detail") на КАЖДОМ
# `logger.exception(...)`/`logger.error(...)` — стабильно per-модуль, не зависит от
# конкретного запроса. Остальная часть fingerprint собрана ТОЛЬКО из стабильных
# данных — имя типа исключения-причины (небольшой фиксированный словарь вроде
# "HTTPStatusError"/"ConnectTimeout") — НИКАКИХ переменных данных запроса (адрес,
# IP, id объявления и т.п.), иначе проблема повторится в других терминах.
def stabilize_retry_error_fingerprint(event: Event, hint: dict[str, Any]) -> Event | None:
"""before_send-хук: схлопывает tenacity.RetryError в один persistent issue per
(источник, тип причины) — РАЗНЫЕ источники (geocoder / scraper_kit / будущий
retry-код) НЕ схлопываются друг с другом, даже если тип причины совпадает.
Определяет тип exception через `hint["exc_info"]` (реальный объект
исключения, тот же контракт что sentry_sdk передаёт в before_send) — не
парсит уже сериализованный event dict, надёжнее к изменениям формата SDK.
`isinstance` (не сравнение `type(...).__name__` со строкой) — иначе любой
посторонний класс с совпадающим именем ложно матчился бы, а подкласс
`tenacity.RetryError` — промахивался бы. Не-RetryError события возвращает без
изменений (OperationalError, алерты scraper sweep'ов и т.п. фильтр не трогает).
"""
if not isinstance(event, dict):
return event
exc_info = hint.get("exc_info") if isinstance(hint, dict) else None
exc_value = exc_info[1] if exc_info and len(exc_info) > 1 else None
if not isinstance(exc_value, RetryError):
return event
cause = exc_value.__cause__ or exc_value.__context__
cause_type = type(cause).__name__ if cause is not None else "Unknown"
culprit = event.get("logger") or event.get("transaction") or "unknown"
event["fingerprint"] = ["retry-exhausted", str(culprit), cause_type]
return event