diff --git a/docker-compose.metrics.yml b/docker-compose.metrics.yml
index 820f75e9..631b519d 100644
--- a/docker-compose.metrics.yml
+++ b/docker-compose.metrics.yml
@@ -196,6 +196,12 @@ services:
# Внешний адрес попадает в кнопку. Пустой — сообщение уйдёт без кнопки,
# но уйдёт: алерт важнее подтверждения.
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:
- ./ops/metrics/alert-ack/app.py:/app/app.py:ro
expose:
diff --git a/ops/metrics/alert-ack/app.py b/ops/metrics/alert-ack/app.py
index 323f077d..0ca1f2ea 100644
--- a/ops/metrics/alert-ack/app.py
+++ b/ops/metrics/alert-ack/app.py
@@ -27,6 +27,28 @@
METRICS_TELEGRAM_ONCALL кого звать поимённо (необязательна)
ALERT_ACK_PUBLIC_URL внешний адрес сервиса, попадает в кнопку
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
@@ -51,6 +73,7 @@ TOPIC_ID = os.environ.get("METRICS_TELEGRAM_TOPIC_ID", "")
ONCALL = os.environ.get("METRICS_TELEGRAM_ONCALL", "")
PUBLIC_URL = os.environ.get("ALERT_ACK_PUBLIC_URL", "").rstrip("/")
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{}/{}"
# 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)
+_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 = (
""
"
{t}"
@@ -243,7 +339,11 @@ class Handler(BaseHTTPRequestHandler):
self._reply(code, page.encode())
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")
return
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")
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:
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)}")
if not 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"))
log.info("alert-ack слушает :%d, тема=%s, дежурный=%s", port, TOPIC_ID or "—", ONCALL or "—")
ThreadingHTTPServer(("", port), Handler).serve_forever()
diff --git a/ops/metrics/alert-ack/test_app.py b/ops/metrics/alert-ack/test_app.py
new file mode 100644
index 00000000..019ff13c
--- /dev/null
+++ b/ops/metrics/alert-ack/test_app.py
@@ -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"]