gendesign/ops/metrics/alert-ack/app.py
bot-backend 053a5fb75c
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Failing after 1m12s
CI / openapi-codegen-check (pull_request) Successful in 1m55s
feat(observability): кнопка «Принял в работу» под клиентским инцидентом (#3078)
Alertmanager инлайн-клавиатуру не поддерживает, а без кнопки нет обратной
связи «человек увидел и взял в работу»: 27.08 продукты лежали 10 часов, и
вопрос «а кто-нибудь это читает» было не к кому адресовать.

ГДЕ ЖИВЁТ. Рядом с Alertmanager, на инфраструктурной машине. У бота МЕРЫ
уже есть приём обновлений, и повесить обработку туда было бы дешевле, но
он работает на продуктовом хосте: при падении продукта кнопка оказалась бы
мёртвой ровно тогда, когда нужна.

ССЫЛКА, А НЕ CALLBACK. Callback требует читателя обновлений бота. Бот один,
и его обновления уже читает МЕРА — второй читатель получил бы 409 Conflict
и отобрал бы сообщения у поддержки.

БЕЗ ПАРОЛЯ НА /ack/*, ОСОЗНАННО. Кнопку жмут ночью с телефона, когда лежит
прод; требование пароля даст ноль нажатий. Защита — 128-битный токен под
конкретное сообщение, живущий сутки; максимум, чего добьётся угадавший, —
ложная отметка в чате, где сразу видно, что её поставил не человек.

ТОЛЬКО КЛИЕНТСКИЙ МАРШРУТ идёт через сервис. Прочие алерты сохраняют прямой
путь в Telegram: чем меньше звеньев, тем надёжнее. Если сервис лёг,
Alertmanager повторяет доставку и переуведомляет каждые 30 минут — алерт
задерживается, но не теряется. Дублировать вторым прямым каналом не стали:
шум в канале тревог опаснее задержки.

Девять тестов дёргают настоящие функции, подменяя один шов — вызов Bot API.
Важнейший: при отказе отправки с клавиатурой сообщение уходит БЕЗ неё —
алерт важнее кнопки.
2026-08-27 13:56:00 +03:00

277 lines
13 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)
"""
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
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)
_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
if self.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 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 пуст — сообщения уйдут БЕЗ кнопки подтверждения")
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()