fix(tradein/observability): stop basic_auth 401 and RetryError GlitchTip noise

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.
This commit is contained in:
bot-backend 2026-08-15 18:08:04 +03:00
parent e8fe9faa13
commit 8fcec9f12e
7 changed files with 299 additions and 11 deletions

View file

@ -8,6 +8,13 @@ Persistent offset в /state/offset.json — не дублируем при resta
Throttle: при >10 401 events за 60s однократный digest event
(чтобы не флудить GlitchTip storm'ом); индивидуальные events во время storm пропускаются.
before_send=_drop_basic_auth_noise (glitchtip-noise фикс): все события отсюда
дропаются перед отправкой в GlitchTip 401 от неаутентифицированного запроса
не ошибка сервиса, это боты сканируют закрытый basic_auth'ом сайт. Раньше это
был крупнейший источник шума в трекере (3 738 issue). Скрипт по-прежнему тэйлит
лог и печатает `[forwarder] 401 event sent: ...` в stdout (docker logs) просто
больше не шлёт эти события в issue-трекер. Смотри `_drop_basic_auth_noise` docstring.
Реальный Caddy JSON access log (v2) структура:
{
"level": "info",
@ -73,6 +80,41 @@ _shutdown = False
_last_exc_sent: float = 0.0
_EXC_THROTTLE_S: float = 300.0
# event_type-теги, которыми emit_event/emit_digest помечают КАЖДОЕ отправляемое
# событие (см. scope.set_tag("event_type", ...) ниже) — используются как ключ
# для before_send-фильтра.
_BASIC_AUTH_EVENT_TYPES = frozenset({"basic_auth_failed", "basic_auth_storm"})
def _drop_basic_auth_noise(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg]
"""before_send-фильтр: 401 неаутентифицированного basic_auth-запроса — НЕ
ошибка сервиса, а expected-поведение сканеров-ботов, ломящихся в закрытый
basic_auth'ом gendsgn.ru (`GET /wp-admin/install.php` и подобное). До этого
фикса emit_event/emit_digest слали КАЖДЫЙ такой 401 individual-событием (или
storm-digest) в GlitchTip remote_ip в message/тегах раздувал кардинальность
(3 738 issue, 2 019 различных заголовков, топ 222 события на «GET
/wp-admin/install.p»), топя содержательные алерты (OperationalError, sweep
failures) в шуме сканеров.
Дропаем НА ИСТОЧНИКЕ (before_send), не постфактум-чисткой issue-трекера
так шум не появляется вообще, а не изредка удаляется руками. Фильтруем по
тегу `event_type`, который ставят ТОЛЬКО emit_event/emit_digest необработанные
исключения самого форвардера (`capture_exception` в конце `main()`, реальный
баг скрипта) этот тег не несут и проходят фильтр как есть (см. `except
Exception` ниже в `main()`).
"""
tags = event.get("tags")
event_type = None
if isinstance(tags, dict):
event_type = tags.get("event_type")
elif isinstance(tags, list):
# sentry_sdk в некоторых версиях сериализует tags как list[tuple[str, str]]
# вместо dict — на всякий случай поддерживаем обе формы.
event_type = dict(tags).get("event_type") if tags else None
if event_type in _BASIC_AUTH_EVENT_TYPES:
return None
return event
def _signal_handler(signum: int, frame: object) -> None:
global _shutdown
@ -221,6 +263,7 @@ def main() -> None:
traces_sample_rate=0.0,
attach_stacktrace=False,
send_default_pii=False,
before_send=_drop_basic_auth_noise,
# Отключаем интеграции которые не нужны тонкому sidecar
default_integrations=False,
)

View file

@ -0,0 +1,77 @@
"""Тесты для `_drop_basic_auth_noise` (before_send-фильтр, glitchtip-noise).
Раньше форвардер слал КАЖДЫЙ basic_auth 401 (сканеры-боты, ломящиеся в закрытый
basic_auth'ом gendsgn.ru) individual-событием в GlitchTip — 3 738 issue, 2 019
различных заголовков (remote_ip раздувал кардинальность), топя содержательный
сигнал. `_drop_basic_auth_noise` дропает эти события НА ИСТОЧНИКЕ (before_send),
но НЕ должен трогать unhandled-ошибки самого форвардера (реальный баг скрипта
`capture_exception` без `event_type`-тега, аналог "500 должен пройти").
"""
from __future__ import annotations
import os
# DSN обязателен на module-level (`os.environ["GLITCHTIP_DSN"]`, fail-fast) — задаём
# ДО импорта forwarder.py, иначе импорт падает KeyError.
os.environ.setdefault("GLITCHTIP_DSN", "http://test@localhost/1")
from forwarder import _BASIC_AUTH_EVENT_TYPES, _drop_basic_auth_noise
def test_drops_individual_basic_auth_401() -> None:
"""emit_event() тегирует event_type=basic_auth_failed — 401 от бота-сканера,
не ошибка сервиса, должен быть отброшен (return None)."""
event = {
"tags": {"event_type": "basic_auth_failed", "remote_ip": "95.165.147.218"},
"message": "basic_auth 401 — GET /wp-admin/install.php from 95.165.147.218",
}
assert _drop_basic_auth_noise(event, {}) is None
def test_drops_basic_auth_storm_digest() -> None:
"""emit_digest() тегирует event_type=basic_auth_storm — тоже 401-класс, тоже
не ошибка сервиса, дропаем."""
event = {
"tags": {"event_type": "basic_auth_storm"},
"message": "basic_auth storm — 15 failed attempts in 60s",
}
assert _drop_basic_auth_noise(event, {}) is None
def test_drops_when_tags_serialized_as_list_of_tuples() -> None:
"""Некоторые версии sentry_sdk сериализуют tags как list[tuple[str, str]]
вместо dict фильтр обязан поддерживать обе формы."""
event = {"tags": [("event_type", "basic_auth_failed")]}
assert _drop_basic_auth_noise(event, {}) is None
def test_passes_through_forwarder_own_crash() -> None:
"""500-аналог: unhandled exception самого форвардера (capture_exception в
конце main(), реальный баг скрипта напр. PermissionError на STATE_FILE) не
несёт event_type-тег должен пройти НЕТРОНУТЫМ, не быть молча проглоченным
вместе с ботовым шумом."""
event = {
"level": "error",
"exception": {"values": [{"type": "PermissionError", "value": "denied"}]},
}
out = _drop_basic_auth_noise(dict(event), {})
assert out == event
def test_passes_through_event_without_tags() -> None:
event: dict = {"message": "something unrelated"}
out = _drop_basic_auth_noise(dict(event), {})
assert out == event
def test_passes_through_unrelated_tag_value() -> None:
event = {"tags": {"event_type": "something_else"}}
out = _drop_basic_auth_noise(dict(event), {})
assert out == event
def test_basic_auth_event_types_are_exactly_the_two_emitters_use() -> None:
"""Явная фиксация словаря — emit_event → basic_auth_failed,
emit_digest basic_auth_storm (см. forwarder.py)."""
assert _BASIC_AUTH_EVENT_TYPES == frozenset({"basic_auth_failed", "basic_auth_storm"})

View file

@ -66,17 +66,28 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
# worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/
# frontend), отдельного broker нет → мониторить нечего.
if settings.glitchtip_dsn:
from app.observability.sentry_scrub import redact_telegram_bot_token
from app.observability.sentry_scrub import (
redact_telegram_bot_token,
stabilize_retry_error_fingerprint,
)
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
"""Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) —
см. app/tgbot_main.py._before_send (идентичная композиция, тот же риск:
теперь этот процесс тоже держит TelegramClient в стек-фреймах при ошибке
sendMessage, а include_local_variables=False ниже первый рубеж защиты)."""
"""Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) +
RetryError fingerprint-стабилизация (glitchtip-noise) см.
app/tgbot_main.py._before_send (та же композиция без последнего шага,
тот бот geocoder не зовёт). PII/token тот же риск: теперь этот процесс
тоже держит TelegramClient в стек-фреймах при ошибке sendMessage, а
include_local_variables=False ниже первый рубеж защиты. RetryError
этот процесс обслуживает /api/v1/geocode/* (suggest/lookup/reverse),
которые ретраят Nominatim через tenacity; см.
sentry_scrub.stabilize_retry_error_fingerprint."""
scrubbed = scrub_pii_event(event, hint) # type: ignore[arg-type]
if scrubbed is None:
return None
return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value]
detokened = redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type]
if detokened is None:
return None
return stabilize_retry_error_fingerprint(detokened, hint) # type: ignore[arg-type,return-value]
sentry_sdk.init(
dsn=settings.glitchtip_dsn,

View file

@ -174,3 +174,40 @@ def redact_telegram_bot_token(event: Event, _hint: dict[str, Any]) -> Event | No
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`
# теперь пробрасывает реальное исключение вместо RetryError — это ГЛАВНЫЙ фикс.
# Функция ниже — belt-and-suspenders: ловит RetryError, если он всё же всплывёт
# (ретрай-код, который забыли/не смогли пометить reraise=True), и схлопывает его
# в ОДИН persistent issue 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.
Определяет тип exception через `hint["exc_info"]` (реальный объект
исключения, тот же контракт что sentry_sdk передаёт в before_send) не
парсит уже сериализованный event dict, надёжнее к изменениям формата SDK.
Не-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 exc_value is None or type(exc_value).__name__ != "RetryError":
return event
cause = exc_value.__cause__ or exc_value.__context__
cause_type = type(cause).__name__ if cause is not None else "Unknown"
event["fingerprint"] = ["retry-exhausted", cause_type]
return event

View file

@ -44,7 +44,24 @@ if settings.glitchtip_dsn:
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from app.observability.sentry_scrub import scrub_pii_event
from app.observability.sentry_scrub import (
scrub_pii_event,
stabilize_retry_error_fingerprint,
)
def _before_send(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg]
"""PII-scrub + RetryError fingerprint-стабилизация (glitchtip-noise).
Этот процесс гоняет `geocode_missing_listings` (ночной batch, сотни
адресов за прогон) @retry-декорированные Nominatim-хелперы
(app/services/geocoder.py) на исчерпанных ретраях исторически плодили
по отдельному GlitchTip issue на КАЖДЫЙ адрес (RetryError.__str__()
тащит нестабильный repr() Future). См. sentry_scrub docstring.
"""
scrubbed = scrub_pii_event(event, hint)
if scrubbed is None:
return None
return stabilize_retry_error_fingerprint(scrubbed, hint)
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
@ -52,7 +69,7 @@ if settings.glitchtip_dsn:
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
traces_sample_rate=0.0,
send_default_pii=False,
before_send=scrub_pii_event,
before_send=_before_send,
integrations=[
SqlalchemyIntegration(),
HttpxIntegration(),

View file

@ -726,7 +726,16 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
return oblast_fallback
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
# reraise=True (GlitchTip-noise fix): без него tenacity на исчерпанных ретраях
# бросает СВОЙ tenacity.RetryError, чей str() тащит repr() последнего Future
# (`<Future at 0x...>` — адрес объекта в памяти, разный на КАЖДЫЙ вызов). GlitchTip
# группирует по этому нестабильному тексту → одна и та же причина (Nominatim
# недоступен/rate-limit) плодила отдельный issue на каждое исчерпание ретраев
# (2 462 issue из 7 461 в трекере). reraise=True пробрасывает РЕАЛЬНОЕ исключение
# (httpx.HTTPStatusError/TimeoutException) — стабильный тип+стек, группируется
# нормально. Дополнительная страховка — stabilize_retry_error_fingerprint
# (app/observability/sentry_scrub.py) на случай если RetryError всплывёт откуда-то ещё.
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
async def _nominatim_lookup(address: str, city_hint: str | None = None) -> GeocodeResult | None:
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
@ -925,7 +934,8 @@ async def _nominatim_query_city_aware(
return _dedupe_nominatim_items(ekb_data, bare_data)[:limit]
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4), reraise=True)
async def _nominatim_suggest(
query: str, limit: int = 8, city_hint: str | None = None
) -> list[GeocodeSuggestion]:
@ -1654,7 +1664,8 @@ def _format_reverse_address(addr: dict) -> str | None:
return ", ".join(parts)
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True)
async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None:
"""Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon.

View file

@ -15,6 +15,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
from app.observability.sentry_scrub import (
redact_telegram_bot_token,
scrub_pii_event,
stabilize_retry_error_fingerprint,
)
@ -259,3 +260,94 @@ def test_composed_before_send_scrubs_pii_and_token_together() -> None:
assert out["request"]["data"]["client_phone"] == "[REDACTED]"
frame_url = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]["url"]
assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_url
# ── RetryError fingerprint stabilization (glitchtip-noise, #<GlitchTip triage>) ─
#
# tenacity.RetryError.__str__() тащит repr() последнего Future — memory address
# объекта, случайный на каждый вызов процесса. Раньше (без `reraise=True` в
# app/services/geocoder.py) каждое исчерпание ретраев Nominatim улетало в
# GlitchTip как RetryError с этим нестабильным текстом → одна и та же причина
# плодила отдельный issue на КАЖДОЕ исчерпание (2 462 issue из 7 461 в трекере).
# Тесты ниже бьют по `stabilize_retry_error_fingerprint` напрямую — belt-and-
# suspenders слой (основной фикс — reraise=True), и по контракту before_send:
# 401-класс (RetryError) схлопывается, содержательные категории (500-подобный
# generic Exception, OperationalError) проходят НЕТРОНУТЫМИ.
from tenacity import RetryError # noqa: E402
def _hint_for(exc: BaseException) -> dict:
"""Строит hint в форме, которую sentry_sdk реально передаёт в before_send —
`exc_info = (type, value, traceback)` (contract stabilize_retry_error_fingerprint
полагается именно на эту форму, не на уже сериализованный event dict)."""
return {"exc_info": (type(exc), exc, exc.__traceback__)}
def _raise_retry_error_from(cause: BaseException) -> RetryError:
try:
raise cause
except type(cause) as caught:
try:
raise RetryError(None) from caught
except RetryError as retry_exc:
return retry_exc
def test_stabilize_retry_error_sets_stable_fingerprint() -> None:
"""RetryError коллапсится в persistent issue по имени типа причины — НЕ по
нестабильному str(RetryError) (repr() Future с memory address)."""
exc = _raise_retry_error_from(TimeoutError("Nominatim timed out"))
out = stabilize_retry_error_fingerprint({"level": "error"}, _hint_for(exc))
assert out is not None
assert out["fingerprint"] == ["retry-exhausted", "TimeoutError"]
def test_stabilize_retry_error_fingerprint_has_no_variable_data() -> None:
"""Fingerprint не должен содержать IP/id объявления/адрес и т.п. — только
фиксированное имя типа исключения-причины (маленький словарь:
HTTPStatusError/ConnectTimeout/TimeoutError/...)."""
exc = _raise_retry_error_from(
ValueError("addr='ул. Ленина 1', ip=95.165.147.218, listing_id=12345")
)
out = stabilize_retry_error_fingerprint({}, _hint_for(exc))
assert out is not None
fingerprint_text = " ".join(out["fingerprint"])
assert "95.165.147.218" not in fingerprint_text
assert "12345" not in fingerprint_text
assert out["fingerprint"] == ["retry-exhausted", "ValueError"]
def test_stabilize_retry_error_leaves_operational_error_untouched() -> None:
"""401-аналог задачи: OperationalError — содержательная категория (реальный
сбой БД), фильтр её НЕ трогает (см. задачу #4 — не выключить сигнал вместе с
шумом)."""
from sqlalchemy.exc import OperationalError
exc = OperationalError("SELECT 1", {}, Exception("connection refused"))
event = {"level": "error", "message": "db connection failed"}
out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc))
assert out == event
assert "fingerprint" not in out
def test_stabilize_retry_error_leaves_generic_exception_untouched() -> None:
"""500-аналог задачи: обычное необработанное исключение (не RetryError)
проходит без изменений."""
exc = RuntimeError("scraper city-sweep failed")
event = {"level": "error"}
out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc))
assert out == event
assert "fingerprint" not in out
def test_stabilize_retry_error_no_exc_info_untouched() -> None:
"""capture_message-based события (нет exc_info) — фильтр не трогает, напр.
scrape_runs.py consecutive-failure алерты (content-ful, должны доходить)."""
event = {"level": "error", "message": "Scraper source 'avito' has 5 consecutive failed runs"}
out = stabilize_retry_error_fingerprint(dict(event), {})
assert out == event
def test_stabilize_retry_error_handles_non_dict_event() -> None:
assert stabilize_retry_error_fingerprint(None, {}) is None # type: ignore[arg-type]