gendesign/ops/metrics/alert-ack/app.py
bot-backend 423842ae36
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(ops): резервный получатель GlitchTip-алертов в alert-ack
Все три alert-правила GlitchTip (backend, frontend, Trade-In) сейчас шлют
единственный вебхук в продуктовый бэкенд на Selectel — тот самый хост, за
которым они следят. Если там упал backend или Caddy, ошибки приложения
задерживаются или пропадают именно тогда, когда нужнее всего.

Добавлен POST /glitchtip в alert-ack (живёт на инфраструктурном хосте Beget,
не зависит от здоровья продукта): второй получатель того же Slack-совместимого
payload, аутентификация секретом в заголовке X-GlitchTip-Secret или query
?secret= (тот же подход, что у tradein-mvp/backend/app/api/v1/glitchtip.py).
Сообщение уходит в существующую тему клиентских инцидентов с явной пометкой
«резервный канал». Секрет свой (ALERT_ACK_GLITCHTIP_SECRET), не переиспользует
продуктовый TRADEIN_INTERNAL_AUTH_SECRET.

Refs #3471
2026-09-12 14:01:30 +03:00

414 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Приёмник алертов с кнопкой подтверждения (#3078).
ЗАЧЕМ ОТДЕЛЬНЫЙ СЕРВИС. Alertmanager умеет писать в Telegram сам, но инлайн-
клавиатуру его интеграция не поддерживает — а без кнопки нет обратной связи
«человек увидел и взял в работу». 27.08 продукты лежали 10 часов, и вопрос «а
кто-нибудь вообще это читает» было не к кому адресовать.
ПОЧЕМУ НА НАБЛЮДАТЕЛЕ, А НЕ У БОТА МЕРЫ. У `tradein-tgbot` уже есть приём
обновлений, и соблазн повесить обработку туда велик. Но он живёт на продуктовом
хосте: при падении продукта кнопка окажется мёртвой ровно в тот момент, ради
которого её и делают. Сервис живёт рядом с Alertmanager на инфраструктурной
машине и переживает смерть наблюдаемого.
ПОЧЕМУ ССЫЛКА, А НЕ CALLBACK. Callback-кнопка требует, чтобы кто-то читал
обновления бота. Бот один, и его обновления уже читает МЕРА — второй читатель
получил бы `409 Conflict` и отобрал бы сообщения у поддержки. URL-кнопка не
требует обновлений вовсе.
БЕЗ ЗАВИСИМОСТЕЙ. Только стандартная библиотека: сервис обязан подниматься даже
тогда, когда всё остальное сломано, и не тащить за собой установку пакетов.
Переменные окружения:
METRICS_TELEGRAM_BOT_TOKEN обязательна
METRICS_TELEGRAM_CHAT_ID обязательна
METRICS_TELEGRAM_TOPIC_ID тема форума (необязательна)
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
import html
import json
import logging
import os
import secrets
import threading
import time
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("alert-ack")
BOT_TOKEN = os.environ.get("METRICS_TELEGRAM_BOT_TOKEN", "")
CHAT_ID = os.environ.get("METRICS_TELEGRAM_CHAT_ID", "")
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}
_PENDING: dict[str, dict] = {}
_LOCK = threading.Lock()
def _tg(method: str, payload: dict) -> dict | None:
"""Вызвать Bot API. Три попытки: путь до Telegram теряет соединения (#3059).
Отказы происходят на стадии подключения, до отправки запроса, поэтому повтор
почти никогда не дублирует уже доставленное. Потерянный алерт дороже дубля.
"""
data = urllib.parse.urlencode(payload).encode()
for attempt in (1, 2, 3):
try:
req = urllib.request.Request(API.format(BOT_TOKEN, method), data=data)
with urllib.request.urlopen(req, timeout=15) as resp:
out = json.loads(resp.read().decode())
if attempt > 1:
log.info("%s: доставлено с попытки %d", method, attempt)
return out
except Exception as exc: # noqa: BLE001 — падать нельзя, это канал алертов
log.warning("%s: попытка %d не удалась: %s", method, attempt, exc)
if attempt < 3:
time.sleep(2)
log.error("%s: три попытки подряд неудачны — сообщение НЕ доставлено", method)
return None
def _purge() -> None:
"""Выбросить протухшие токены. Без этого словарь растёт до перезапуска."""
now = time.time()
with _LOCK:
dead = [t for t, v in _PENDING.items() if now - v["created"] > TTL_SEC]
for t in dead:
del _PENDING[t]
def _render(payload: dict) -> tuple[str, str]:
"""Собрать текст сообщения и краткий заголовок инцидента."""
status = payload.get("status", "firing")
alerts = payload.get("alerts") or []
common = payload.get("commonLabels") or {}
first = (alerts[0].get("labels") or {}).get("alertname") if alerts else "?"
name = common.get("alertname") or first
host = common.get("host", "")
title = f"{name}{' · ' + host if host else ''}"
if status == "resolved":
return f"✅ <b>Восстановлено</b> · {html.escape(title)}", title
lines = [f"🚨 <b>КЛИЕНТЫ ЗАТРОНУТЫ</b> · {html.escape(title)}"]
if ONCALL:
lines.append(f"{html.escape(ONCALL)} — нужна реакция.")
for a in alerts[:5]:
ann = a.get("annotations") or {}
if ann.get("summary"):
lines.append(html.escape(ann["summary"]))
if ann.get("description"):
lines.append(html.escape(ann["description"]))
return "\n".join(lines), title
def _send_alert(payload: dict) -> None:
text, title = _render(payload)
msg = {
"chat_id": CHAT_ID,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": "true",
}
if TOPIC_ID:
msg["message_thread_id"] = TOPIC_ID
# Кнопка только у горящего инцидента: подтверждать нечего, когда всё прошло.
token = ""
if payload.get("status", "firing") == "firing" and PUBLIC_URL:
token = secrets.token_urlsafe(16)
msg["reply_markup"] = json.dumps(
{
"inline_keyboard": [
[{"text": "✅ Принял в работу", "url": f"{PUBLIC_URL}/ack/{token}"}]
]
}
)
out = _tg("sendMessage", msg)
if token and out and out.get("ok"):
with _LOCK:
_PENDING[token] = {
"message_id": out["result"]["message_id"],
"title": title,
"created": time.time(),
"acked_by": None,
}
elif token:
# Кнопку прицепить не вышло — сообщение важнее кнопки, шлём без неё.
msg.pop("reply_markup", 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 = (
"<!doctype html><meta charset=utf-8>"
"<title>{t}</title>"
"<body style='font:16px system-ui;margin:3rem;max-width:32rem'>"
"<h1 style='font-size:1.4rem'>{h}</h1><p>{p}</p></body>"
)
def do_ack(token: str) -> tuple[int, str]:
"""Обработать нажатие кнопки. Вынесено из HTTP-слоя, чтобы быть проверяемым.
Возвращает (код ответа, HTML-страница). Идемпотентно: повторное нажатие не
шлёт второе сообщение в чат — ссылка живёт сутки, и по ней могут кликнуть
дважды, а дубль «принято» в теме выглядит как второй человек.
"""
_purge()
with _LOCK:
entry = _PENDING.get(token)
if entry is None:
# Неизвестный или протухший токен. Не рассказываем, что именно: эндпоинт
# публичный, и перебор не должен получать подсказок.
return 404, _PAGE.format(
t="Не найдено",
h="Ссылка недействительна",
p="Возможно, инцидент уже подтверждён или ссылка устарела.",
)
if entry["acked_by"]:
return 200, _PAGE.format(
t="Уже подтверждено",
h="Уже подтверждено",
p=f"Инцидент «{html.escape(entry['title'])}» уже отмечен как принятый.",
)
who = ONCALL or "дежурный"
when = time.strftime("%H:%M", time.localtime())
with _LOCK:
entry["acked_by"] = who
reply = {
"chat_id": CHAT_ID,
"text": (
f"✅ <b>Принято в работу</b> · {html.escape(entry['title'])}\n"
f"{html.escape(who)}, {when}"
),
"parse_mode": "HTML",
"reply_to_message_id": entry["message_id"],
}
if TOPIC_ID:
reply["message_thread_id"] = TOPIC_ID
_tg("sendMessage", reply)
# Снимаем кнопку у исходного сообщения, чтобы её не жали повторно.
_tg(
"editMessageReplyMarkup",
{"chat_id": CHAT_ID, "message_id": entry["message_id"], "reply_markup": json.dumps({})},
)
return 200, _PAGE.format(
t="Принято",
h="Принято в работу",
p=f"Отметка отправлена в чат. Инцидент: «{html.escape(entry['title'])}».",
)
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt: str, *args) -> None: # noqa: A003 — подпись из stdlib
log.info("%s %s", self.address_string(), fmt % args)
def _reply(self, code: int, body: bytes, ctype: str = "text/html; charset=utf-8") -> None:
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None: # noqa: N802 — имя из stdlib
if self.path == "/healthz":
self._reply(200, b"ok", "text/plain; charset=utf-8")
return
if not self.path.startswith("/ack/"):
self._reply(404, b"not found", "text/plain; charset=utf-8")
return
code, page = do_ack(self.path[len("/ack/") :].split("?")[0])
self._reply(code, page.encode())
def do_POST(self) -> None: # noqa: N802 — имя из stdlib
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)
raw = self.rfile.read(length) if length else b"{}"
try:
payload = json.loads(raw.decode() or "{}")
except Exception: # noqa: BLE001
log.warning("вебхук: тело не разобрано, %d байт", len(raw))
self._reply(400, b"bad json", "text/plain; charset=utf-8")
return
# Отвечаем Alertmanager сразу: его таймаут короткий, а отправка в
# Telegram может занять до трёх попыток. Иначе он посчитает доставку
# неудачной и начнёт слать повторы поверх уже отправленного.
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]
if missing:
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()
if __name__ == "__main__":
main()