Files
remnawave-bedolaga-telegram…/app/logging_handler.py
2026-02-04 04:47:39 +03:00

232 lines
9.8 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.

"""Кастомный logging handler для отправки ERROR/CRITICAL в админский чат Telegram.
Перехватывает все log records уровня ERROR и CRITICAL и отправляет их
в админский чат через существующий механизм send_error_to_admin_chat()
из app.middlewares.global_error.
Дедупликация:
- Записи, уже обработанные GlobalErrorMiddleware или @error_handler,
помечаются атрибутом _admin_notified = True и пропускаются.
- Хеши недавних сообщений хранятся в LRU-кеше для предотвращения
дублирования одинаковых ошибок за короткий период.
Async bridge:
- logging.Handler.emit() -- синхронный. Мы используем
asyncio.get_running_loop().call_soon_threadsafe() для планирования
asyncio.Task из любого потока (sync или async).
Deferred init:
- Bot instance создаётся позже в main.py. Метод set_bot() позволяет
передать его после создания. До этого записи молча пропускаются.
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
import time
from typing import Final
from aiogram import Bot
# Константы
RECENT_HASHES_MAX_SIZE: Final[int] = 256
RECENT_HASH_TTL_SECONDS: Final[float] = 300.0 # 5 минут -- совпадает с cooldown в global_error
# Логгеры, от которых мы гарантированно не хотим получать уведомления,
# даже если они вдруг выдадут ERROR (шум от транспортного уровня).
IGNORED_LOGGER_PREFIXES: Final[tuple[str, ...]] = (
'aiohttp.access',
'aiohttp.client',
'aiohttp.internal',
'uvicorn.access',
'uvicorn.error',
'uvicorn.protocols',
'websockets',
'asyncio',
)
class TelegramErrorHandler(logging.Handler):
"""Logging handler, отправляющий ERROR/CRITICAL записи в админский Telegram-чат.
Использует существующий механизм троттлинга и буферизации из
``app.middlewares.global_error.send_error_to_admin_chat``.
Usage::
handler = TelegramErrorHandler()
handler.setLevel(logging.ERROR)
logging.getLogger().addHandler(handler)
# Позже, когда Bot создан:
handler.set_bot(bot)
"""
def __init__(self, level: int = logging.ERROR) -> None:
super().__init__(level=level)
self._bot: Bot | None = None
# LRU-подобный кеш хешей недавних сообщений: hash -> timestamp
self._recent_hashes: dict[str, float] = {}
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def set_bot(self, bot: Bot) -> None:
"""Устанавливает Bot instance для отправки сообщений.
Вызывается из main.py после создания бота.
"""
self._bot = bot
# ------------------------------------------------------------------
# logging.Handler interface
# ------------------------------------------------------------------
def emit(self, record: logging.LogRecord) -> None:
"""Обрабатывает log record.
Синхронный метод (требование logging). Планирует async-отправку
через event loop.
"""
# 1. Фильтр по уровню (на случай если кто-то обойдёт setLevel)
if record.levelno < logging.ERROR:
return
# 2. Уже отправлено через GlobalErrorMiddleware / @error_handler
if getattr(record, '_admin_notified', False):
return
# 3. Фильтруем шумные логгеры
if any(record.name.startswith(prefix) for prefix in IGNORED_LOGGER_PREFIXES):
return
# 4. Бот ещё не инициализирован -- пропускаем
bot = self._bot
if bot is None:
return
# 5. Дедупликация по хешу (logger_name + message)
msg_hash = self._compute_hash(record)
now = time.monotonic()
# Чистим просроченные записи (ленивая очистка)
self._evict_stale(now)
if msg_hash in self._recent_hashes:
return
self._recent_hashes[msg_hash] = now
# 6. Планируем отправку через event loop
self._schedule_send(bot, record)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
@staticmethod
def _compute_hash(record: logging.LogRecord) -> str:
"""Вычисляет короткий хеш для дедупликации.
Хешируем имя логгера + сообщение (без timestamp).
"""
raw = f'{record.name}:{record.getMessage()}'
return hashlib.md5(raw.encode('utf-8', errors='replace')).hexdigest()
def _evict_stale(self, now: float) -> None:
"""Удаляет устаревшие записи из кеша хешей."""
if not self._recent_hashes:
return
stale_keys = [k for k, ts in self._recent_hashes.items() if (now - ts) > RECENT_HASH_TTL_SECONDS]
for k in stale_keys:
self._recent_hashes.pop(k, None)
# Принудительная очистка при переполнении — удаляем самые старые
if len(self._recent_hashes) > RECENT_HASHES_MAX_SIZE:
sorted_keys = sorted(self._recent_hashes, key=self._recent_hashes.get)
for k in sorted_keys[: len(self._recent_hashes) - RECENT_HASHES_MAX_SIZE]:
self._recent_hashes.pop(k, None)
def _schedule_send(self, bot: Bot, record: logging.LogRecord) -> None:
"""Планирует асинхронную отправку в event loop.
Работает из любого потока:
- Если вызов из async-контекста -- создаём Task напрямую.
- Если из другого потока -- используем call_soon_threadsafe.
"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# Нет running loop -- мы в стороннем потоке без loop.
# Пытаемся получить loop, привязанный к основному потоку.
try:
loop = asyncio.get_event_loop()
if loop.is_closed():
return
loop.call_soon_threadsafe(self._create_send_task, bot, record, loop)
except RuntimeError:
return
else:
# Мы в async-контексте -- создаём task напрямую
self._create_send_task(bot, record, loop)
def _create_send_task(self, bot: Bot, record: logging.LogRecord, loop: asyncio.AbstractEventLoop) -> None:
"""Создаёт asyncio.Task для отправки уведомления."""
loop.create_task(self._send(bot, record))
@staticmethod
async def _send(bot: Bot, record: logging.LogRecord) -> None:
"""Отправляет log record в админский чат через существующую инфраструктуру."""
try:
# Ленивый импорт -- избегаем циклических зависимостей при старте
from app.middlewares.global_error import send_error_to_admin_chat
# Формируем pseudo-Exception из log record
error = _make_log_record_error(record)
context_parts: list[str] = [f'Logger: {record.name}']
if record.funcName:
context_parts.append(f'Function: {record.funcName}')
if record.pathname and record.lineno:
context_parts.append(f'Location: {record.pathname}:{record.lineno}')
context = '\n'.join(context_parts)
# Извлекаем traceback из log record (если есть exc_info)
tb_override: str | None = None
if record.exc_info and record.exc_info[2] is not None:
import traceback
tb_override = ''.join(traceback.format_exception(*record.exc_info))
elif record.exc_text:
tb_override = record.exc_text
await send_error_to_admin_chat(bot, error, context, tb_override=tb_override)
except Exception:
# Ни в коем случае не даём исключению утечь -- это logging handler,
# рекурсия убьёт приложение.
pass
def _make_log_record_error(record: logging.LogRecord) -> Exception:
"""Создаёт Exception-обёртку для LogRecord.
send_error_to_admin_chat использует type(error).__name__ как error_type.
Мы динамически создаём класс с правильным именем, чтобы не мутировать
общий класс между вызовами.
"""
class_name = f'Log{record.levelname.capitalize()}'
error_cls = type(
class_name,
(Exception,),
{
'__str__': lambda self: self.args[0] if self.args else '',
},
)
error = error_cls(record.getMessage())
error.record = record # type: ignore[attr-defined]
return error