gendesign/tradein-mvp/backend/app/api/v1/glitchtip.py
bot-backend b649caec2d
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
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
CI Trade-In / backend-tests (pull_request) Successful in 4m50s
feat(tradein/ops): приёмник GlitchTip webhook-алертов в Telegram-тему
Мониторинг GlitchTip сейчас нем (alerts_projectalert/alerts_alertrecipient
пусты, EMAIL_URL=consolemail:// печатает письма в stdout, аудит на проде
2026-08-15). GlitchTip умеет получателя типа webhook, но шлёт свой Slack-
совместимый JSON без каких-либо заголовков — Telegram Bot API его не
понимает, нужен адаптер.

- app/api/v1/glitchtip.py: POST /api/v1/trade-in/ops/glitchtip-webhook —
  принимает issue- и uptime-алерты (структурно одинаковый payload у
  GlitchTip 6.1.6, см. docstring), форматирует короткое сообщение
  (проект/заголовок/ссылка/время получения) и шлёт через существующий
  TelegramClient в отдельную тему алертов. Обрезка под лимит Telegram
  (4096 симв.), неизвестная форма payload пересылается как есть с
  пометкой вместо 500.
- Auth: GlitchTip не может слать кастомные заголовки (aiohttp.post без
  headers=) — переиспользуем TRADEIN_INTERNAL_AUTH_SECRET (#2213) как
  query-параметр `secret`, constant-time compare. В отличие от rbac.py
  пустой секрет здесь fail-CLOSED (503), это единственный auth-рубеж пути.
- config.py: TELEGRAM_ALERTS_CHAT_ID / TELEGRAM_ALERTS_TOPIC_ID — намеренно
  отдельные от TELEGRAM_SUPPORT_*, чтобы алерты не лились в топик клиентов.
- rbac.py: путь добавлен в _PUBLIC_PATHS (фиксированный, без секрета в
  самом пути — секрет только в query).
- docker-compose.prod.yml: glitchtip-worker (реально шлёт вебхуки, не
  glitchtip-web) переведён на networks: [default, shared] — без этого
  tradein-backend не резолвится с его стороны (общей сети не было вообще).
2026-08-15 22:39:44 +03:00

219 lines
11 KiB
Python
Raw Permalink 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.

"""GlitchTip → Telegram алерты (мониторинг сейчас нем: `alerts_projectalert`/
`alerts_alertrecipient` пусты, `EMAIL_URL=consolemail://` печатает письма в
stdout и никуда их не доставляет — аудит на проде 2026-08-15).
GlitchTip (self-hosted, `errors.gendsgn.ru`, образ `glitchtip/glitchtip:6.1.6`)
умеет слать получателю типа `webhook` (``RecipientType.GENERAL_WEBHOOK`` —
"General Slack-compatible webhook"). И issue-алерты (``apps/alerts/webhooks.py
send_issue_as_webhook``), и uptime-алерты (``apps/uptime/webhooks.py
_send_uptime_generic``) в итоге идут через ОДНУ И ТУ ЖЕ низкоуровневую
``send_webhook()`` — ``aiohttp.ClientSession.post(url, json=asdict(WebhookPayload
(text=..., attachments=[...])))``, БЕЗ каких-либо заголовков (ни Authorization,
ни подписи, ни X-*). Значит:
1) тело запроса для issue и uptime алертов структурно ОДИНАКОВОЕ —
``{"text": str, "attachments": [{"title","title_link","text","color",
"fields",...}]}`` — просто у uptime пустые/отсутствующие ``fields``/``color``;
2) единственный канал для аутентификации — сам URL (как и Slack-вебхуки).
Секрет ОБЯЗАН ехать query-параметром, HTTP-заголовок здесь поставить
нечем (GlitchTip-сторона его не добавляет).
Переиспользуем существующий ``TRADEIN_INTERNAL_AUTH_SECRET`` (#2213
defense-in-depth, см. ``app.core.rbac``) вместо нового секрета — тот же
``secrets.compare_digest`` constant-time compare, тот же env. Отличие от
rbac-паттерна: ТАМ пустой секрет — fail-open (есть второй рубеж, roles.yaml).
ЗДЕСЬ секрет — единственный рубеж вообще, поэтому пустой секрет ИЛИ
несконфигурированный Telegram-бот → 503 "не настроено", а не тихий
fail-open настежь.
Путь ФИКСИРОВАННЫЙ (не несёт секрет в себе) — так его можно добавить в
``app.core.rbac._PUBLIC_PATHS`` одной строкой (точное совпадение, без
regex/prefix-веток в ``rbac_guard``). Сам путь — не секрет, секрет — только
значение query-параметра.
Сетевая связность (docker-compose.prod.yml, корневой стек): вебхуки шлёт
``glitchtip-worker`` (celery-таска), НЕ ``glitchtip-web`` — оба сейчас сидят
только в ``gendesign_default``. tradein-backend слушает на ``gendesign_shared``
(алиас неявный — Docker embedded DNS резолвит по ``container_name``, тот же
приём уже используется Caddy → ``tradein-backend:8000``, см. Caddyfile).
Значит ``glitchtip-worker`` тоже должен быть подписан на ``gendesign_shared``,
иначе имя ``tradein-backend`` не резолвится — общей сети нет.
"""
from __future__ import annotations
import json
import logging
import secrets
from datetime import UTC, datetime
from typing import Annotated, Any
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict, ValidationError
from app.core.config import settings
from app.services.tgbot.client import TelegramApiError, TelegramClient
logger = logging.getLogger(__name__)
router = APIRouter()
# Telegram sendMessage лимит — 4096 символов (см. support.py MAX_MESSAGE_LENGTH
# для исходящих сообщений пользователя; здесь лимит на ИСХОДЯЩЕЕ в Telegram, тот
# же потолок). Суффикс обрезки учтён в _truncate.
_TELEGRAM_MAX_LEN = 4096
_TRUNCATE_SUFFIX = "\n… (обрезано)"
# Узкий интерактивный бюджет (тот же принцип, что #tgsupport-web review H1 в
# support.py): GlitchTip-таска ждёт HTTP-ответ синхронно (её собственный aiohttp
# timeout=10s), поэтому наш путь не может тянуть воркерные 5 ретраев/минуты.
_INTERACTIVE_SEND_TIMEOUT_S = 8.0
_INTERACTIVE_SEND_MAX_RETRIES = 1
class GlitchTipAttachment(BaseModel):
"""Slack-совместимый attachment. Issue- и uptime-алерты заполняют РАЗНЫЕ
подмножества полей (uptime не шлёт ``fields``/``color``) — все опциональны,
``extra="allow"`` на случай будущих версий GlitchTip."""
model_config = ConfigDict(extra="allow")
title: str | None = None
title_link: str | None = None
text: str | None = None
color: str | None = None
fields: list[dict[str, Any]] | None = None
class GlitchTipWebhookPayload(BaseModel):
"""Тело POST от GlitchTip ``send_webhook()`` — одинаковое для issue- и
uptime-алертов (см. docstring модуля)."""
model_config = ConfigDict(extra="allow")
text: str | None = None
attachments: list[GlitchTipAttachment] | None = None
def _truncate(text: str, limit: int = _TELEGRAM_MAX_LEN) -> str:
if len(text) <= limit:
return text
return text[: limit - len(_TRUNCATE_SUFFIX)] + _TRUNCATE_SUFFIX
def _field_value(attachment: GlitchTipAttachment, label: str) -> str | None:
"""Ищет значение поля attachment.fields по title (issue-алерты кладут туда
"Project" литералом — см. apps/alerts/webhooks.py send_issue_as_webhook)."""
for field in attachment.fields or []:
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 _format_known_payload(payload: GlitchTipWebhookPayload, received_at: datetime) -> str:
lines = [f"GlitchTip: {payload.text or 'Alert'}"]
for attachment in payload.attachments or []:
block: list[str] = []
project = _field_value(attachment, "Project")
if project:
block.append(f"Проект: {project}")
if attachment.title:
block.append(attachment.title)
if attachment.text:
block.append(attachment.text)
if attachment.title_link:
block.append(f"Ссылка: {attachment.title_link}")
if block:
lines.append("")
lines.extend(block)
lines.append("")
lines.append(f"Получено: {received_at.strftime('%Y-%m-%d %H:%M:%S')} UTC")
return "\n".join(lines)
def _format_unknown_payload(raw_body: bytes, received_at: datetime) -> str:
"""Payload не распознан ни как issue-, ни как uptime-алерт (нет ни `text`,
ни `attachments`, либо тело — не JSON-объект вовсе) — не роняем запрос,
пересылаем как есть с пометкой (см. требование задачи: неизвестная форма
payload не должна давать 500)."""
text_repr = raw_body.decode("utf-8", errors="replace")
header = "GlitchTip webhook: неизвестный формат payload, пересылаю как есть"
return _truncate(
f"{header}\n\n{text_repr}\n\nПолучено: {received_at.strftime('%Y-%m-%d %H:%M:%S')} UTC"
)
def _build_message(raw_body: bytes, received_at: datetime) -> str:
try:
data = json.loads(raw_body)
except (json.JSONDecodeError, UnicodeDecodeError):
return _format_unknown_payload(raw_body, received_at)
if not isinstance(data, dict):
return _format_unknown_payload(raw_body, received_at)
try:
payload = GlitchTipWebhookPayload.model_validate(data)
except ValidationError:
return _format_unknown_payload(raw_body, received_at)
if payload.text is None and not payload.attachments:
return _format_unknown_payload(raw_body, received_at)
return _truncate(_format_known_payload(payload, received_at))
def _alerts_configured() -> bool:
"""Все три части ОБЯЗАНЫ быть заданы: секрет (auth), токен бота, chat_id
темы алертов. Отсутствие любой — 503, а не тихий no-op и не fail-open."""
return bool(
settings.tradein_internal_auth_secret
and settings.telegram_bot_token
and settings.telegram_alerts_chat_id
)
def _verify_secret(provided: str) -> None:
expected = settings.tradein_internal_auth_secret
if not secrets.compare_digest(provided or "", expected):
logger.warning("glitchtip webhook: invalid or missing secret query param")
raise HTTPException(status_code=401, detail="invalid or missing secret")
@router.post("/ops/glitchtip-webhook")
async def glitchtip_webhook(
request: Request,
secret: Annotated[str, Query()] = "",
) -> dict[str, str]:
"""Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в
Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID``
— ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля).
Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) — этот
хендлер сам делает единственную проверку (``secret`` query-параметр).
"""
if not _alerts_configured():
raise HTTPException(status_code=503, detail="glitchtip alerts webhook not configured")
_verify_secret(secret)
raw_body = await request.body()
received_at = datetime.now(UTC)
text = _build_message(raw_body, received_at)
client = TelegramClient(settings.telegram_bot_token)
try:
await client.send_message(
chat_id=settings.telegram_alerts_chat_id,
text=text,
message_thread_id=settings.telegram_alerts_topic_id or None,
# review H1-style бюджет (см. support.py) — синхронный HTTP-путь не
# может легально висеть воркерные минуты ретраев.
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
)
except TelegramApiError:
logger.exception("glitchtip webhook: не удалось переслать алерт в Telegram")
raise HTTPException(status_code=502, detail="failed to forward alert to telegram") from None
return {"status": "ok"}