feat(tradein/ops): алерты GlitchTip доезжают в Telegram #2915

Merged
lekss361 merged 1 commit from feat/tradein-glitchtip-telegram-alerts into main 2026-08-15 19:56:41 +00:00
6 changed files with 537 additions and 1 deletions

View file

@ -367,7 +367,16 @@ services:
CELERY_WORKER_AUTOSCALE: "1,3"
restart: always
mem_limit: 384m
networks: [default]
# GlitchTip → Telegram алерты (мониторинг был нем, аудит 2026-08-15,
# см. tradein-mvp/backend/app/api/v1/glitchtip.py): вебхуки шлёт РЕАЛЬНО
# этот celery-воркер (apps/alerts/webhooks.py send_webhook — не glitchtip-web),
# получателю `webhook` нужен доступ к http://tradein-backend:8000/... —
# tradein-backend сидит на gendesign_shared, у glitchtip-* её раньше не было
# вообще (та же грабля, что #2709 у redis: сеть должна быть общей ДО того,
# как переменная окружения с URL вообще имеет смысл). default — обязательно
# явно, иначе воркер потеряет Postgres/Redis-брокер (см. комментарий у redis
# выше про неявную привязку к default).
networks: [default, shared]
caddy:
image: caddy:2

View file

@ -0,0 +1,219 @@
"""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"}

View file

@ -997,6 +997,15 @@ class Settings(BaseSettings):
# message_thread_id топика внутри support-группы, в который идут зеркала.
telegram_support_topic_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_TOPIC_ID")
# ── GlitchTip → Telegram алерты (мониторинг был нем, аудит 2026-08-15) ──
# Отдельная тема от TELEGRAM_SUPPORT_TOPIC_ID выше — алерты об ошибках прода
# НЕ должны литься в топик, куда пишут живые клиенты. См. app/api/v1/glitchtip.py.
# Пусто/0 = вебхук отвечает 503 "not configured" (fail-closed, не fail-open —
# это единственный auth-рубеж эндпоинта, в отличие от rbac-путей).
# ENV: TELEGRAM_ALERTS_CHAT_ID, TELEGRAM_ALERTS_TOPIC_ID.
telegram_alerts_chat_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_CHAT_ID")
telegram_alerts_topic_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_TOPIC_ID")
# ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ──────────
# См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит
# роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch.

View file

@ -86,6 +86,12 @@ _PUBLIC_PATHS = frozenset(
# не секрет, читает только process env — быстрая справка для клиента/
# поддержки/смоук-теста, не должна требовать сессию.
"/api/v1/trade-in/version",
# GlitchTip webhook → Telegram (app/api/v1/glitchtip.py): вызывается
# ИЗ glitchtip-worker (docker-сеть gendesign_shared), не может нести
# X-Authenticated-User/сессию. Путь фиксированный и не секрет — секрет
# это query-параметр `secret`, который проверяет сам хендлер
# (secrets.compare_digest против TRADEIN_INTERNAL_AUTH_SECRET).
"/api/v1/trade-in/ops/glitchtip-webhook",
}
)
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед

View file

@ -27,6 +27,7 @@ from app.api.v1 import (
brand,
buildings,
geocode,
glitchtip,
lead,
me,
privacy_admin,
@ -251,6 +252,7 @@ app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]
app.include_router(version.router, prefix="/api/v1/trade-in", tags=["trade-in-version"])
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-support"])
app.include_router(glitchtip.router, prefix="/api/v1/trade-in", tags=["trade-in-ops"])
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
app.include_router(search.router, prefix="/api/v1", tags=["search"])
app.include_router(me.router, prefix="/api/v1", tags=["me"])

View file

@ -0,0 +1,291 @@
"""Offline-тесты приёмника GlitchTip webhook-алертов
(POST /api/v1/trade-in/ops/glitchtip-webhook) app/api/v1/glitchtip.py.
Проверяет HTTP-контракт: успешная пересылка issue-/uptime-алертов в
Telegram (клиент замокан), отказ без валидного секрета, отказ при
несконфигурированных настройках, обрезка длинного текста под лимит Telegram
(4096), graceful-обработка неизвестной формы payload (НЕ 500).
NEVER touches real DB / real Telegram API.
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import json
from typing import Any, ClassVar
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api.v1 import glitchtip as glitchtip_module
from app.services.tgbot.client import TelegramApiError
_SECRET = "test-shared-secret"
_ENDPOINT = "/api/v1/trade-in/ops/glitchtip-webhook"
@pytest.fixture(autouse=True)
def _configured(monkeypatch: pytest.MonkeyPatch) -> None:
"""По умолчанию вебхук полностью сконфигурирован — отдельные тесты
переопределяют конкретные поля."""
monkeypatch.setattr(glitchtip_module.settings, "tradein_internal_auth_secret", _SECRET)
monkeypatch.setattr(glitchtip_module.settings, "telegram_bot_token", "fake-token")
monkeypatch.setattr(glitchtip_module.settings, "telegram_alerts_chat_id", -1004443088679)
monkeypatch.setattr(glitchtip_module.settings, "telegram_alerts_topic_id", 158)
class _FakeTelegramClient:
"""Подменяет `TelegramClient` внутри модуля `glitchtip` — никакого httpx/сети."""
calls: ClassVar[list[dict[str, Any]]] = []
_response: ClassVar[dict[str, Any] | Exception] = {"message_id": 1}
def __init__(self, _token: str) -> None:
pass
async def send_message(self, **kwargs: Any) -> dict[str, Any]:
_FakeTelegramClient.calls.append(kwargs)
if isinstance(_FakeTelegramClient._response, Exception):
raise _FakeTelegramClient._response
return _FakeTelegramClient._response
@pytest.fixture(autouse=True)
def _fake_telegram_client(monkeypatch: pytest.MonkeyPatch) -> Any:
_FakeTelegramClient.calls = []
_FakeTelegramClient._response = {"message_id": 1}
monkeypatch.setattr(glitchtip_module, "TelegramClient", _FakeTelegramClient)
return _FakeTelegramClient
@pytest.fixture
def client() -> TestClient:
app = FastAPI()
app.include_router(glitchtip_module.router, prefix="/api/v1/trade-in")
return TestClient(app)
_ISSUE_PAYLOAD = {
"text": "GlitchTip Alert",
"attachments": [
{
"title": "ValueError: something broke",
"title_link": "https://errors.gendsgn.ru/organizations/gendesign/issues/123/",
"text": "app/services/foo.py in bar",
"color": "#e03131",
"fields": [
{"title": "Project", "value": "tradein-backend", "short": True},
{"title": "Environment", "value": "production", "short": True},
],
"mrkdown_in": ["text"],
}
],
}
_UPTIME_PAYLOAD = {
"text": "GlitchTip Uptime Alert",
"attachments": [
{
"title": "gendsgn.ru",
"title_link": "https://errors.gendsgn.ru/organizations/gendesign/uptime/1/",
"text": "The monitored site has gone down.",
"image_url": None,
"color": None,
"fields": None,
"mrkdown_in": None,
}
],
}
# ── успешная пересылка ──────────────────────────────────────────────────────
def test_issue_alert_forwarded_to_telegram(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
assert r.status_code == 200, r.text
assert r.json() == {"status": "ok"}
assert len(_fake_telegram_client.calls) == 1
call = _fake_telegram_client.calls[0]
assert call["chat_id"] == -1004443088679
assert call["message_thread_id"] == 158
assert "ValueError: something broke" in call["text"]
assert "tradein-backend" in call["text"] # Project field
assert "errors.gendsgn.ru" in call["text"]
def test_uptime_alert_forwarded_to_telegram(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_UPTIME_PAYLOAD)
assert r.status_code == 200, r.text
assert len(_fake_telegram_client.calls) == 1
call = _fake_telegram_client.calls[0]
assert "GlitchTip Uptime Alert" in call["text"]
assert "gone down" in call["text"]
assert call["message_thread_id"] == 158
def test_telegram_failure_returns_502_not_500(
client: TestClient, _fake_telegram_client: Any
) -> None:
_FakeTelegramClient._response = TelegramApiError("sendMessage", 400, "chat not found")
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
assert r.status_code == 502
assert r.status_code != 500
# ── auth ─────────────────────────────────────────────────────────────────────
def test_missing_secret_401(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(_ENDPOINT, json=_ISSUE_PAYLOAD)
assert r.status_code == 401
assert _fake_telegram_client.calls == []
def test_wrong_secret_401(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(f"{_ENDPOINT}?secret=wrong-value", json=_ISSUE_PAYLOAD)
assert r.status_code == 401
assert _fake_telegram_client.calls == []
def test_secret_not_configured_returns_503_not_500(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
monkeypatch.setattr(glitchtip_module.settings, "tradein_internal_auth_secret", "")
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
assert r.status_code == 503
assert r.status_code != 500
assert _fake_telegram_client.calls == []
def test_bot_not_configured_returns_503(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
monkeypatch.setattr(glitchtip_module.settings, "telegram_bot_token", "")
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
assert r.status_code == 503
assert _fake_telegram_client.calls == []
def test_alerts_chat_id_not_configured_returns_503(
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
) -> None:
monkeypatch.setattr(glitchtip_module.settings, "telegram_alerts_chat_id", 0)
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=_ISSUE_PAYLOAD)
assert r.status_code == 503
assert _fake_telegram_client.calls == []
# ── обрезка длинного текста ─────────────────────────────────────────────────
def test_long_payload_truncated_to_telegram_limit(
client: TestClient, _fake_telegram_client: Any
) -> None:
huge_payload = {
"text": "GlitchTip Alert",
"attachments": [
{
"title": "Huge issue",
"title_link": "https://errors.gendsgn.ru/x",
"text": "x" * 10000,
}
],
}
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=huge_payload)
assert r.status_code == 200, r.text
sent_text = _fake_telegram_client.calls[0]["text"]
assert len(sent_text) <= 4096
assert sent_text.endswith("(обрезано)")
def test_unknown_form_huge_raw_body_truncated(
client: TestClient, _fake_telegram_client: Any
) -> None:
r = client.post(
f"{_ENDPOINT}?secret={_SECRET}",
content=("x" * 10000).encode(),
headers={"content-type": "application/json"},
)
assert r.status_code == 200, r.text
sent_text = _fake_telegram_client.calls[0]["text"]
assert len(sent_text) <= 4096
# ── неизвестная форма payload — НЕ 500 ──────────────────────────────────────
def test_unknown_json_shape_forwarded_with_marker(
client: TestClient, _fake_telegram_client: Any
) -> None:
"""Ни `text`, ни `attachments` — форма, которую GlitchTip НЕ шлёт сегодня,
но контракт задачи требует не падать 500, а переслать как есть."""
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json={"some_field": "some_value", "n": 42})
assert r.status_code == 200, r.text
sent_text = _fake_telegram_client.calls[0]["text"]
assert "неизвестный формат" in sent_text
assert "some_value" in sent_text
def test_non_json_body_forwarded_not_500(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(
f"{_ENDPOINT}?secret={_SECRET}",
content=b"not-json-at-all {{{",
headers={"content-type": "text/plain"},
)
assert r.status_code == 200, r.text
sent_text = _fake_telegram_client.calls[0]["text"]
assert "неизвестный формат" in sent_text
assert "not-json-at-all" in sent_text
def test_json_array_body_forwarded_not_500(client: TestClient, _fake_telegram_client: Any) -> None:
"""Валидный JSON, но не объект (top-level list) — тоже неизвестная форма."""
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", json=[1, 2, 3])
assert r.status_code == 200, r.text
assert len(_fake_telegram_client.calls) == 1
def test_empty_body_forwarded_not_500(client: TestClient, _fake_telegram_client: Any) -> None:
r = client.post(f"{_ENDPOINT}?secret={_SECRET}", content=b"")
assert r.status_code == 200, r.text
assert len(_fake_telegram_client.calls) == 1
# ── формат сообщения ─────────────────────────────────────────────────────────
def test_message_format_json_roundtrip(client: TestClient, _fake_telegram_client: Any) -> None:
"""Sanity: убеждаемся, что тестовый payload реально валиден как JSON (не
полагаемся на literal dict без проверки сериализации)."""
body = json.dumps(_ISSUE_PAYLOAD)
r = client.post(
f"{_ENDPOINT}?secret={_SECRET}",
content=body.encode(),
headers={"content-type": "application/json"},
)
assert r.status_code == 200, r.text