mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
- Ежедневная ротация в 00:00 с архивацией в tar.gz - Разделение по уровням: info.log, warning.log, error.log - Отдельный payments.log для платежных операций - Отправка архивов в Telegram-канал бекапов - Автоочистка архивов старше 7 дней (настраивается) - Переключатель LOG_ROTATION_ENABLED (по умолчанию выключен)
352 lines
13 KiB
Python
352 lines
13 KiB
Python
"""Сервис ротации логов с отправкой в Telegram.
|
||
|
||
Функционал:
|
||
- Ежедневная ротация в настроенное время (по умолчанию 00:00)
|
||
- Разделение по уровням: info.log, warning.log, error.log
|
||
- Отдельный лог платежей: payments.log
|
||
- Архивирование всех логов за день в один tar.gz
|
||
- Отправка архива в Telegram-канал
|
||
- Очистка архивов старше N дней
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import tarfile
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
from typing import Dict, List, Optional, Tuple
|
||
|
||
from aiogram import Bot
|
||
from aiogram.types import FSInputFile
|
||
|
||
from app.config import settings
|
||
from app.utils.timezone import get_local_timezone
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@dataclass
|
||
class LogRotationStatus:
|
||
"""Статус сервиса ротации логов."""
|
||
|
||
enabled: bool
|
||
running: bool
|
||
rotation_time: str
|
||
keep_days: int
|
||
send_to_telegram: bool
|
||
next_rotation: Optional[str]
|
||
log_dir: str
|
||
archive_count: int
|
||
|
||
|
||
class LogRotationService:
|
||
"""Сервис ежедневной ротации и архивации логов."""
|
||
|
||
def __init__(self, bot: Optional[Bot] = None):
|
||
self.bot = bot
|
||
self._rotation_task: Optional[asyncio.Task] = None
|
||
self._running = False
|
||
self._handlers: List[logging.Handler] = []
|
||
|
||
# Пути
|
||
self.log_dir = Path(settings.LOG_DIR).resolve()
|
||
self.current_dir = self.log_dir / "current"
|
||
self.archive_dir = self.log_dir / "archive"
|
||
|
||
@property
|
||
def log_files(self) -> Dict[str, Path]:
|
||
"""Пути к текущим лог-файлам."""
|
||
return {
|
||
"bot": self.current_dir / "bot.log",
|
||
"info": self.current_dir / settings.LOG_INFO_FILE,
|
||
"warning": self.current_dir / settings.LOG_WARNING_FILE,
|
||
"error": self.current_dir / settings.LOG_ERROR_FILE,
|
||
"payments": self.current_dir / settings.LOG_PAYMENTS_FILE,
|
||
}
|
||
|
||
def set_bot(self, bot: Bot) -> None:
|
||
"""Установить экземпляр бота для отправки логов."""
|
||
self.bot = bot
|
||
|
||
def register_handlers(self, handlers: List[logging.Handler]) -> None:
|
||
"""Зарегистрировать хэндлеры для управления при ротации."""
|
||
self._handlers = handlers
|
||
|
||
async def initialize(self) -> None:
|
||
"""Создать необходимые директории."""
|
||
self.current_dir.mkdir(parents=True, exist_ok=True)
|
||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
async def start(self) -> None:
|
||
"""Запустить сервис ротации."""
|
||
if self._running:
|
||
return
|
||
|
||
self._running = True
|
||
self._rotation_task = asyncio.create_task(self._rotation_loop())
|
||
logger.info("Сервис ротации логов запущен")
|
||
|
||
async def stop(self) -> None:
|
||
"""Остановить сервис ротации."""
|
||
self._running = False
|
||
if self._rotation_task and not self._rotation_task.done():
|
||
self._rotation_task.cancel()
|
||
try:
|
||
await self._rotation_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
logger.info("Сервис ротации логов остановлен")
|
||
|
||
def is_running(self) -> bool:
|
||
"""Проверить, запущен ли сервис."""
|
||
return self._running
|
||
|
||
async def _rotation_loop(self) -> None:
|
||
"""Основной цикл ожидания времени ротации."""
|
||
while self._running:
|
||
next_rotation = self._calculate_next_rotation_time()
|
||
now = datetime.now(get_local_timezone())
|
||
wait_seconds = (next_rotation - now).total_seconds()
|
||
|
||
if wait_seconds > 0:
|
||
logger.info(
|
||
"Следующая ротация логов: %s (через %.1f часов)",
|
||
next_rotation.strftime("%Y-%m-%d %H:%M"),
|
||
wait_seconds / 3600,
|
||
)
|
||
try:
|
||
await asyncio.sleep(wait_seconds)
|
||
except asyncio.CancelledError:
|
||
break
|
||
|
||
if self._running:
|
||
await self.rotate_logs()
|
||
|
||
def _calculate_next_rotation_time(self) -> datetime:
|
||
"""Вычислить время следующей ротации."""
|
||
now = datetime.now(get_local_timezone())
|
||
|
||
# Парсим время ротации
|
||
time_str = settings.LOG_ROTATION_TIME
|
||
try:
|
||
hours, minutes = map(int, time_str.split(":"))
|
||
except ValueError:
|
||
hours, minutes = 0, 0
|
||
logger.warning(
|
||
"Некорректное LOG_ROTATION_TIME='%s', используем 00:00", time_str
|
||
)
|
||
|
||
next_rotation = now.replace(hour=hours, minute=minutes, second=0, microsecond=0)
|
||
|
||
# Если время уже прошло сегодня, ротация завтра
|
||
if next_rotation <= now:
|
||
next_rotation += timedelta(days=1)
|
||
|
||
return next_rotation
|
||
|
||
async def rotate_logs(self) -> Tuple[bool, str]:
|
||
"""Выполнить ротацию логов.
|
||
|
||
Создаёт один архив logs_YYYY-MM-DD.tar.gz со всеми лог-файлами за день.
|
||
|
||
Returns:
|
||
Tuple[bool, str]: (успех, сообщение)
|
||
"""
|
||
try:
|
||
logger.info("Начинаем ротацию логов...")
|
||
|
||
# Дата для архива (вчера, т.к. логи были за предыдущие сутки)
|
||
yesterday = (
|
||
datetime.now(get_local_timezone()) - timedelta(days=1)
|
||
).strftime("%Y-%m-%d")
|
||
|
||
# Сбрасываем буферы хэндлеров перед архивацией
|
||
for handler in self._handlers:
|
||
try:
|
||
handler.flush()
|
||
except Exception:
|
||
pass
|
||
|
||
# Собираем файлы для архивации
|
||
files_to_archive: List[Tuple[Path, str]] = []
|
||
for name, log_path in self.log_files.items():
|
||
if log_path.exists() and log_path.stat().st_size > 0:
|
||
files_to_archive.append((log_path, f"{name}.log"))
|
||
|
||
if not files_to_archive:
|
||
message = "Нет логов для архивации"
|
||
logger.info(message)
|
||
return True, message
|
||
|
||
# Создаём один архив со всеми логами
|
||
archive_path = await self._create_archive(files_to_archive, yesterday)
|
||
|
||
if archive_path:
|
||
# Очищаем текущие лог-файлы
|
||
for log_path, _ in files_to_archive:
|
||
log_path.write_text("")
|
||
|
||
# Очистка старых архивов
|
||
await self._cleanup_old_archives()
|
||
|
||
# Отправка в Telegram
|
||
if settings.LOG_ROTATION_SEND_TO_TELEGRAM and self.bot:
|
||
await self._send_logs_to_telegram(archive_path, yesterday)
|
||
|
||
message = f"Ротация логов завершена. Архив: {archive_path.name}"
|
||
logger.info(message)
|
||
return True, message
|
||
else:
|
||
message = "Ошибка создания архива логов"
|
||
logger.error(message)
|
||
return False, message
|
||
|
||
except Exception as error:
|
||
message = f"Ошибка ротации логов: {error}"
|
||
logger.error(message, exc_info=True)
|
||
return False, message
|
||
|
||
async def _create_archive(
|
||
self,
|
||
files: List[Tuple[Path, str]],
|
||
date_str: str,
|
||
) -> Optional[Path]:
|
||
"""Создать архив со всеми логами за день.
|
||
|
||
Args:
|
||
files: список (путь к файлу, имя в архиве)
|
||
date_str: дата в формате YYYY-MM-DD
|
||
|
||
Returns:
|
||
Путь к созданному архиву или None при ошибке
|
||
"""
|
||
try:
|
||
if settings.LOG_ROTATION_COMPRESS:
|
||
archive_name = f"logs_{date_str}.tar.gz"
|
||
mode = "w:gz"
|
||
else:
|
||
archive_name = f"logs_{date_str}.tar"
|
||
mode = "w"
|
||
|
||
archive_path = self.archive_dir / archive_name
|
||
|
||
def _create_tar():
|
||
with tarfile.open(archive_path, mode) as tar:
|
||
for file_path, arcname in files:
|
||
tar.add(file_path, arcname=arcname)
|
||
|
||
await asyncio.to_thread(_create_tar)
|
||
logger.debug("Создан архив: %s", archive_path)
|
||
return archive_path
|
||
|
||
except Exception as error:
|
||
logger.error("Ошибка создания архива: %s", error)
|
||
return None
|
||
|
||
async def _cleanup_old_archives(self) -> None:
|
||
"""Удалить архивы старше LOG_ROTATION_KEEP_DAYS."""
|
||
keep_days = settings.LOG_ROTATION_KEEP_DAYS
|
||
cutoff_date = datetime.now(get_local_timezone()) - timedelta(days=keep_days)
|
||
|
||
if not self.archive_dir.exists():
|
||
return
|
||
|
||
# Ищем файлы вида logs_YYYY-MM-DD.tar.gz или logs_YYYY-MM-DD.tar
|
||
for archive_file in self.archive_dir.iterdir():
|
||
if not archive_file.is_file():
|
||
continue
|
||
|
||
# Извлекаем дату из имени файла logs_YYYY-MM-DD.tar.gz
|
||
name = archive_file.name
|
||
if not name.startswith("logs_"):
|
||
continue
|
||
|
||
try:
|
||
# logs_2025-01-26.tar.gz -> 2025-01-26
|
||
date_part = name.replace("logs_", "").replace(".tar.gz", "").replace(".tar", "")
|
||
file_date = datetime.strptime(date_part, "%Y-%m-%d")
|
||
file_date = file_date.replace(tzinfo=get_local_timezone())
|
||
|
||
if file_date < cutoff_date:
|
||
archive_file.unlink()
|
||
logger.info("Удален старый архив логов: %s", archive_file.name)
|
||
except ValueError:
|
||
# Пропускаем файлы с некорректным форматом имени
|
||
pass
|
||
|
||
async def _send_logs_to_telegram(
|
||
self,
|
||
archive_path: Path,
|
||
date_str: str,
|
||
) -> None:
|
||
"""Отправить архив логов в Telegram."""
|
||
chat_id = settings.get_log_rotation_chat_id()
|
||
if not chat_id:
|
||
logger.warning("LOG_ROTATION_CHAT_ID не задан, пропускаем отправку")
|
||
return
|
||
|
||
topic_id = settings.get_log_rotation_topic_id()
|
||
|
||
try:
|
||
file_size_kb = archive_path.stat().st_size / 1024
|
||
caption = (
|
||
f"<b>Логи бота</b>\n"
|
||
f"Дата: {date_str}\n"
|
||
f"Файл: <code>{archive_path.name}</code>\n"
|
||
f"Размер: {file_size_kb:.1f} KB"
|
||
)
|
||
|
||
send_kwargs = {
|
||
"chat_id": chat_id,
|
||
"document": FSInputFile(archive_path),
|
||
"caption": caption,
|
||
"parse_mode": "HTML",
|
||
}
|
||
|
||
if topic_id:
|
||
send_kwargs["message_thread_id"] = topic_id
|
||
|
||
await self.bot.send_document(**send_kwargs)
|
||
logger.info("Архив логов отправлен: %s", archive_path.name)
|
||
|
||
except Exception as error:
|
||
logger.error("Ошибка отправки архива %s: %s", archive_path.name, error)
|
||
|
||
# === Ручные операции ===
|
||
|
||
async def force_rotate(self) -> Tuple[bool, str]:
|
||
"""Принудительная ротация (для админ-команды)."""
|
||
return await self.rotate_logs()
|
||
|
||
def get_status(self) -> LogRotationStatus:
|
||
"""Получить статус сервиса."""
|
||
archive_count = 0
|
||
if self.archive_dir.exists():
|
||
# Считаем файлы logs_*.tar.gz или logs_*.tar
|
||
archive_count = len(
|
||
[f for f in self.archive_dir.iterdir()
|
||
if f.is_file() and f.name.startswith("logs_")]
|
||
)
|
||
|
||
next_rotation = None
|
||
if self._running:
|
||
next_rotation = self._calculate_next_rotation_time().isoformat()
|
||
|
||
return LogRotationStatus(
|
||
enabled=settings.is_log_rotation_enabled(),
|
||
running=self._running,
|
||
rotation_time=settings.LOG_ROTATION_TIME,
|
||
keep_days=settings.LOG_ROTATION_KEEP_DAYS,
|
||
send_to_telegram=settings.LOG_ROTATION_SEND_TO_TELEGRAM,
|
||
next_rotation=next_rotation,
|
||
log_dir=str(self.log_dir),
|
||
archive_count=archive_count,
|
||
)
|
||
|
||
|
||
# Глобальный экземпляр сервиса
|
||
log_rotation_service = LogRotationService()
|