gendesign/ops/metrics/alert-ack/app.py
bot-backend 4d3e273405
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
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
fix(ops): alert-ack вычитывает тело запроса до любой ветки отказа
Соединение переиспользуется (protocol_version = HTTP/1.1), и Caddy перед
сервисом держит пул к апстриму. Ответ 401/404/503 без чтения тела оставлял
его в сокете, и следующий запрос по тому же соединению начинался с чужих
байт.

Поймано на проде: зонд без секрета получил 401, а следующий запрос — уже с
верным секретом — вернул 501 Unsupported method ('{"text":"probe"}POST').
То есть один отказ съедал следующий НАСТОЯЩИЙ алерт, ровно в том канале,
который заводился как резервный.

Тело теперь читается один раз в начале do_POST и передаётся вниз. Четыре
теста поднимают настоящий сокет и шлют пару запросов по одному соединению —
на прежнем коде три из них падают с той же строкой 501.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JY6iWDnGDthdvsMWgK1BMG
2026-09-12 14:25:44 +03:00

421 lines
22 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
# Тело читается ДО любой развилки и ветки отказа. protocol_version =
# HTTP/1.1, то есть соединение переиспользуется, а Caddy перед нами
# держит пул к апстриму. Ответить 401/404/503, не вычитав тело, значит
# оставить его в сокете — и следующий запрос по тому же соединению
# начнётся с чужих байт. Проверено на проде 12.09.2026: неавторизованный
# зонд на /glitchtip, а следом законный алерт получил
# 501 Unsupported method ('{"text":"probe"}POST'). То есть один
# отказ ронял следующий НАСТОЯЩИЙ алерт — ровно то, ради чего этот
# резервный канал и заводился.
parsed = urllib.parse.urlsplit(self.path)
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
if parsed.path == "/glitchtip":
self._handle_glitchtip(parsed, raw)
return
if parsed.path != "/alertmanager":
self._reply(404, b"not found", "text/plain; charset=utf-8")
return
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, raw: bytes) -> 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
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()