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:
parent
e8fe9faa13
commit
8fcec9f12e
7 changed files with 299 additions and 11 deletions
|
|
@ -8,6 +8,13 @@ Persistent offset в /state/offset.json — не дублируем при resta
|
||||||
Throttle: при >10 401 events за 60s — однократный digest event
|
Throttle: при >10 401 events за 60s — однократный digest event
|
||||||
(чтобы не флудить GlitchTip storm'ом); индивидуальные events во время storm пропускаются.
|
(чтобы не флудить 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) структура:
|
Реальный Caddy JSON access log (v2) структура:
|
||||||
{
|
{
|
||||||
"level": "info",
|
"level": "info",
|
||||||
|
|
@ -73,6 +80,41 @@ _shutdown = False
|
||||||
_last_exc_sent: float = 0.0
|
_last_exc_sent: float = 0.0
|
||||||
_EXC_THROTTLE_S: float = 300.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:
|
def _signal_handler(signum: int, frame: object) -> None:
|
||||||
global _shutdown
|
global _shutdown
|
||||||
|
|
@ -221,6 +263,7 @@ def main() -> None:
|
||||||
traces_sample_rate=0.0,
|
traces_sample_rate=0.0,
|
||||||
attach_stacktrace=False,
|
attach_stacktrace=False,
|
||||||
send_default_pii=False,
|
send_default_pii=False,
|
||||||
|
before_send=_drop_basic_auth_noise,
|
||||||
# Отключаем интеграции которые не нужны тонкому sidecar
|
# Отключаем интеграции которые не нужны тонкому sidecar
|
||||||
default_integrations=False,
|
default_integrations=False,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
77
ops/glitchtip-auth-forwarder/test_forwarder.py
Normal file
77
ops/glitchtip-auth-forwarder/test_forwarder.py
Normal 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"})
|
||||||
|
|
@ -66,17 +66,28 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
# worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/
|
# worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/
|
||||||
# frontend), отдельного broker нет → мониторить нечего.
|
# frontend), отдельного broker нет → мониторить нечего.
|
||||||
if settings.glitchtip_dsn:
|
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:
|
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
|
||||||
"""Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) —
|
"""Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) +
|
||||||
см. app/tgbot_main.py._before_send (идентичная композиция, тот же риск:
|
RetryError fingerprint-стабилизация (glitchtip-noise) — см.
|
||||||
теперь этот процесс тоже держит TelegramClient в стек-фреймах при ошибке
|
app/tgbot_main.py._before_send (та же композиция без последнего шага,
|
||||||
sendMessage, а include_local_variables=False ниже — первый рубеж защиты)."""
|
тот бот 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]
|
scrubbed = scrub_pii_event(event, hint) # type: ignore[arg-type]
|
||||||
if scrubbed is None:
|
if scrubbed is None:
|
||||||
return 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(
|
sentry_sdk.init(
|
||||||
dsn=settings.glitchtip_dsn,
|
dsn=settings.glitchtip_dsn,
|
||||||
|
|
|
||||||
|
|
@ -174,3 +174,40 @@ def redact_telegram_bot_token(event: Event, _hint: dict[str, Any]) -> Event | No
|
||||||
if not isinstance(event, dict):
|
if not isinstance(event, dict):
|
||||||
return event
|
return event
|
||||||
return _redact_strings(event) # type: ignore[return-value]
|
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
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,24 @@ if settings.glitchtip_dsn:
|
||||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
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(
|
sentry_sdk.init(
|
||||||
dsn=settings.glitchtip_dsn,
|
dsn=settings.glitchtip_dsn,
|
||||||
|
|
@ -52,7 +69,7 @@ if settings.glitchtip_dsn:
|
||||||
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
|
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
|
||||||
traces_sample_rate=0.0,
|
traces_sample_rate=0.0,
|
||||||
send_default_pii=False,
|
send_default_pii=False,
|
||||||
before_send=scrub_pii_event,
|
before_send=_before_send,
|
||||||
integrations=[
|
integrations=[
|
||||||
SqlalchemyIntegration(),
|
SqlalchemyIntegration(),
|
||||||
HttpxIntegration(),
|
HttpxIntegration(),
|
||||||
|
|
|
||||||
|
|
@ -726,7 +726,16 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
|
||||||
return oblast_fallback
|
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:
|
async def _nominatim_lookup(address: str, city_hint: str | None = None) -> GeocodeResult | None:
|
||||||
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
|
"""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]
|
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(
|
async def _nominatim_suggest(
|
||||||
query: str, limit: int = 8, city_hint: str | None = None
|
query: str, limit: int = 8, city_hint: str | None = None
|
||||||
) -> list[GeocodeSuggestion]:
|
) -> list[GeocodeSuggestion]:
|
||||||
|
|
@ -1654,7 +1664,8 @@ def _format_reverse_address(addr: dict) -> str | None:
|
||||||
return ", ".join(parts)
|
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:
|
async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None:
|
||||||
"""Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon.
|
"""Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
||||||
from app.observability.sentry_scrub import (
|
from app.observability.sentry_scrub import (
|
||||||
redact_telegram_bot_token,
|
redact_telegram_bot_token,
|
||||||
scrub_pii_event,
|
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]"
|
assert out["request"]["data"]["client_phone"] == "[REDACTED]"
|
||||||
frame_url = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]["url"]
|
frame_url = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]["url"]
|
||||||
assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_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]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue