Уведомления об ошибках бэкенда
Главное правило боевого бэкенда: необработанная ошибка должна стучаться в дверь. Ниже — компактные обёртки и middleware для трёх популярных стеков.
Общие правила
Заголовок раздела «Общие правила»- Не блокируйте запрос отправкой уведомления. Делайте
await-в-фоне или fire-and-forget. - Тротлинг. Если бэкенд получит 1000 одинаковых ошибок в минуту — не превращайте Notifly в спам. Минимальная защита: отправлять не чаще одной нотификации за N секунд по одинаковому ключу (хеш стека).
- Не отправляйте секреты. Чистите тело запроса от паролей и токенов.
Node.js (Express)
Заголовок раздела «Node.js (Express)»const fetch = require('node-fetch');
const seen = new Map();const COOLDOWN_MS = 60_000;
async function notify(title, message, priority = 7) { const key = title + message.slice(0, 200); if (Date.now() - (seen.get(key) || 0) < COOLDOWN_MS) return; seen.set(key, Date.now());
try { await fetch(`${process.env.NOTIFLY_URL}/message?token=${process.env.NOTIFLY_TOKEN}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({title, message, priority}), timeout: 5000, }); } catch (e) { console.error('notifly failed:', e.message); }}
module.exports = {notify};Подключаем в Express:
const express = require('express');const {notify} = require('./notifly');const app = express();
// ваши роуты ...
// последний middleware — ловим все ошибкиapp.use((err, req, res, next) => { notify( `🐛 ${req.method} ${req.path} — ${err.name}`, `${err.message}\n\n${err.stack?.slice(0, 1500)}`, 8 ); res.status(500).json({error: 'internal'});});
// ловим то, что упало вне контекста запросаprocess.on('unhandledRejection', (reason) => { notify('💥 unhandledRejection', String(reason).slice(0, 1500), 9);});process.on('uncaughtException', (err) => { notify('💥 uncaughtException', `${err.message}\n${err.stack}`, 10);});Python (FastAPI)
Заголовок раздела «Python (FastAPI)»import os, time, httpxfrom collections import OrderedDict
_COOLDOWN = 60_seen: "OrderedDict[str, float]" = OrderedDict()
def notify(title: str, message: str, priority: int = 7) -> None: key = title + message[:200] now = time.time() if now - _seen.get(key, 0) < _COOLDOWN: return _seen[key] = now if len(_seen) > 200: _seen.popitem(last=False) try: httpx.post( f"{os.environ['NOTIFLY_URL']}/message", params={"token": os.environ["NOTIFLY_TOKEN"]}, json={"title": title, "message": message, "priority": priority}, timeout=5, ) except Exception as e: print(f"notifly failed: {e}")FastAPI exception handler:
from fastapi import FastAPI, Requestfrom fastapi.responses import JSONResponseimport tracebackfrom notifly import notify
app = FastAPI()
@app.exception_handler(Exception)async def all_exceptions(request: Request, exc: Exception) -> JSONResponse: notify( title=f"🐛 {request.method} {request.url.path} — {type(exc).__name__}", message=f"{exc}\n\n{traceback.format_exc()[-1500:]}", priority=8, ) return JSONResponse({"error": "internal"}, status_code=500)Go (net/http)
Заголовок раздела «Go (net/http)»package notifly
import ( "bytes" "encoding/json" "net/http" "os" "sync" "time")
var ( seen = map[string]time.Time{} seenLock sync.Mutex cooldown = 60 * time.Second client = &http.Client{Timeout: 5 * time.Second})
func Notify(title, message string, priority int) { key := title if len(message) > 200 { key += message[:200] } else { key += message } seenLock.Lock() if time.Since(seen[key]) < cooldown { seenLock.Unlock() return } seen[key] = time.Now() seenLock.Unlock()
body, _ := json.Marshal(map[string]any{ "title": title, "message": message, "priority": priority, }) url := os.Getenv("NOTIFLY_URL") + "/message?token=" + os.Getenv("NOTIFLY_TOKEN") go func() { resp, err := client.Post(url, "application/json", bytes.NewReader(body)) if err == nil { resp.Body.Close() } }()}Middleware для net/http:
import ( "fmt" "net/http" "runtime/debug")
func Recovery(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if rec := recover(); rec != nil { stack := string(debug.Stack()) if len(stack) > 1500 { stack = stack[:1500] } Notify( fmt.Sprintf("🐛 %s %s — panic", r.Method, r.URL.Path), fmt.Sprintf("%v\n\n%s", rec, stack), 9, ) http.Error(w, "internal", 500) } }() next.ServeHTTP(w, r) })}Что присылать кроме ошибок
Заголовок раздела «Что присылать кроме ошибок»- Старт сервиса. «🚀 backend v1.42 запущен на host-A» — сразу видно деплой.
- Падение здоровья БД (timeout > N мс) — отдельный приоритет.
- Подозрительные события безопасности. Например, попытка авторизации с неизвестного User-Agent для админ-аккаунта.
- Бизнес-события. Первый платёж от нового клиента, превышение квоты.
- Знание о 500-х за минуту, а не за час по жалобе пользователя.
- Контекст в кармане. Метод, путь, краткий стектрейс — обычно этого достаточно, чтобы открыть IDE и пойти чинить.
- Тротлинг встроен — никаких 10 000 уведомлений из-за разорванного pool-а БД.