"""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) единственный канал для аутентификации от САМОГО GlitchTip — сам URL (как и у Slack-вебхуков): заголовок GlitchTip-сторона не добавляет. Поэтому хендлер принимает секрет и из заголовка ``X-GlitchTip-Secret`` (предпочтительно — не течёт в access-log, #3154), и из query-параметра ``?secret=`` как fallback для текущего отправителя. Переиспользуем существующий ``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, Header, HTTPException, Query, Request from fastapi.responses import JSONResponse from pydantic import BaseModel, ConfigDict, ValidationError from starlette.background import BackgroundTask from app.core.config import settings from app.services.tgbot.client import TelegramError from app.services.tgbot.shared import get_telegram_client from app.tasks.glitchtip_alert_retry import retry_forward_alert 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 # constant-time: длина/префикс секрета не утекают через время ответа. if not secrets.compare_digest(provided or "", expected): logger.warning("glitchtip webhook: invalid or missing secret") raise HTTPException(status_code=401, detail="invalid or missing secret") @router.post("/ops/glitchtip-webhook", response_model=None) async def glitchtip_webhook( request: Request, secret: Annotated[str, Query()] = "", header_secret: Annotated[str, Header(alias="X-GlitchTip-Secret")] = "", ) -> dict[str, str] | JSONResponse: """Приёмник GlitchTip webhook-алертов (issue + uptime) → пересылка в Telegram-тему алертов (``TELEGRAM_ALERTS_CHAT_ID``/``TELEGRAM_ALERTS_TOPIC_ID`` — ОТДЕЛЬНАЯ тема от support-топика, см. docstring модуля). Отказ синхронной попытки (#3471) отвечает 502 как и раньше (#3456 — честный сигнал отправителю), но ставит доставку в фон (``app.tasks.glitchtip_alert_retry.retry_forward_alert`` через ``starlette.background.BackgroundTask`` на самом ответе) — GlitchTip вебхуки не ретраит (#3157), без этого текст алерта терялся бы безвозвратно. ``BackgroundTask`` привязан НАПРЯМУЮ к возвращаемому ``JSONResponse``, а не к ``BackgroundTasks``-зависимости: FastAPI прикрепляет задачи из ``BackgroundTasks`` только к ответу, который вернул сам хендлер, а `raise HTTPException` строит ОТДЕЛЬНЫЙ ответ в exception-мидлваре — задачи, поставленные до `raise`, в реальности молча терялись бы вместе с ним (это воспроизведено тестом, не гипотеза). Путь публичный в ``rbac_guard`` (``app.core.rbac._PUBLIC_PATHS``) — этот хендлер сам делает единственную проверку секрета. Секрет принимается ИЗ ЗАГОЛОВКА ``X-GlitchTip-Secret``, а query-параметр ``?secret=`` остаётся fallback'ом (#3154). Заголовок предпочтителен потому, что query едет в access-log и оттуда в Loki открытым текстом; query оставлен, т.к. САМ GlitchTip 6.1.6 заголовков не шлёт вовсе (``send_webhook()`` — ``session.post(url, json=...)`` без headers, см. docstring модуля), и убрать query можно только когда заголовок начнёт подставлять кто-то перед нами (Caddy ``header_up`` на маршруте вебхука) либо после смены отправителя. """ if not _alerts_configured(): raise HTTPException(status_code=503, detail="glitchtip alerts webhook not configured") _verify_secret(header_secret or secret) raw_body = await request.body() received_at = datetime.now(UTC) text = _build_message(raw_body, received_at) # Общий клиент приложения (#tg-connection-resilience): на каждый запрос # свой создавать нельзя — это ноль keep-alive и полный TCP+TLS-хендшейк # до api.telegram.org перед каждой отправкой. Живёт в lifespan. client = get_telegram_client() 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 TelegramError: # Ловим общий предок, а не `TelegramApiError`: недоступность Telegram — # тоже «переслать не смогли», и отвечать на неё надо задуманным 502, а не # 500 из необработанного исключения (#3456). 502 ОСТАЁТСЯ — это честный # сигнал отправителю. Но GlitchTip вебхуки не ретраит (#3157) — без этого # текст алерта пропал бы бесследно, поэтому доставку ставим в фон # (#3471, см. app.tasks.glitchtip_alert_retry). # # `raise HTTPException` здесь НЕ подходит: FastAPI прикрепляет # background-задачи только к ответу, который вернул сам хендлер, а # исключение строит СВОЙ отдельный JSONResponse в exception-мидлваре — # задача, поставленная до `raise`, никогда бы не выполнилась. Поэтому # 502 собран и возвращён вручную, с задачей на этом же объекте ответа. logger.exception("glitchtip webhook: не удалось переслать алерт в Telegram") return JSONResponse( status_code=502, content={"detail": "failed to forward alert to telegram"}, background=BackgroundTask( retry_forward_alert, client, chat_id=settings.telegram_alerts_chat_id, text=text, message_thread_id=settings.telegram_alerts_topic_id or None, ), ) return {"status": "ok"}