Files
remnawave-bedolaga-telegram…/app/services/log_rotation_service.py
gy9vin d343a317ee feat(logging): добавить систему ротации логов
- Ежедневная ротация в 00:00 с архивацией в tar.gz
  - Разделение по уровням: info.log, warning.log, error.log
  - Отдельный payments.log для платежных операций
  - Отправка архивов в Telegram-канал бекапов
  - Автоочистка архивов старше 7 дней (настраивается)
  - Переключатель LOG_ROTATION_ENABLED (по умолчанию выключен)
2025-12-27 19:02:28 +03:00

352 lines
13 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.

"""Сервис ротации логов с отправкой в 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()