Merge remote-tracking branch 'forgejo/main' into feat/3471-telegram-relay-beget
This commit is contained in:
commit
9a93e575cc
14 changed files with 719 additions and 33 deletions
|
|
@ -90,8 +90,9 @@ jobs:
|
||||||
METRICS_TELEGRAM_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_TOPIC_ID }}
|
METRICS_TELEGRAM_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_TOPIC_ID }}
|
||||||
METRICS_TELEGRAM_INFRA_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_INFRA_TOPIC_ID }}
|
METRICS_TELEGRAM_INFRA_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_INFRA_TOPIC_ID }}
|
||||||
METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }}
|
METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }}
|
||||||
|
ALERT_ACK_GLITCHTIP_SECRET: ${{ secrets.ALERT_ACK_GLITCHTIP_SECRET }}
|
||||||
with:
|
with:
|
||||||
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL
|
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL,ALERT_ACK_GLITCHTIP_SECRET
|
||||||
host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
|
host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
|
||||||
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
|
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
|
||||||
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
|
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
|
||||||
|
|
@ -188,6 +189,14 @@ jobs:
|
||||||
echo "Инфраструктура: тема ${INFRA_TOPIC_ID} по умолчанию (METRICS_TELEGRAM_INFRA_TOPIC_ID не задана)."
|
echo "Инфраструктура: тема ${INFRA_TOPIC_ID} по умолчанию (METRICS_TELEGRAM_INFRA_TOPIC_ID не задана)."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Резервный приёмник GlitchTip (#3471) отвечает 503 на любой
|
||||||
|
# запрос, пока секрет пуст: тихо принимать чужие алерты настежь
|
||||||
|
# хуже, чем не принимать вовсе. Молчаливого отказа тут быть не
|
||||||
|
# должно — деплой обязан сказать, что канал не поднялся.
|
||||||
|
if [ -z "${ALERT_ACK_GLITCHTIP_SECRET:-}" ]; then
|
||||||
|
echo "::warning title=Резервный канал GlitchTip выключен::ALERT_ACK_GLITCHTIP_SECRET пуст — alert-ack отвечает 503 на /glitchtip, и при падении продуктового бэкенда его ошибки доставлять будет нечем."
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -n "${METRICS_TELEGRAM_ONCALL:-}" ]; then
|
if [ -n "${METRICS_TELEGRAM_ONCALL:-}" ]; then
|
||||||
echo "Клиентские инциденты: зовём ${METRICS_TELEGRAM_ONCALL} поимённо."
|
echo "Клиентские инциденты: зовём ${METRICS_TELEGRAM_ONCALL} поимённо."
|
||||||
else
|
else
|
||||||
|
|
@ -344,6 +353,51 @@ jobs:
|
||||||
done
|
done
|
||||||
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
|
docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps
|
||||||
|
|
||||||
|
# ── Prometheus: конфиг/правила лежат на диске, `up -d` их не
|
||||||
|
# перечитывает ────────────────────────────────────────────────
|
||||||
|
# Тот же класс бага, что у Caddyfile и alertmanager.yml выше:
|
||||||
|
# docker compose сравнивает описание сервиса, а НЕ содержимое
|
||||||
|
# бинд-маунта, поэтому уже работающий контейнер продолжает жить
|
||||||
|
# со старым конфигом сколько угодно — на проде дошло до 16 суток
|
||||||
|
# незамеченными (#3467): lastConfigTime совпадал со startTime
|
||||||
|
# контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**.
|
||||||
|
#
|
||||||
|
# У Prometheus, в отличие от Alertmanager (см. комментарий выше),
|
||||||
|
# /-/reload переоткрывает файлы ПО ПУТИ заново, поэтому новый инод
|
||||||
|
# после `git reset --hard` подхватывается без пересоздания
|
||||||
|
# контейнера. --web.enable-lifecycle уже включён в compose ради
|
||||||
|
# этого шага (см. docker-compose.metrics.yml) — просто раньше
|
||||||
|
# никто не звал сам reload.
|
||||||
|
#
|
||||||
|
# promtool проверяет ОБА файла ДО reload: битый конфиг не должен
|
||||||
|
# положить работающий Prometheus молчаливым откатом на дефолты.
|
||||||
|
if docker exec gendesign-prometheus promtool check config /etc/prometheus/prometheus.yml \
|
||||||
|
&& docker exec gendesign-prometheus sh -c 'promtool check rules /etc/prometheus/rules/*.yml'; then
|
||||||
|
LAST_CONFIG_BEFORE="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')"
|
||||||
|
|
||||||
|
docker exec gendesign-prometheus wget -q -O /dev/null --post-data='' http://localhost:9090/-/reload
|
||||||
|
|
||||||
|
# lastConfigTime обновляется на КАЖДЫЙ успешный reload, даже
|
||||||
|
# если содержимое конфига не поменялось — значит сравнение
|
||||||
|
# "было/стало" надёжно ловит и несостоявшийся reload, и
|
||||||
|
# изменившиеся правила.
|
||||||
|
LAST_CONFIG_AFTER=""
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
LAST_CONFIG_AFTER="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')"
|
||||||
|
[ -n "$LAST_CONFIG_AFTER" ] && [ "$LAST_CONFIG_AFTER" != "$LAST_CONFIG_BEFORE" ] && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$LAST_CONFIG_AFTER" ] || [ "$LAST_CONFIG_AFTER" = "$LAST_CONFIG_BEFORE" ]; then
|
||||||
|
echo "ОШИБКА: reload Prometheus не подтверждён — lastConfigTime не изменился ($LAST_CONFIG_BEFORE)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Prometheus: конфиг и правила проверены, reload подтверждён ($LAST_CONFIG_BEFORE -> $LAST_CONFIG_AFTER)."
|
||||||
|
else
|
||||||
|
echo "ОШИБКА: конфиг/правила Prometheus не проходят promtool — reload НЕ выполнен, работающий Prometheus остаётся на прежнем конфиге."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
|
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
|
||||||
agent-apps:
|
agent-apps:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
112
backend/tests/ops/test_3467_prometheus_reload.py
Normal file
112
backend/tests/ops/test_3467_prometheus_reload.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
"""Правки Prometheus-конфига/правил обязаны доезжать до работающего процесса.
|
||||||
|
|
||||||
|
ЧТО СЛУЧИЛОСЬ НА ПРОДЕ. `GET /api/v1/status/runtimeinfo` внутри
|
||||||
|
`gendesign-prometheus` 12.09 отдавал `lastConfigTime`, совпадающий со
|
||||||
|
`startTime` контейнера, — конфиг и правила не перечитывались 16 суток.
|
||||||
|
Деплой при этом был зелёный: `docker compose up -d` не пересоздаёт
|
||||||
|
контейнер из-за изменения содержимого бинд-маунта (он сравнивает только
|
||||||
|
описание сервиса), а `--web.enable-lifecycle` был включён в
|
||||||
|
docker-compose.metrics.yml, но эндпоинт `/-/reload` никто не вызывал.
|
||||||
|
|
||||||
|
Тот же класс бага, что уже пойман и починен для Caddy (`caddy reload`)
|
||||||
|
и для Alertmanager (`--force-recreate`, см. test_3xxx_alertmanager_inode.py)
|
||||||
|
в этом же workflow — только для Prometheus починка не пересоздание
|
||||||
|
контейнера, а именно `POST /-/reload`: он переоткрывает файлы конфига по
|
||||||
|
пути заново, так что новый инод после `git reset --hard` подхватывается
|
||||||
|
без даунтайма.
|
||||||
|
|
||||||
|
Проверяется здесь: (1) валидация promtool ЕСТЬ, (2) reload вызывается
|
||||||
|
ТОЛЬКО после успешной валидации, (3) шаг обязан упасть, если reload не
|
||||||
|
подтверждён сменой lastConfigTime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
|
||||||
|
|
||||||
|
|
||||||
|
def _text() -> str:
|
||||||
|
return WORKFLOW.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_promtool_checks_config_and_rules() -> None:
|
||||||
|
"""promtool обязан проверять и конфиг, и правила — не только один файл."""
|
||||||
|
text = _text()
|
||||||
|
assert "promtool check config /etc/prometheus/prometheus.yml" in text, (
|
||||||
|
"нет проверки конфига promtool'ом — битый prometheus.yml долетит до reload"
|
||||||
|
)
|
||||||
|
assert "promtool check rules" in text, (
|
||||||
|
"нет проверки правил promtool'ом — битое правило долетит до reload"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_endpoint_is_called() -> None:
|
||||||
|
"""Сам reload обязан вызываться — иначе валидация ничего не решает."""
|
||||||
|
text = _text()
|
||||||
|
assert "localhost:9090/-/reload" in text, (
|
||||||
|
"нет вызова POST /-/reload — конфиг/правила проверяются, но в силу не вступают "
|
||||||
|
"(#3467: lastConfigTime не менялся 16 суток при зелёном деплое)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_happens_after_validation_not_before() -> None:
|
||||||
|
"""Reload обязан идти ПОСЛЕ promtool, а не до/вместо него."""
|
||||||
|
text = _text()
|
||||||
|
check_pos = text.index("promtool check config /etc/prometheus/prometheus.yml")
|
||||||
|
reload_pos = text.index("localhost:9090/-/reload")
|
||||||
|
assert check_pos < reload_pos, (
|
||||||
|
"reload стоит раньше проверки конфига — битый конфиг мог бы применяться вслепую"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_is_guarded_by_the_promtool_check() -> None:
|
||||||
|
"""Reload обязан быть ВНУТРИ `if promtool ...; then`, а не безусловным."""
|
||||||
|
text = _text()
|
||||||
|
guard_start = text.index("if docker exec gendesign-prometheus promtool check config")
|
||||||
|
else_pos = text.index("else", guard_start)
|
||||||
|
reload_pos = text.index("localhost:9090/-/reload")
|
||||||
|
assert guard_start < reload_pos < else_pos, (
|
||||||
|
"вызов reload лежит вне ветки успешной проверки promtool — "
|
||||||
|
"битый конфиг всё равно приведёт к reload, либо reload вообще не защищён проверкой"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_validation_skips_reload_and_fails_the_step() -> None:
|
||||||
|
"""При провале promtool — reload НЕ вызывается, и шаг падает (exit 1)."""
|
||||||
|
text = _text()
|
||||||
|
guard_start = text.index("if docker exec gendesign-prometheus promtool check config")
|
||||||
|
else_pos = text.index("else", guard_start)
|
||||||
|
fi_pos = text.index("fi", else_pos)
|
||||||
|
else_branch = text[else_pos:fi_pos]
|
||||||
|
assert "localhost:9090/-/reload" not in else_branch, (
|
||||||
|
"reload вызывается даже в ветке провалившейся проверки"
|
||||||
|
)
|
||||||
|
assert "exit 1" in else_branch, (
|
||||||
|
"провал promtool не роняет шаг — деплой останется зелёным при битом конфиге"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_acceptance_checks_last_config_time_actually_changed() -> None:
|
||||||
|
"""Приёмка обязана сверять `lastConfigTime` до/после, а не доверять коду ответа reload.
|
||||||
|
|
||||||
|
`wget` на POST /-/reload может отрапортовать успех, даже если Prometheus
|
||||||
|
молча остался на старом конфиге (например, если бинарь внутри образа не
|
||||||
|
поддерживает --post-data так, как ожидалось) — единственное надёжное
|
||||||
|
подтверждение реального перечитывания конфига это смена таймстемпа.
|
||||||
|
"""
|
||||||
|
text = _text()
|
||||||
|
assert text.count("lastConfigTime") >= 2, (
|
||||||
|
"нет сравнения lastConfigTime до/после — reload не проверяется по факту"
|
||||||
|
)
|
||||||
|
assert "LAST_CONFIG_BEFORE" in text and "LAST_CONFIG_AFTER" in text, (
|
||||||
|
"нет явного до/после сравнения таймстемпа последней перезагрузки конфига"
|
||||||
|
)
|
||||||
|
verify_start = text.index("LAST_CONFIG_AFTER")
|
||||||
|
verify_block_end = text.index("Prometheus: конфиг и правила проверены", verify_start)
|
||||||
|
verify_block = text[verify_start:verify_block_end]
|
||||||
|
assert "exit 1" in verify_block, (
|
||||||
|
"если lastConfigTime не изменился, шаг обязан падать, а не считаться успешным"
|
||||||
|
)
|
||||||
|
|
@ -105,11 +105,24 @@ metrics.gendsgn.ru {
|
||||||
# угадавший, — ложная отметка «принято» в чате, где сразу видно, что её
|
# угадавший, — ложная отметка «принято» в чате, где сразу видно, что её
|
||||||
# поставил не человек. Прав в системе токен не даёт никаких.
|
# поставил не человек. Прав в системе токен не даёт никаких.
|
||||||
#
|
#
|
||||||
# Сервис отвечает только на /ack/* и /healthz; всё прочее — 404.
|
# Сервис отвечает только на /ack/*, /glitchtip и /healthz; всё прочее — 404.
|
||||||
handle /ack/* {
|
handle /ack/* {
|
||||||
reverse_proxy alert-ack:8080
|
reverse_proxy alert-ack:8080
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Резервный приёмник алертов GlitchTip (#3471). Основной получатель —
|
||||||
|
# продуктовый бэкенд на Selectel, то есть тот самый сервис, за которым эти
|
||||||
|
# алерты и следят: пока он лежит, его собственные ошибки доставлять некому.
|
||||||
|
# Этот путь живёт у другого провайдера и с чистой сетью до Telegram, поэтому
|
||||||
|
# переживает падение Selectel целиком.
|
||||||
|
#
|
||||||
|
# Секрет — в значении query-параметра, а не в пути: путь сам по себе не
|
||||||
|
# секрет, и его попадание в access-лог безопасно. Само значение вырезает
|
||||||
|
# scrub_credentials в Alloy до записи в Loki (#3154).
|
||||||
|
handle /glitchtip* {
|
||||||
|
reverse_proxy alert-ack:8080
|
||||||
|
}
|
||||||
|
|
||||||
# Вход ОДИН — собственный вход Grafana (#3078). Внешний basic_auth снят по
|
# Вход ОДИН — собственный вход Grafana (#3078). Внешний basic_auth снят по
|
||||||
# решению владельца: два запроса пароля подряд мешали работе, а Grafana имеет
|
# решению владельца: два запроса пароля подряд мешали работе, а Grafana имеет
|
||||||
# собственную аутентификацию с ролями и `GF_USERS_ALLOW_SIGN_UP=false`.
|
# собственную аутентификацию с ролями и `GF_USERS_ALLOW_SIGN_UP=false`.
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,12 @@ services:
|
||||||
# Внешний адрес попадает в кнопку. Пустой — сообщение уйдёт без кнопки,
|
# Внешний адрес попадает в кнопку. Пустой — сообщение уйдёт без кнопки,
|
||||||
# но уйдёт: алерт важнее подтверждения.
|
# но уйдёт: алерт важнее подтверждения.
|
||||||
ALERT_ACK_PUBLIC_URL: ${ALERT_ACK_PUBLIC_URL:-https://metrics.gendsgn.ru}
|
ALERT_ACK_PUBLIC_URL: ${ALERT_ACK_PUBLIC_URL:-https://metrics.gendsgn.ru}
|
||||||
|
# Резервный получатель GlitchTip-алертов (#3471, POST /glitchtip) — второй
|
||||||
|
# получатель наряду с основным вебхуком в продуктовый бэкенд на Selectel.
|
||||||
|
# Секрет СВОЙ, не общий с продуктовым TRADEIN_INTERNAL_AUTH_SECRET: разные
|
||||||
|
# хосты/домены безопасности. Пусто — эндпоинт отвечает 503, остальной
|
||||||
|
# функционал сервиса не затронут.
|
||||||
|
ALERT_ACK_GLITCHTIP_SECRET: ${ALERT_ACK_GLITCHTIP_SECRET:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./ops/metrics/alert-ack/app.py:/app/app.py:ro
|
- ./ops/metrics/alert-ack/app.py:/app/app.py:ro
|
||||||
expose:
|
expose:
|
||||||
|
|
@ -257,6 +263,22 @@ services:
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
# ── Grafana: витрина ─────────────────────────────────────────────────────────
|
# ── Grafana: витрина ─────────────────────────────────────────────────────────
|
||||||
|
# Grafana здесь ТОЛЬКО рисует — не решает, что считать инцидентом и куда его
|
||||||
|
# слать. Тревоги живут в Prometheus (правила) и Alertmanager (маршрутизация,
|
||||||
|
# Telegram); это единственный путь доставки (#3158).
|
||||||
|
#
|
||||||
|
# Встроенный Alerting выключен ЯВНО, а не просто «не настроен». Проверка на
|
||||||
|
# живом API 12.09.2026 нашла: 0 правил, единственный контакт-поинт —
|
||||||
|
# стоковый grafana-default-email на example@email.com, GF_SMTP_* не заданы.
|
||||||
|
# То есть кнопка «New alert rule» в интерфейсе есть и работает, а результат
|
||||||
|
# молча уходит в никуда — ровно та ситуация, из-за которой никто не проверяет
|
||||||
|
# второй, настоящий путь. Дублирующий движок на том же датасорсе Prometheus
|
||||||
|
# надёжности всё равно не прибавляет (общая точка отказа), только даёт второе
|
||||||
|
# место, где правило может быть заведено и забыто.
|
||||||
|
#
|
||||||
|
# Если это когда-нибудь понадобится включить обратно — сначала подключить
|
||||||
|
# реальный SMTP или другой contact point и завести хотя бы одно тестовое
|
||||||
|
# правило руками, иначе вернётся тот же капкан.
|
||||||
grafana:
|
grafana:
|
||||||
image: grafana/grafana:11.5.1
|
image: grafana/grafana:11.5.1
|
||||||
container_name: gendesign-grafana
|
container_name: gendesign-grafana
|
||||||
|
|
@ -276,6 +298,12 @@ services:
|
||||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-}
|
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-}
|
||||||
GF_SERVER_ROOT_URL: https://metrics.gendsgn.ru/
|
GF_SERVER_ROOT_URL: https://metrics.gendsgn.ru/
|
||||||
GF_SERVER_SERVE_FROM_SUB_PATH: "false"
|
GF_SERVER_SERVE_FROM_SUB_PATH: "false"
|
||||||
|
# Единственный официальный переключатель Grafana Alerting в 11.x — секция
|
||||||
|
# [unified_alerting], легаси-[alerting] удалён из Grafana ещё в 9.0 и в
|
||||||
|
# 11.5 в конфиге отсутствует (сверено с grafana.com/docs/grafana/v11.5/
|
||||||
|
# setup-grafana/configure-grafana/#unified_alerting). false здесь убирает
|
||||||
|
# раздел Alerting из UI и глушит движок правил целиком — см. #3158 выше.
|
||||||
|
GF_UNIFIED_ALERTING_ENABLED: "false"
|
||||||
# Телеметрия наружу — выключена. Отдельный хост, отдельный провайдер, и не
|
# Телеметрия наружу — выключена. Отдельный хост, отдельный провайдер, и не
|
||||||
# хочется, чтобы наблюдатель сам ходил в интернет без нужды.
|
# хочется, чтобы наблюдатель сам ходил в интернет без нужды.
|
||||||
GF_ANALYTICS_REPORTING_ENABLED: "false"
|
GF_ANALYTICS_REPORTING_ENABLED: "false"
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,28 @@
|
||||||
METRICS_TELEGRAM_ONCALL кого звать поимённо (необязательна)
|
METRICS_TELEGRAM_ONCALL кого звать поимённо (необязательна)
|
||||||
ALERT_ACK_PUBLIC_URL внешний адрес сервиса, попадает в кнопку
|
ALERT_ACK_PUBLIC_URL внешний адрес сервиса, попадает в кнопку
|
||||||
ALERT_ACK_TTL_MIN сколько минут живёт токен (по умолчанию 1440)
|
ALERT_ACK_TTL_MIN сколько минут живёт токен (по умолчанию 1440)
|
||||||
|
ALERT_ACK_GLITCHTIP_SECRET секрет резервного вебхука GlitchTip (#3471,
|
||||||
|
см. POST /glitchtip ниже); пусто — 503
|
||||||
|
|
||||||
|
РЕЗЕРВНЫЙ КАНАЛ GLITCHTIP (#3471). Все три alert-правила GlitchTip (backend,
|
||||||
|
frontend, Trade-In) шлют основной вебхук в продуктовый бэкенд на Selectel —
|
||||||
|
тот самый хост, за которым они следят. Если там упал сам бэкенд или Caddy,
|
||||||
|
алерт об этом теряется именно тогда, когда нужнее всего. `POST /glitchtip`
|
||||||
|
— второй получатель того же алерта, зарегистрированный в GlitchTip отдельной
|
||||||
|
строкой; живёт на ЭТОМ (инфраструктурном, Beget) хосте и не зависит от
|
||||||
|
здоровья продукта. Формат тела — тот же Slack-совместимый payload, что и у
|
||||||
|
продуктового приёмника (`tradein-mvp/backend/app/api/v1/glitchtip.py`):
|
||||||
|
``{"text": str, "attachments": [{"title","title_link","text","color",
|
||||||
|
"fields":[{"title","value"}]}]}``, GlitchTip заголовков не шлёт вовсе —
|
||||||
|
аутентификация только через секрет в query (``?secret=``) или в заголовке
|
||||||
|
``X-GlitchTip-Secret`` (тот же выбор, что там же и по той же причине: заголовок
|
||||||
|
не течёт в access-log, query остаётся, т.к. сам GlitchTip 6.1.6 заголовков не
|
||||||
|
добавляет). Секрет намеренно СВОЙ (``ALERT_ACK_GLITCHTIP_SECRET``), а не общий
|
||||||
|
с продуктовым ``TRADEIN_INTERNAL_AUTH_SECRET`` — секреты разных хостов/доменов
|
||||||
|
безопасности компрометировать вместе незачем. Сообщение уходит в ту же тему
|
||||||
|
клиентских инцидентов (``METRICS_TELEGRAM_CHAT_ID``/``_TOPIC_ID``), что и
|
||||||
|
Alertmanager-алерты через alert-ack, с явной пометкой «резервный канал», чтобы
|
||||||
|
не спутать с основным путём.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -51,6 +73,7 @@ TOPIC_ID = os.environ.get("METRICS_TELEGRAM_TOPIC_ID", "")
|
||||||
ONCALL = os.environ.get("METRICS_TELEGRAM_ONCALL", "")
|
ONCALL = os.environ.get("METRICS_TELEGRAM_ONCALL", "")
|
||||||
PUBLIC_URL = os.environ.get("ALERT_ACK_PUBLIC_URL", "").rstrip("/")
|
PUBLIC_URL = os.environ.get("ALERT_ACK_PUBLIC_URL", "").rstrip("/")
|
||||||
TTL_SEC = int(os.environ.get("ALERT_ACK_TTL_MIN", "1440")) * 60
|
TTL_SEC = int(os.environ.get("ALERT_ACK_TTL_MIN", "1440")) * 60
|
||||||
|
GLITCHTIP_SECRET = os.environ.get("ALERT_ACK_GLITCHTIP_SECRET", "")
|
||||||
API = "https://api.telegram.org/bot{}/{}"
|
API = "https://api.telegram.org/bot{}/{}"
|
||||||
|
|
||||||
# token -> {"message_id": int, "title": str, "created": float, "acked_by": str|None}
|
# token -> {"message_id": int, "title": str, "created": float, "acked_by": str|None}
|
||||||
|
|
@ -153,6 +176,79 @@ def _send_alert(payload: dict) -> None:
|
||||||
_tg("sendMessage", msg)
|
_tg("sendMessage", msg)
|
||||||
|
|
||||||
|
|
||||||
|
_GLITCHTIP_TEXT_LIMIT = 3500 # запас под баннер+имя проекта до лимита Telegram 4096
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_glitchtip_secret(provided: str) -> bool:
|
||||||
|
"""Constant-time сравнение — длина/префикс секрета не утекают через время
|
||||||
|
ответа (тот же приём, что у продуктового приёмника, см. docstring модуля)."""
|
||||||
|
return bool(GLITCHTIP_SECRET) and secrets.compare_digest(provided or "", GLITCHTIP_SECRET)
|
||||||
|
|
||||||
|
|
||||||
|
def _glitchtip_field(attachment: dict, label: str) -> str | None:
|
||||||
|
for field in attachment.get("fields") or []:
|
||||||
|
if not isinstance(field, dict):
|
||||||
|
continue
|
||||||
|
if str(field.get("title", "")).strip().lower() == label.lower():
|
||||||
|
value = field.get("value")
|
||||||
|
return str(value) if value is not None else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _render_glitchtip(payload: dict) -> str:
|
||||||
|
"""Собрать текст сообщения из Slack-совместимого payload GlitchTip.
|
||||||
|
|
||||||
|
Максимально терпимо к форме тела: GlitchTip шлёт ОДИНАКОВУЮ структуру для
|
||||||
|
issue- и uptime-алертов, но поля внутри attachments опциональны, а тестовое
|
||||||
|
сообщение из UI GlitchTip может не иметь attachments вовсе. Ничего в теле
|
||||||
|
не считаем обязательным — падать сервису на резервном канале нельзя.
|
||||||
|
"""
|
||||||
|
lines = ["⚠️ РЕЗЕРВНЫЙ КАНАЛ (GlitchTip → alert-ack)"]
|
||||||
|
lines.append("Основной путь через продуктовый бэкенд мог быть недоступен.")
|
||||||
|
lines.append("")
|
||||||
|
text = payload.get("text")
|
||||||
|
lines.append(html.escape(str(text)) if text else "GlitchTip alert")
|
||||||
|
|
||||||
|
attachments = payload.get("attachments")
|
||||||
|
for attachment in attachments if isinstance(attachments, list) else []:
|
||||||
|
if not isinstance(attachment, dict):
|
||||||
|
continue
|
||||||
|
block: list[str] = []
|
||||||
|
project = _glitchtip_field(attachment, "Project")
|
||||||
|
if project:
|
||||||
|
block.append(f"Проект: {html.escape(project)}")
|
||||||
|
if attachment.get("title"):
|
||||||
|
block.append(html.escape(str(attachment["title"])))
|
||||||
|
if attachment.get("text"):
|
||||||
|
block.append(html.escape(str(attachment["text"])))
|
||||||
|
if attachment.get("title_link"):
|
||||||
|
block.append(f"Ссылка: {html.escape(str(attachment['title_link']))}")
|
||||||
|
if block:
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(block)
|
||||||
|
|
||||||
|
out = "\n".join(lines)
|
||||||
|
if len(out) > _GLITCHTIP_TEXT_LIMIT:
|
||||||
|
out = out[:_GLITCHTIP_TEXT_LIMIT] + "\n… (обрезано)"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _send_glitchtip_alert(payload: dict) -> None:
|
||||||
|
"""Переслать вебхук GlitchTip в ту же тему клиентских инцидентов, что и
|
||||||
|
Alertmanager через этот сервис. Без кнопки подтверждения — это не
|
||||||
|
firing/resolved инцидент с состоянием, а разовое уведомление резервного
|
||||||
|
канала."""
|
||||||
|
msg = {
|
||||||
|
"chat_id": CHAT_ID,
|
||||||
|
"text": _render_glitchtip(payload),
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
"disable_web_page_preview": "true",
|
||||||
|
}
|
||||||
|
if TOPIC_ID:
|
||||||
|
msg["message_thread_id"] = TOPIC_ID
|
||||||
|
_tg("sendMessage", msg)
|
||||||
|
|
||||||
|
|
||||||
_PAGE = (
|
_PAGE = (
|
||||||
"<!doctype html><meta charset=utf-8>"
|
"<!doctype html><meta charset=utf-8>"
|
||||||
"<title>{t}</title>"
|
"<title>{t}</title>"
|
||||||
|
|
@ -243,7 +339,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._reply(code, page.encode())
|
self._reply(code, page.encode())
|
||||||
|
|
||||||
def do_POST(self) -> None: # noqa: N802 — имя из stdlib
|
def do_POST(self) -> None: # noqa: N802 — имя из stdlib
|
||||||
if self.path != "/alertmanager":
|
parsed = urllib.parse.urlsplit(self.path)
|
||||||
|
if parsed.path == "/glitchtip":
|
||||||
|
self._handle_glitchtip(parsed)
|
||||||
|
return
|
||||||
|
if parsed.path != "/alertmanager":
|
||||||
self._reply(404, b"not found", "text/plain; charset=utf-8")
|
self._reply(404, b"not found", "text/plain; charset=utf-8")
|
||||||
return
|
return
|
||||||
length = int(self.headers.get("Content-Length") or 0)
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
|
@ -261,6 +361,38 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._reply(200, b"accepted", "text/plain; charset=utf-8")
|
self._reply(200, b"accepted", "text/plain; charset=utf-8")
|
||||||
threading.Thread(target=_send_alert, args=(payload,), daemon=True).start()
|
threading.Thread(target=_send_alert, args=(payload,), daemon=True).start()
|
||||||
|
|
||||||
|
def _handle_glitchtip(self, parsed: urllib.parse.SplitResult) -> None:
|
||||||
|
"""POST /glitchtip — резервный получатель GlitchTip-алертов (#3471).
|
||||||
|
|
||||||
|
Секрет — из заголовка ``X-GlitchTip-Secret`` (предпочтительно, не течёт
|
||||||
|
в access-log) либо из query ``?secret=`` (fallback: GlitchTip 6.1.6
|
||||||
|
заголовков не шлёт вовсе). Несконфигурированный секрет → 503, а не
|
||||||
|
тихий приём без проверки. Неразобранное/нестандартное тело НЕ роняет
|
||||||
|
запрос — это резервный канал, теряться на кривом JSON ему нельзя.
|
||||||
|
"""
|
||||||
|
if not GLITCHTIP_SECRET:
|
||||||
|
self._reply(503, b"glitchtip webhook not configured", "text/plain; charset=utf-8")
|
||||||
|
return
|
||||||
|
|
||||||
|
header_secret = self.headers.get("X-GlitchTip-Secret", "")
|
||||||
|
query_secret = urllib.parse.parse_qs(parsed.query).get("secret", [""])[0]
|
||||||
|
if not _verify_glitchtip_secret(header_secret or query_secret):
|
||||||
|
log.warning("glitchtip webhook: invalid or missing secret")
|
||||||
|
self._reply(401, b"invalid or missing secret", "text/plain; charset=utf-8")
|
||||||
|
return
|
||||||
|
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
raw = self.rfile.read(length) if length else b"{}"
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw.decode() or "{}")
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {"text": raw.decode(errors="replace")}
|
||||||
|
except Exception: # noqa: BLE001 — резервный канал не роняем на кривом теле
|
||||||
|
payload = {"text": raw.decode(errors="replace")}
|
||||||
|
|
||||||
|
self._reply(200, b"accepted", "text/plain; charset=utf-8")
|
||||||
|
threading.Thread(target=_send_glitchtip_alert, args=(payload,), daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
missing = [n for n, v in (("BOT_TOKEN", BOT_TOKEN), ("CHAT_ID", CHAT_ID)) if not v]
|
missing = [n for n, v in (("BOT_TOKEN", BOT_TOKEN), ("CHAT_ID", CHAT_ID)) if not v]
|
||||||
|
|
@ -268,6 +400,11 @@ def main() -> None:
|
||||||
raise SystemExit(f"не заданы обязательные переменные: {', '.join(missing)}")
|
raise SystemExit(f"не заданы обязательные переменные: {', '.join(missing)}")
|
||||||
if not PUBLIC_URL:
|
if not PUBLIC_URL:
|
||||||
log.warning("ALERT_ACK_PUBLIC_URL пуст — сообщения уйдут БЕЗ кнопки подтверждения")
|
log.warning("ALERT_ACK_PUBLIC_URL пуст — сообщения уйдут БЕЗ кнопки подтверждения")
|
||||||
|
if not GLITCHTIP_SECRET:
|
||||||
|
log.warning(
|
||||||
|
"ALERT_ACK_GLITCHTIP_SECRET пуст — резервный канал GlitchTip (#3471) "
|
||||||
|
"отключён, POST /glitchtip будет отвечать 503"
|
||||||
|
)
|
||||||
port = int(os.environ.get("ALERT_ACK_PORT", "8080"))
|
port = int(os.environ.get("ALERT_ACK_PORT", "8080"))
|
||||||
log.info("alert-ack слушает :%d, тема=%s, дежурный=%s", port, TOPIC_ID or "—", ONCALL or "—")
|
log.info("alert-ack слушает :%d, тема=%s, дежурный=%s", port, TOPIC_ID or "—", ONCALL or "—")
|
||||||
ThreadingHTTPServer(("", port), Handler).serve_forever()
|
ThreadingHTTPServer(("", port), Handler).serve_forever()
|
||||||
|
|
|
||||||
117
ops/metrics/alert-ack/test_app.py
Normal file
117
ops/metrics/alert-ack/test_app.py
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
"""Тесты для резервного канала GlitchTip → alert-ack (#3471).
|
||||||
|
|
||||||
|
Продукт (tradein-backend на Selectel) — САМ объект наблюдения GlitchTip. Если
|
||||||
|
он лежит, основной вебхук-получатель лежит вместе с ним, и алерт об этом не
|
||||||
|
доходит именно тогда, когда нужнее всего. `POST /glitchtip` — второй
|
||||||
|
получатель на ДРУГОМ хосте (Beget, рядом с этим сервисом), не зависящий от
|
||||||
|
здоровья продукта.
|
||||||
|
|
||||||
|
Проверяем на уровне функций, а не полного HTTP-транспорта (тот же приём, что
|
||||||
|
`ops/glitchtip-auth-forwarder/test_forwarder.py`): `BaseHTTPRequestHandler`
|
||||||
|
неудобно поднимать без реального сокета, а бизнес-логика — секрет, рендер,
|
||||||
|
отправка — целиком вынесена в чистые функции модуля.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
# Env — ДО импорта app.py: BOT_TOKEN/CHAT_ID читаются на уровне модуля.
|
||||||
|
os.environ.setdefault("METRICS_TELEGRAM_BOT_TOKEN", "test-bot-token")
|
||||||
|
os.environ.setdefault("METRICS_TELEGRAM_CHAT_ID", "-1001234567890")
|
||||||
|
os.environ.setdefault("METRICS_TELEGRAM_TOPIC_ID", "158")
|
||||||
|
os.environ["ALERT_ACK_GLITCHTIP_SECRET"] = "correct-secret"
|
||||||
|
|
||||||
|
import app as alert_ack # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_glitchtip_secret_accepts_correct_value() -> None:
|
||||||
|
assert alert_ack._verify_glitchtip_secret("correct-secret") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_glitchtip_secret_rejects_wrong_value() -> None:
|
||||||
|
"""Неверный секрет — отказ."""
|
||||||
|
assert alert_ack._verify_glitchtip_secret("wrong-secret") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_glitchtip_secret_rejects_empty_value() -> None:
|
||||||
|
assert alert_ack._verify_glitchtip_secret("") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_glitchtip_secret_fails_closed_when_unconfigured(monkeypatch) -> None:
|
||||||
|
"""Пустой ALERT_ACK_GLITCHTIP_SECRET — отказ всем, а не тихий fail-open."""
|
||||||
|
monkeypatch.setattr(alert_ack, "GLITCHTIP_SECRET", "")
|
||||||
|
assert alert_ack._verify_glitchtip_secret("correct-secret") is False
|
||||||
|
assert alert_ack._verify_glitchtip_secret("") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_glitchtip_full_payload_marks_fallback_channel() -> None:
|
||||||
|
payload = {
|
||||||
|
"text": "GlitchTip Alert: Something broke",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"title": "TypeError: boom",
|
||||||
|
"title_link": "https://errors.gendsgn.ru/issue/1",
|
||||||
|
"text": "подробности ошибки",
|
||||||
|
"color": "#ff0000",
|
||||||
|
"fields": [{"title": "Project", "value": "tradein-backend"}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
text = alert_ack._render_glitchtip(payload)
|
||||||
|
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
|
||||||
|
assert "Проект: tradein-backend" in text
|
||||||
|
assert "TypeError: boom" in text
|
||||||
|
assert "https://errors.gendsgn.ru/issue/1" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_glitchtip_survives_payload_without_attachments() -> None:
|
||||||
|
"""Тело без attachments не роняет сервис — только текст."""
|
||||||
|
text = alert_ack._render_glitchtip({"text": "просто текст без вложений"})
|
||||||
|
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
|
||||||
|
assert "просто текст без вложений" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_glitchtip_survives_empty_payload() -> None:
|
||||||
|
"""Пустой словарь (нет ни text, ни attachments) — тоже не должен падать."""
|
||||||
|
text = alert_ack._render_glitchtip({})
|
||||||
|
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
|
||||||
|
assert "GlitchTip alert" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_glitchtip_survives_malformed_attachments() -> None:
|
||||||
|
"""attachments/fields неожиданной формы (не список, не словарь, битые
|
||||||
|
типы) — резервный канал не должен падать на кривом теле."""
|
||||||
|
payload = {
|
||||||
|
"text": "test",
|
||||||
|
"attachments": [
|
||||||
|
"not-a-dict",
|
||||||
|
{"fields": "not-a-list"},
|
||||||
|
{"fields": [{"title": "Project"}]}, # value отсутствует
|
||||||
|
None,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
text = alert_ack._render_glitchtip(payload)
|
||||||
|
assert "РЕЗЕРВНЫЙ КАНАЛ" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_glitchtip_alert_forwards_via_tg(monkeypatch) -> None:
|
||||||
|
"""Верный секрет уже проверен вызывающей стороной (do_POST) — здесь
|
||||||
|
проверяем, что отрендеренное сообщение реально уходит в тот же chat/topic,
|
||||||
|
что и Alertmanager-алерты этого сервиса, без кнопки подтверждения."""
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(alert_ack, "_tg", lambda method, payload: calls.append((method, payload)))
|
||||||
|
|
||||||
|
alert_ack._send_glitchtip_alert({"text": "boom", "attachments": []})
|
||||||
|
|
||||||
|
assert len(calls) == 1
|
||||||
|
method, sent = calls[0]
|
||||||
|
assert method == "sendMessage"
|
||||||
|
assert sent["chat_id"] == alert_ack.CHAT_ID
|
||||||
|
assert sent["message_thread_id"] == alert_ack.TOPIC_ID
|
||||||
|
assert "reply_markup" not in sent
|
||||||
|
assert "boom" in sent["text"]
|
||||||
16
ops/metrics/grafana/provisioning/alerting/README.md
Normal file
16
ops/metrics/grafana/provisioning/alerting/README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Эта папка сознательно пустая
|
||||||
|
|
||||||
|
Grafana умеет провижинить contact points, notification policies и alert rules
|
||||||
|
файлами отсюда (`/etc/grafana/provisioning/alerting`). Не клади их сюда.
|
||||||
|
|
||||||
|
Решение (#3158, 12.09.2026): единственный путь доставки тревог — Prometheus
|
||||||
|
(правила) + Alertmanager (маршрутизация, Telegram). Grafana только рисует.
|
||||||
|
Встроенный Alerting выключен явно (`GF_UNIFIED_ALERTING_ENABLED: "false"` в
|
||||||
|
`docker-compose.metrics.yml`, секция `grafana`) — при живом API 12.09.2026
|
||||||
|
единственным контакт-поинтом был стоковый `grafana-default-email` на
|
||||||
|
`example@email.com`, `GF_SMTP_*` не задан, правил ноль. Файл сюда работать не
|
||||||
|
заставит: движок alerting выключен на уровне сервиса, провижининг в эту папку
|
||||||
|
Grafana просто не читает.
|
||||||
|
|
||||||
|
Если понадобится включить обратно — сначала пересмотреть само решение в
|
||||||
|
`docker-compose.metrics.yml`, а не просто добавить файл в эту папку.
|
||||||
|
|
@ -19,6 +19,7 @@ from sentry_sdk.integrations.httpx import HttpxIntegration
|
||||||
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 sentry_sdk.integrations.starlette import StarletteIntegration
|
from sentry_sdk.integrations.starlette import StarletteIntegration
|
||||||
|
from sentry_sdk.types import Event, Hint
|
||||||
|
|
||||||
from app.api.public import mera as public_mera
|
from app.api.public import mera as public_mera
|
||||||
from app.api.v1 import (
|
from app.api.v1 import (
|
||||||
|
|
@ -79,31 +80,43 @@ install_query_secret_filter()
|
||||||
# frontend), отдельного broker нет → мониторить нечего.
|
# frontend), отдельного broker нет → мониторить нечего.
|
||||||
if settings.glitchtip_dsn:
|
if settings.glitchtip_dsn:
|
||||||
from app.observability.sentry_scrub import (
|
from app.observability.sentry_scrub import (
|
||||||
|
drop_payments_disabled_event,
|
||||||
redact_telegram_bot_token,
|
redact_telegram_bot_token,
|
||||||
scrub_payment_request_body,
|
scrub_payment_request_body,
|
||||||
scrub_public_address,
|
scrub_public_address,
|
||||||
stabilize_retry_error_fingerprint,
|
stabilize_retry_error_fingerprint,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
|
def _before_send(event: Event, hint: Hint) -> Event | None:
|
||||||
"""Композиция платёжный body-wipe + PII-scrub + Telegram bot-токен redaction +
|
"""Композиция payments-disabled drop + платёжный body-wipe + PII-scrub +
|
||||||
RetryError fingerprint-стабилизация (#tgsupport-web, PR-D2, glitchtip-noise) —
|
Telegram bot-токен redaction + RetryError fingerprint-стабилизация
|
||||||
см. app/tgbot_main.py._before_send (идентичная композиция без последнего шага,
|
(#tgsupport-web, PR-D2, glitchtip-noise, #3471) — см.
|
||||||
тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже держит
|
app/tgbot_main.py._before_send (идентичная композиция без последнего
|
||||||
TelegramClient в стек-фреймах при ошибке sendMessage, а
|
шага, тот бот geocoder не зовёт). Тот же риск: теперь этот процесс тоже
|
||||||
|
держит TelegramClient в стек-фреймах при ошибке sendMessage, а
|
||||||
include_local_variables=False ниже — первый рубеж защиты.
|
include_local_variables=False ниже — первый рубеж защиты.
|
||||||
|
|
||||||
PR-D2: платёжный body-wipe идёт ПЕРВЫМ шагом, а не заменяет остальные —
|
#3471: payments-disabled drop идёт ПЕРВЫМ шагом — это единственный
|
||||||
режет `request.data` целиком только для `/payments/*`, остальные пути
|
процесс из трёх entrypoint'ов, который реально держит ASGI-роут
|
||||||
(extra/contexts/traceback) по-прежнему проходят ключ-based scrub и
|
`/api/v1/payments/*`, поэтому именно здесь события возникают; ранний
|
||||||
token-redaction. Тот же обработчик передан ОБОИМ каналам ниже
|
return None экономит остальную композицию на заведомо отбрасываемом
|
||||||
(before_send и before_send_transaction) — вчерашний баг в Птице закрыл
|
событии.
|
||||||
только error-канал, transaction-канал остался вообще без обработчика.
|
|
||||||
|
PR-D2: платёжный body-wipe идёт следующим шагом, а не заменяет
|
||||||
|
остальные — режет `request.data` целиком только для `/payments/*`,
|
||||||
|
остальные пути (extra/contexts/traceback) по-прежнему проходят
|
||||||
|
ключ-based scrub и token-redaction. Тот же обработчик передан ОБОИМ
|
||||||
|
каналам ниже (before_send и before_send_transaction) — вчерашний баг в
|
||||||
|
Птице закрыл только error-канал, transaction-канал остался вообще без
|
||||||
|
обработчика.
|
||||||
|
|
||||||
RetryError-стабилизация — этот процесс обслуживает /api/v1/geocode/*
|
RetryError-стабилизация — этот процесс обслуживает /api/v1/geocode/*
|
||||||
(suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см.
|
(suggest/lookup/reverse), которые ретраят Nominatim через tenacity; см.
|
||||||
sentry_scrub.stabilize_retry_error_fingerprint."""
|
sentry_scrub.stabilize_retry_error_fingerprint."""
|
||||||
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
|
dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type]
|
||||||
|
if dropped is None:
|
||||||
|
return None
|
||||||
|
scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type]
|
||||||
if scrubbed is None:
|
if scrubbed is None:
|
||||||
return None
|
return None
|
||||||
# Публичный периметр МЕРЫ: тело запроса — это ровно введённый адрес, а
|
# Публичный периметр МЕРЫ: тело запроса — это ровно введённый адрес, а
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sentry_sdk.types import Event
|
from sentry_sdk.types import Event
|
||||||
|
from starlette.exceptions import HTTPException as _StarletteHTTPException
|
||||||
from tenacity import RetryError
|
from tenacity import RetryError
|
||||||
|
|
||||||
_REDACTED = "[REDACTED]"
|
_REDACTED = "[REDACTED]"
|
||||||
|
|
@ -256,6 +257,41 @@ def scrub_payment_request_body(event: Event, _hint: dict[str, Any]) -> Event | N
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
_PAYMENTS_DISABLED_DETAIL = "payments are disabled"
|
||||||
|
|
||||||
|
|
||||||
|
def drop_payments_disabled_event(event: Event, hint: dict[str, Any]) -> Event | None:
|
||||||
|
"""before_send-хук: роняет 503 "payments are disabled" из
|
||||||
|
`payments.py._require_enabled` (issue #3471, GlitchTip-группа TRADE-IN-3GG).
|
||||||
|
|
||||||
|
Источник — внутренний IP смоук-проверки: кнопки оплаты во фронте нет,
|
||||||
|
клиентского трафика на эти пути нет вообще, а выключенный платёжный контур
|
||||||
|
(`settings.payments_enabled=False`) штатно отвечает 503 на каждый такой
|
||||||
|
запрос — 167 событий за 29.08-12.09 размывали ленту, на этом фоне терялась
|
||||||
|
настоящая ошибка. Само поведение ручки НЕ меняется (503 остаётся) —
|
||||||
|
фильтруется только репортинг в трекер: sentry_sdk `StarletteIntegration`
|
||||||
|
репортит любой `HTTPException` с кодом из `failed_request_status_codes`
|
||||||
|
(по умолчанию весь диапазон 5xx) как error-событие, даже когда исключение
|
||||||
|
штатно обработано FastAPI и превращено в корректный HTTP-ответ.
|
||||||
|
|
||||||
|
Матчим `isinstance` реального объекта исключения из `hint["exc_info"]` (тот
|
||||||
|
же контракт, что `stabilize_retry_error_fingerprint` ниже) + точный текст
|
||||||
|
`detail` — НЕ код 503 сам по себе, чтобы не проглотить другие 503 (напр.
|
||||||
|
будущий maintenance-режим другого роутера).
|
||||||
|
"""
|
||||||
|
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 (
|
||||||
|
isinstance(exc_value, _StarletteHTTPException)
|
||||||
|
and exc_value.status_code == 503
|
||||||
|
and exc_value.detail == _PAYMENTS_DISABLED_DETAIL
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
_PUBLIC_API_URL_SEGMENT = "/api/public/"
|
_PUBLIC_API_URL_SEGMENT = "/api/public/"
|
||||||
|
|
||||||
#: Хосты геокодеров: их URL несёт введённый адрес прямо в query.
|
#: Хосты геокодеров: их URL несёт введённый адрес прямо в query.
|
||||||
|
|
|
||||||
|
|
@ -43,14 +43,16 @@ if settings.glitchtip_dsn:
|
||||||
from sentry_sdk.integrations.httpx import HttpxIntegration
|
from sentry_sdk.integrations.httpx import HttpxIntegration
|
||||||
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 sentry_sdk.types import Event, Hint
|
||||||
|
|
||||||
from app.observability.sentry_scrub import (
|
from app.observability.sentry_scrub import (
|
||||||
|
drop_payments_disabled_event,
|
||||||
scrub_payment_request_body,
|
scrub_payment_request_body,
|
||||||
scrub_pii_event,
|
scrub_pii_event,
|
||||||
stabilize_retry_error_fingerprint,
|
stabilize_retry_error_fingerprint,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _before_send(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg]
|
def _before_send(event: Event, hint: Hint) -> Event | None:
|
||||||
"""PR-D2: этот процесс не держит ASGI-приложения (нет `request` в event
|
"""PR-D2: этот процесс не держит ASGI-приложения (нет `request` в event
|
||||||
сегодня), но payments_confirm/payments_reconcile (PR-E, тот же
|
сегодня), но payments_confirm/payments_reconcile (PR-E, тот же
|
||||||
`tradein-scraper` контейнер) будут звать Т-Банк API отсюда — belt-and-
|
`tradein-scraper` контейнер) будут звать Т-Банк API отсюда — belt-and-
|
||||||
|
|
@ -58,6 +60,13 @@ if settings.glitchtip_dsn:
|
||||||
`request`/`extra`. Тот же обработчик на оба канала ниже — см.
|
`request`/`extra`. Тот же обработчик на оба канала ниже — см.
|
||||||
app/main.py._before_send (идентичный мотив, не дублировать без причины).
|
app/main.py._before_send (идентичный мотив, не дублировать без причины).
|
||||||
|
|
||||||
|
#3471: payments-disabled drop — тот же belt-and-suspenders мотив, что и
|
||||||
|
payment body-wipe выше по докстрингу: этот процесс сегодня не отвечает
|
||||||
|
503 из `_require_enabled` (нет ASGI/роутов), реальный источник шума —
|
||||||
|
`app/main.py`, но фильтр держим одинаковым во всех трёх entrypoint'ах,
|
||||||
|
чтобы поведение не разошлось, если payments-код когда-нибудь переедет
|
||||||
|
сюда же.
|
||||||
|
|
||||||
PII-scrub + RetryError fingerprint-стабилизация (glitchtip-noise) идут
|
PII-scrub + RetryError fingerprint-стабилизация (glitchtip-noise) идут
|
||||||
следом за платёжным body-wipe: этот процесс гоняет
|
следом за платёжным body-wipe: этот процесс гоняет
|
||||||
`geocode_missing_listings` (ночной batch, сотни адресов за прогон) —
|
`geocode_missing_listings` (ночной batch, сотни адресов за прогон) —
|
||||||
|
|
@ -66,13 +75,16 @@ if settings.glitchtip_dsn:
|
||||||
на КАЖДЫЙ адрес (RetryError.__str__() тащит нестабильный repr() Future).
|
на КАЖДЫЙ адрес (RetryError.__str__() тащит нестабильный repr() Future).
|
||||||
См. sentry_scrub docstring.
|
См. sentry_scrub docstring.
|
||||||
"""
|
"""
|
||||||
scrubbed = scrub_payment_request_body(event, hint) # type: ignore[arg-type]
|
dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type]
|
||||||
|
if dropped is None:
|
||||||
|
return None
|
||||||
|
scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type]
|
||||||
if scrubbed is None:
|
if scrubbed is None:
|
||||||
return None
|
return None
|
||||||
scrubbed = scrub_pii_event(scrubbed, hint)
|
scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type]
|
||||||
if scrubbed is None:
|
if scrubbed is None:
|
||||||
return None
|
return None
|
||||||
return stabilize_retry_error_fingerprint(scrubbed, hint)
|
return stabilize_retry_error_fingerprint(scrubbed, hint) # type: ignore[arg-type,return-value]
|
||||||
|
|
||||||
sentry_sdk.init(
|
sentry_sdk.init(
|
||||||
dsn=settings.glitchtip_dsn,
|
dsn=settings.glitchtip_dsn,
|
||||||
|
|
|
||||||
|
|
@ -1309,6 +1309,8 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No
|
||||||
транзиентный сбой узла ≠ перманентный бан, используется пока только для логов):
|
транзиентный сбой узла ≠ перманентный бан, используется пока только для логов):
|
||||||
- "timeout" — сеть недоступна/медленная (httpx.TimeoutException)
|
- "timeout" — сеть недоступна/медленная (httpx.TimeoutException)
|
||||||
- "connect_error" — прокси не поднят/не слушает/DNS (httpx.ConnectError)
|
- "connect_error" — прокси не поднят/не слушает/DNS (httpx.ConnectError)
|
||||||
|
- "proxy_error" — сам прокси отверг соединение (httpx.ProxyError, напр. 407 от
|
||||||
|
провайдера — это состояние пула, а не инцидент; #3471)
|
||||||
- "http_error" — ipify ответил ошибкой через прокси (auth/upstream)
|
- "http_error" — ipify ответил ошибкой через прокси (auth/upstream)
|
||||||
- "other" — прочее
|
- "other" — прочее
|
||||||
|
|
||||||
|
|
@ -1328,6 +1330,14 @@ async def _probe_proxy(url: str) -> tuple[bool, str | None, int | None, str | No
|
||||||
except httpx.ConnectError:
|
except httpx.ConnectError:
|
||||||
logger.warning("proxy_pool: health probe connect_error proxy=%s", _mask(url))
|
logger.warning("proxy_pool: health probe connect_error proxy=%s", _mask(url))
|
||||||
return False, None, None, "connect_error"
|
return False, None, None, "connect_error"
|
||||||
|
except httpx.ProxyError as exc:
|
||||||
|
# #3471: сам прокси-провайдер отверг соединение (чаще всего 407 —
|
||||||
|
# исчерпан лимит/просрочен пакет) — штатный исход health-пробы, не
|
||||||
|
# инцидент приложения. Одна строка без трейса: узел + причина текстом
|
||||||
|
# исключения, полный traceback здесь не несёт новой информации и только
|
||||||
|
# засорял логи (184 строки/сутки, #3471).
|
||||||
|
logger.warning("proxy_pool: health probe proxy_error proxy=%s reason=%s", _mask(url), exc)
|
||||||
|
return False, None, None, "proxy_error"
|
||||||
except httpx.HTTPStatusError as exc:
|
except httpx.HTTPStatusError as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"proxy_pool: health probe http_error proxy=%s status=%s",
|
"proxy_pool: health probe http_error proxy=%s status=%s",
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ import logging
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
|
|
@ -57,18 +56,20 @@ if settings.glitchtip_dsn:
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from sentry_sdk.integrations.httpx import HttpxIntegration
|
from sentry_sdk.integrations.httpx import HttpxIntegration
|
||||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||||
|
from sentry_sdk.types import Event, Hint
|
||||||
|
|
||||||
from app.observability.sentry_scrub import (
|
from app.observability.sentry_scrub import (
|
||||||
|
drop_payments_disabled_event,
|
||||||
redact_telegram_bot_token,
|
redact_telegram_bot_token,
|
||||||
scrub_payment_request_body,
|
scrub_payment_request_body,
|
||||||
scrub_pii_event,
|
scrub_pii_event,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _before_send(event: Any, hint: dict[str, Any]) -> Any:
|
def _before_send(event: Event, hint: Hint) -> Event | None:
|
||||||
"""Композиция платёжный body-wipe (PR-D2) + PII-scrub (form-данные) +
|
"""Композиция payments-disabled drop (#3471) + платёжный body-wipe (PR-D2) +
|
||||||
Telegram bot-токен redaction (#tgsupport review). Токен утекает ДВУМЯ
|
PII-scrub (form-данные) + Telegram bot-токен redaction (#tgsupport review).
|
||||||
независимыми векторами, которые `include_local_variables=False` ниже и
|
Токен утекает ДВУМЯ независимыми векторами, которые
|
||||||
этот хук закрывают вместе:
|
`include_local_variables=False` ниже и этот хук закрывают вместе:
|
||||||
1. `include_local_variables=True` (sentry_sdk default) кладёт stack-frame
|
1. `include_local_variables=True` (sentry_sdk default) кладёт stack-frame
|
||||||
locals (`self._base`/`url` в `TelegramClient._request`) в traceback —
|
locals (`self._base`/`url` в `TelegramClient._request`) в traceback —
|
||||||
закрыто через `include_local_variables=False` в `sentry_sdk.init`.
|
закрыто через `include_local_variables=False` в `sentry_sdk.init`.
|
||||||
|
|
@ -78,18 +79,22 @@ if settings.glitchtip_dsn:
|
||||||
— belt-and-suspenders на случай #1 (если include_local_variables
|
— belt-and-suspenders на случай #1 (если include_local_variables
|
||||||
случайно вернут) И на span data.
|
случайно вернут) И на span data.
|
||||||
|
|
||||||
Платёжный body-wipe — belt-and-suspenders: этот процесс не держит ASGI-
|
Payments-disabled drop и платёжный body-wipe — belt-and-suspenders: этот
|
||||||
приложения (нет `request` в event сегодня), но тот же обработчик передан
|
процесс не держит ASGI-приложения (нет `request`/HTTPException в event
|
||||||
ОБОИМ каналам ниже (before_send/before_send_transaction) ради единообразия
|
сегодня, реальный источник 503 — app/main.py), но тот же обработчик
|
||||||
со всеми точками инициализации sentry_sdk в проекте (см. app/main.py).
|
передан ОБОИМ каналам ниже (before_send/before_send_transaction) ради
|
||||||
|
единообразия со всеми точками инициализации sentry_sdk в проекте.
|
||||||
"""
|
"""
|
||||||
scrubbed = scrub_payment_request_body(event, hint)
|
dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type]
|
||||||
|
if dropped is None:
|
||||||
|
return None
|
||||||
|
scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type]
|
||||||
if scrubbed is None:
|
if scrubbed is None:
|
||||||
return None
|
return None
|
||||||
scrubbed = scrub_pii_event(scrubbed, hint)
|
scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type]
|
||||||
if scrubbed is None:
|
if scrubbed is None:
|
||||||
return None
|
return None
|
||||||
return redact_telegram_bot_token(scrubbed, hint)
|
return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value]
|
||||||
|
|
||||||
sentry_sdk.init(
|
sentry_sdk.init(
|
||||||
dsn=settings.glitchtip_dsn,
|
dsn=settings.glitchtip_dsn,
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services import proxy_pool
|
from app.services import proxy_pool
|
||||||
|
|
@ -1527,3 +1528,74 @@ def test_clear_source_bans_resets_escalation() -> None:
|
||||||
assert ban["ban_count"] == 1
|
assert ban["ban_count"] == 1
|
||||||
expected = datetime.now(UTC) + timedelta(hours=SOURCE_BAN_BASE_HOURS)
|
expected = datetime.now(UTC) + timedelta(hours=SOURCE_BAN_BASE_HOURS)
|
||||||
assert abs((ban["banned_until"] - expected).total_seconds()) < 60
|
assert abs((ban["banned_until"] - expected).total_seconds()) < 60
|
||||||
|
|
||||||
|
|
||||||
|
# ── health-probe failure logging (#3471 — GlitchTip/log noise) ──────────────
|
||||||
|
# httpx.ProxyError (типично 407 от провайдера) раньше падал в generic
|
||||||
|
# `except Exception: ... exc_info=True` внутри `_probe_proxy` — полный traceback
|
||||||
|
# на КАЖДЫЙ провал, хотя это штатное состояние пула (184 строки/сутки на
|
||||||
|
# проде), а не инцидент приложения. Тесты ниже бьют по РЕАЛЬНОМУ `_probe_proxy`
|
||||||
|
# (не монки-заглушке, как в тестах `run_proxy_healthcheck` выше) — только так
|
||||||
|
# видно, что осталось от логирования при живом httpx-исключении.
|
||||||
|
|
||||||
|
|
||||||
|
async def test_probe_proxy_proxy_error_logs_single_line_without_traceback(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
async def _broken_get(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> httpx.Response:
|
||||||
|
raise httpx.ProxyError("407 Proxy Authentication Required")
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "get", _broken_get)
|
||||||
|
|
||||||
|
with caplog.at_level("WARNING", logger="app.services.proxy_pool"):
|
||||||
|
ok, exit_ip, latency_ms, fail_kind = await proxy_pool._probe_proxy(
|
||||||
|
"http://u:p@h1:8080"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ok is False
|
||||||
|
assert exit_ip is None
|
||||||
|
assert latency_ms is None
|
||||||
|
assert fail_kind == "proxy_error"
|
||||||
|
|
||||||
|
records = [r for r in caplog.records if r.name == "app.services.proxy_pool"]
|
||||||
|
assert len(records) == 1, "провал ipify-пробы обязан лечь ОДНОЙ строкой, не пачкой"
|
||||||
|
record = records[0]
|
||||||
|
assert record.exc_info is None, "проба — штатная операция, полный traceback не нужен"
|
||||||
|
assert "proxy_error" in record.message
|
||||||
|
assert "407" in record.message # причина (текст исключения) видна без трейса
|
||||||
|
|
||||||
|
|
||||||
|
async def test_healthcheck_counts_proxy_error_as_failed_without_traceback(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Итоговая строка `checked=.../ok=.../failed=...` не ломается провалом
|
||||||
|
вида ProxyError, а сам провал не тащит traceback в лог прогона."""
|
||||||
|
db = FakeSession([_proxy(1, fails=0)])
|
||||||
|
|
||||||
|
async def _broken_get(self: httpx.AsyncClient, *args: Any, **kwargs: Any) -> httpx.Response:
|
||||||
|
raise httpx.ProxyError("407 Proxy Authentication Required")
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx.AsyncClient, "get", _broken_get)
|
||||||
|
|
||||||
|
# INFO (не WARNING): итоговая сводка `healthcheck done` логируется на INFO —
|
||||||
|
# порог ниже WARNING нужен, чтобы её тоже поймать в этом же прогоне.
|
||||||
|
with caplog.at_level("INFO", logger="app.services.proxy_pool"):
|
||||||
|
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert counters["checked"] == 1
|
||||||
|
assert counters["ok"] == 0
|
||||||
|
assert counters["failed"] == 1
|
||||||
|
assert db._by_id(1)["consecutive_fails"] == 1
|
||||||
|
|
||||||
|
summary = [
|
||||||
|
r
|
||||||
|
for r in caplog.records
|
||||||
|
if r.name == "app.services.proxy_pool" and "healthcheck done" in r.message
|
||||||
|
]
|
||||||
|
assert len(summary) == 1
|
||||||
|
assert "checked=1" in summary[0].message
|
||||||
|
assert "ok=0" in summary[0].message
|
||||||
|
assert "failed=1" in summary[0].message
|
||||||
|
assert not any(r.exc_info for r in caplog.records if r.name == "app.services.proxy_pool")
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import pytest
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
from app.observability.sentry_scrub import (
|
from app.observability.sentry_scrub import (
|
||||||
|
drop_payments_disabled_event,
|
||||||
redact_telegram_bot_token,
|
redact_telegram_bot_token,
|
||||||
scrub_payment_request_body,
|
scrub_payment_request_body,
|
||||||
scrub_pii_event,
|
scrub_pii_event,
|
||||||
|
|
@ -627,3 +628,63 @@ def test_scrub_pii_event_httpx_url_query_stabilization_leaves_unrelated_text_unt
|
||||||
out = scrub_pii_event(event, {})
|
out = scrub_pii_event(event, {})
|
||||||
assert out is not None
|
assert out is not None
|
||||||
assert out["extra"]["note"] == benign
|
assert out["extra"]["note"] == benign
|
||||||
|
|
||||||
|
|
||||||
|
# ── payments-disabled drop (issue #3471, GlitchTip-группа TRADE-IN-3GG) ─────
|
||||||
|
# 503 из `payments.py._require_enabled` — штатный ответ выключенного
|
||||||
|
# kill-switch'а, а не инцидент: sentry_sdk `StarletteIntegration` репортит его
|
||||||
|
# как error-событие только потому, что 503 попадает в дефолтный диапазон
|
||||||
|
# `failed_request_status_codes` (5xx), хотя FastAPI обработал исключение
|
||||||
|
# штатно. 167 событий/2 недели от внутреннего IP смоук-проверки (кнопки оплаты
|
||||||
|
# во фронте нет) размывали ленту. Фильтр не должен трогать поведение самой
|
||||||
|
# ручки (503 остаётся) и не должен глотать другие ошибки — включая другие 503.
|
||||||
|
|
||||||
|
from fastapi import HTTPException # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _exc_info_hint(exc: BaseException) -> dict:
|
||||||
|
"""Та же форма hint, что sentry_sdk реально передаёт в before_send —
|
||||||
|
`exc_info = (type, value, traceback)` (см. `_hint_for` выше по файлу)."""
|
||||||
|
return {"exc_info": (type(exc), exc, exc.__traceback__)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_payments_disabled_event_drops_the_503() -> None:
|
||||||
|
exc = HTTPException(status_code=503, detail="payments are disabled")
|
||||||
|
event = {"level": "error", "exception": {"values": [{"type": "HTTPException"}]}}
|
||||||
|
assert drop_payments_disabled_event(event, _exc_info_hint(exc)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_payments_disabled_event_leaves_other_errors_untouched() -> None:
|
||||||
|
"""Обычная ошибка (не платёжный kill-switch) должна долетать до GlitchTip
|
||||||
|
без изменений — фильтр специфичен по (status_code, detail), а не по 5xx."""
|
||||||
|
exc = ValueError("boom")
|
||||||
|
event = {"level": "error"}
|
||||||
|
out = drop_payments_disabled_event(event, _exc_info_hint(exc))
|
||||||
|
assert out is event
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_payments_disabled_event_leaves_other_503s_untouched() -> None:
|
||||||
|
"""Другой 503 с другим текстом (напр. будущий maintenance-режим другого
|
||||||
|
роутера) не должен ложно схлопнуться с платёжным kill-switch'ем."""
|
||||||
|
exc = HTTPException(status_code=503, detail="service temporarily unavailable")
|
||||||
|
event = {"level": "error"}
|
||||||
|
out = drop_payments_disabled_event(event, _exc_info_hint(exc))
|
||||||
|
assert out is event
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_payments_disabled_event_leaves_matching_detail_wrong_status_untouched() -> None:
|
||||||
|
"""Тот же текст detail, но другой status_code — не платёжный kill-switch."""
|
||||||
|
exc = HTTPException(status_code=500, detail="payments are disabled")
|
||||||
|
event = {"level": "error"}
|
||||||
|
out = drop_payments_disabled_event(event, _exc_info_hint(exc))
|
||||||
|
assert out is event
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_payments_disabled_event_no_exc_info_untouched() -> None:
|
||||||
|
event = {"level": "error"}
|
||||||
|
out = drop_payments_disabled_event(event, {})
|
||||||
|
assert out is event
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_payments_disabled_event_handles_non_dict_event() -> None:
|
||||||
|
assert drop_payments_disabled_event(None, {}) is None # type: ignore[arg-type]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue