Merge pull request 'Резервный канал для алертов GlitchTip: приём на alert-ack, другой хост и другой провайдер' (#3482) from feat/3471-glitchtip-fallback-alert-ack into main
This commit is contained in:
commit
01960b03be
3 changed files with 261 additions and 1 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
Loading…
Add table
Reference in a new issue