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.
77 lines
3.7 KiB
Python
77 lines
3.7 KiB
Python
"""Тесты для `_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"})
|