feat(logging): добавить систему ротации логов

- Ежедневная ротация в 00:00 с архивацией в tar.gz
  - Разделение по уровням: info.log, warning.log, error.log
  - Отдельный payments.log для платежных операций
  - Отправка архивов в Telegram-канал бекапов
  - Автоочистка архивов старше 7 дней (настраивается)
  - Переключатель LOG_ROTATION_ENABLED (по умолчанию выключен)
This commit is contained in:
gy9vin
2025-12-27 19:02:28 +03:00
parent 3fc9e63653
commit d343a317ee
17 changed files with 704 additions and 44 deletions
+22
View File
@@ -541,6 +541,28 @@ VERSION_CHECK_INTERVAL_HOURS=1
LOG_LEVEL=INFO
LOG_FILE=logs/bot.log
# === Ротация логов ===
# Включить новую систему ротации (по умолчанию старое поведение)
LOG_ROTATION_ENABLED=false
# Время ротации (HH:MM)
LOG_ROTATION_TIME=00:00
# Хранить архивы N дней
LOG_ROTATION_KEEP_DAYS=7
# Сжимать архивы gzip
LOG_ROTATION_COMPRESS=true
# Отправлять архивы в Telegram-канал
LOG_ROTATION_SEND_TO_TELEGRAM=false
# Канал для логов (если не задан, используется BACKUP_SEND_CHAT_ID)
LOG_ROTATION_CHAT_ID=
# Топик в канале (если не задан, используется BACKUP_SEND_TOPIC_ID)
LOG_ROTATION_TOPIC_ID=
# Пути к лог-файлам (при LOG_ROTATION_ENABLED=true)
LOG_DIR=logs
LOG_INFO_FILE=info.log
LOG_WARNING_FILE=warning.log
LOG_ERROR_FILE=error.log
LOG_PAYMENTS_FILE=payments.log
# ===== РАЗРАБОТКА =====
DEBUG=false
WEBHOOK_URL=
+47 -1
View File
@@ -426,7 +426,23 @@ class Settings(BaseSettings):
LOG_LEVEL: str = "INFO"
LOG_FILE: str = "logs/bot.log"
# === Log Rotation Settings ===
LOG_ROTATION_ENABLED: bool = False # По умолчанию старое поведение
LOG_ROTATION_TIME: str = "00:00" # Время ротации (HH:MM)
LOG_ROTATION_KEEP_DAYS: int = 7 # Хранить архивы N дней
LOG_ROTATION_COMPRESS: bool = True # Сжимать архивы gzip
LOG_ROTATION_SEND_TO_TELEGRAM: bool = False # Отправлять в канал
LOG_ROTATION_CHAT_ID: Optional[str] = None # Канал для логов (или BACKUP_SEND_CHAT_ID)
LOG_ROTATION_TOPIC_ID: Optional[int] = None # Топик в канале
# Пути к лог-файлам (при LOG_ROTATION_ENABLED=true)
LOG_DIR: str = "logs"
LOG_INFO_FILE: str = "info.log"
LOG_WARNING_FILE: str = "warning.log"
LOG_ERROR_FILE: str = "error.log"
LOG_PAYMENTS_FILE: str = "payments.log"
DEBUG: bool = False
WEBHOOK_URL: Optional[str] = None
WEBHOOK_PATH: str = "/webhook"
@@ -1630,6 +1646,36 @@ class Settings(BaseSettings):
password = (self.BACKUP_ARCHIVE_PASSWORD or "").strip()
return password if password else None
# === Log Rotation Methods ===
def is_log_rotation_enabled(self) -> bool:
"""Проверить, включена ли новая система ротации логов."""
return self.LOG_ROTATION_ENABLED
def get_log_rotation_chat_id(self) -> Optional[int]:
"""Получить ID канала для отправки логов.
Если LOG_ROTATION_CHAT_ID не задан, использует BACKUP_SEND_CHAT_ID.
"""
chat_id = self.LOG_ROTATION_CHAT_ID or self.BACKUP_SEND_CHAT_ID
if not chat_id:
return None
try:
return int(chat_id)
except (ValueError, TypeError):
return None
def get_log_rotation_topic_id(self) -> Optional[int]:
"""Получить ID топика для отправки логов.
Если LOG_ROTATION_TOPIC_ID не задан, использует BACKUP_SEND_TOPIC_ID.
"""
topic_id = self.LOG_ROTATION_TOPIC_ID
if topic_id is not None:
return topic_id
return self.BACKUP_SEND_TOPIC_ID
def get_referral_settings(self) -> Dict:
return {
"program_enabled": self.is_referral_program_enabled(),
+351
View File
@@ -0,0 +1,351 @@
"""Сервис ротации логов с отправкой в 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()
+1 -3
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import json
import logging
from datetime import datetime
from importlib import import_module
from typing import Any, Dict, Optional
@@ -18,8 +17,7 @@ from app.services.subscription_auto_purchase_service import (
)
from app.services.cloudpayments_service import CloudPaymentsAPIError, CloudPaymentsService
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
from app.utils.payment_logger import payment_logger as logger
class CloudPaymentsPaymentMixin:
+1 -3
View File
@@ -7,7 +7,6 @@
from __future__ import annotations
import logging
from types import SimpleNamespace
from typing import Any
@@ -25,8 +24,7 @@ from app.services.subscription_checkout_service import (
)
from app.services.user_cart_service import user_cart_service
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
logger = logging.getLogger(__name__)
from app.utils.payment_logger import payment_logger as logger
class PaymentCommonMixin:
+2 -3
View File
@@ -1,7 +1,7 @@
"""Mixin с логикой обработки платежей CryptoBot."""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from datetime import datetime
@@ -28,8 +28,7 @@ from app.services.subscription_renewal_service import (
)
from app.utils.currency_converter import currency_converter
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
from app.utils.payment_logger import payment_logger as logger
renewal_service = SubscriptionRenewalService()
+1 -3
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import logging
import secrets
import time
from datetime import datetime, timezone, timedelta
@@ -14,8 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
from app.utils.payment_logger import payment_logger as logger
class HeleketPaymentMixin:
+1 -3
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import logging
import uuid
from importlib import import_module
from typing import Any, Dict, Optional
@@ -16,8 +15,7 @@ from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
from app.utils.payment_logger import payment_logger as logger
class MulenPayPaymentMixin:
+1 -3
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import logging
from datetime import datetime
from importlib import import_module
import uuid
@@ -18,8 +17,7 @@ from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
from app.utils.payment_logger import payment_logger as logger
class Pal24PaymentMixin:
+1 -3
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import logging
import uuid
from datetime import datetime
from importlib import import_module
@@ -18,8 +17,7 @@ from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
from app.utils.payment_logger import payment_logger as logger
class PlategaPaymentMixin:
+1 -3
View File
@@ -6,7 +6,6 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal, ROUND_FLOOR, ROUND_HALF_UP
@@ -25,8 +24,7 @@ from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
from app.utils.payment_logger import payment_logger as logger
@dataclass(slots=True)
+1 -3
View File
@@ -4,12 +4,10 @@ from __future__ import annotations
import hashlib
import hmac
import logging
from typing import Dict
from app.config import settings
logger = logging.getLogger(__name__)
from app.utils.payment_logger import payment_logger as logger
class TributePaymentMixin:
+1 -3
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import logging
import uuid
from datetime import datetime
from importlib import import_module
@@ -18,8 +17,7 @@ from app.services.subscription_auto_purchase_service import (
)
from app.services.wata_service import WataAPIError, WataService
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
from app.utils.payment_logger import payment_logger as logger
def _extract_transaction_id(payment: Any, remote_link: Optional[Dict[str, Any]] = None) -> Optional[str]:
+1 -3
View File
@@ -6,7 +6,6 @@
from __future__ import annotations
import logging
from datetime import datetime
from decimal import Decimal, InvalidOperation
from importlib import import_module
@@ -21,8 +20,7 @@ from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
from app.utils.payment_logger import payment_logger as logger
if TYPE_CHECKING:
from app.database.models import YooKassaPayment
+108
View File
@@ -0,0 +1,108 @@
"""Кастомные хэндлеры для системы логирования с ротацией.
Модуль предоставляет:
- LevelFilterHandler: фильтрация логов по диапазону уровней
- PaymentLogFilter: перехват логов из платежных модулей
- ExcludePaymentFilter: исключение платежей из основных логов
"""
from __future__ import annotations
import logging
from typing import Optional
class LevelFilterHandler(logging.Handler):
"""Хэндлер, фильтрующий логи по диапазону уровней.
Используется для разделения логов:
- info.log: только INFO (min_level=INFO, max_level=INFO)
- warning.log: WARNING и выше (min_level=WARNING)
- error.log: ERROR и CRITICAL (min_level=ERROR)
Args:
filename: Путь к файлу лога
min_level: Минимальный уровень логирования
max_level: Максимальный уровень (по умолчанию CRITICAL)
encoding: Кодировка файла
"""
def __init__(
self,
filename: str,
min_level: int,
max_level: Optional[int] = None,
encoding: str = "utf-8",
):
super().__init__(level=min_level)
self.min_level = min_level
self.max_level = max_level if max_level is not None else logging.CRITICAL
self._file_handler = logging.FileHandler(filename, encoding=encoding)
def emit(self, record: logging.LogRecord) -> None:
"""Записать лог только если уровень в заданном диапазоне."""
if self.min_level <= record.levelno <= self.max_level:
self._file_handler.emit(record)
def setFormatter(self, fmt: logging.Formatter) -> None:
"""Установить форматтер для внутреннего хэндлера."""
super().setFormatter(fmt)
self._file_handler.setFormatter(fmt)
def close(self) -> None:
"""Закрыть файловый хэндлер."""
self._file_handler.close()
super().close()
def flush(self) -> None:
"""Сбросить буфер файлового хэндлера."""
self._file_handler.flush()
class PaymentLogFilter(logging.Filter):
"""Фильтр для логов платежей.
Пропускает записи из модулей:
- app.services.payment.*
- app.payments (выделенный логгер)
- Связанные платежные сервисы
"""
PAYMENT_MODULES = (
"app.payments",
"app.services.payment",
"app.services.yookassa_service",
"app.services.tribute_service",
"app.services.mulenpay_service",
"app.services.cloudpayments_service",
"app.services.platega_service",
"app.services.pal24_service",
"app.services.wata_service",
"app.external.cryptobot",
"app.external.heleket",
"app.external.tribute",
"app.external.yookassa_webhook",
"app.external.pal24_webhook",
"app.external.wata_webhook",
"app.external.heleket_webhook",
)
def filter(self, record: logging.LogRecord) -> bool:
"""Пропустить только записи из платежных модулей."""
return any(record.name.startswith(module) for module in self.PAYMENT_MODULES)
class ExcludePaymentFilter(logging.Filter):
"""Исключает платежные логи из основных файлов.
Используется для bot.log, info.log, warning.log, error.log
чтобы платежные записи шли только в payments.log.
"""
PAYMENT_MODULES = PaymentLogFilter.PAYMENT_MODULES
def filter(self, record: logging.LogRecord) -> bool:
"""Пропустить записи НЕ из платежных модулей."""
return not any(
record.name.startswith(module) for module in self.PAYMENT_MODULES
)
+54
View File
@@ -0,0 +1,54 @@
"""Специальный логгер для платежей.
Выделенный логгер для всех платежных операций.
Записи идут в отдельный файл payments.log.
Использование:
from app.utils.payment_logger import payment_logger
payment_logger.info("Создан YooKassa платеж %s на %s", payment_id, amount)
payment_logger.error("Ошибка обработки webhook: %s", error)
"""
from __future__ import annotations
import logging
from typing import Optional
# Выделенный логгер для всех платежных операций
payment_logger = logging.getLogger("app.payments")
def configure_payment_logger(
handler: logging.Handler,
formatter: Optional[logging.Formatter] = None,
level: int = logging.INFO,
) -> None:
"""Настроить payment_logger с указанным хэндлером.
Args:
handler: Хэндлер для записи логов (FileHandler, StreamHandler и т.д.)
formatter: Форматтер для логов (опционально)
level: Уровень логирования (по умолчанию INFO)
"""
payment_logger.setLevel(level)
if formatter:
handler.setFormatter(formatter)
payment_logger.addHandler(handler)
# Предотвращаем дублирование в родительских логгерах
payment_logger.propagate = False
def get_payment_logger() -> logging.Logger:
"""Получить экземпляр payment_logger.
Альтернативный способ получения логгера для модулей,
которые предпочитают явный вызов функции.
Returns:
Настроенный логгер платежей
"""
return payment_logger
+110 -10
View File
@@ -37,6 +37,9 @@ from app.services.contest_rotation_service import contest_rotation_service
from app.services.nalogo_queue_service import nalogo_queue_service
from app.utils.startup_timeline import StartupTimeline
from app.utils.timezone import TimezoneAwareFormatter
from app.utils.log_handlers import LevelFilterHandler, ExcludePaymentFilter
from app.utils.payment_logger import payment_logger, configure_payment_logger
from app.services.log_rotation_service import log_rotation_service
class GracefulExit:
@@ -55,17 +58,85 @@ async def main():
timezone_name=settings.TIMEZONE,
)
file_handler = logging.FileHandler(settings.LOG_FILE, encoding='utf-8')
file_handler.setFormatter(formatter)
log_handlers = []
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setFormatter(formatter)
# === Инициализация системы логирования ===
if settings.is_log_rotation_enabled():
# Новая система: разделение по уровням + отдельный лог платежей
await log_rotation_service.initialize()
log_dir = log_rotation_service.current_dir
log_dir.mkdir(parents=True, exist_ok=True)
# 1. Общий лог (bot.log) - все уровни, без платежей
bot_handler = logging.FileHandler(log_dir / "bot.log", encoding='utf-8')
bot_handler.setFormatter(formatter)
bot_handler.addFilter(ExcludePaymentFilter())
log_handlers.append(bot_handler)
# 2. INFO лог - только INFO уровень
info_handler = LevelFilterHandler(
str(log_dir / settings.LOG_INFO_FILE),
min_level=logging.INFO,
max_level=logging.INFO,
)
info_handler.setFormatter(formatter)
info_handler.addFilter(ExcludePaymentFilter())
log_handlers.append(info_handler)
# 3. WARNING лог - WARNING и выше
warning_handler = LevelFilterHandler(
str(log_dir / settings.LOG_WARNING_FILE),
min_level=logging.WARNING,
)
warning_handler.setFormatter(formatter)
warning_handler.addFilter(ExcludePaymentFilter())
log_handlers.append(warning_handler)
# 4. ERROR лог - только ERROR и CRITICAL
error_handler = LevelFilterHandler(
str(log_dir / settings.LOG_ERROR_FILE),
min_level=logging.ERROR,
)
error_handler.setFormatter(formatter)
error_handler.addFilter(ExcludePaymentFilter())
log_handlers.append(error_handler)
# 5. Payment лог - отдельный файл для платежей
payment_handler = logging.FileHandler(
log_dir / settings.LOG_PAYMENTS_FILE,
encoding='utf-8',
)
configure_payment_logger(payment_handler, formatter)
# 6. Консольный вывод
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setFormatter(formatter)
log_handlers.append(stream_handler)
logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL),
handlers=log_handlers,
)
# Регистрируем хэндлеры для управления при ротации
log_rotation_service.register_handlers(log_handlers)
else:
# Старое поведение: один файл лога
file_handler = logging.FileHandler(settings.LOG_FILE, encoding='utf-8')
file_handler.setFormatter(formatter)
log_handlers.append(file_handler)
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setFormatter(formatter)
log_handlers.append(stream_handler)
logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL),
handlers=log_handlers,
)
logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL),
handlers=[file_handler, stream_handler],
)
# Установим более высокий уровень логирования для "мусорных" логов
logging.getLogger("aiohttp.access").setLevel(logging.ERROR)
logging.getLogger("aiohttp.client").setLevel(logging.WARNING)
@@ -74,7 +145,7 @@ async def main():
logging.getLogger("aiogram").setLevel(logging.WARNING)
logging.getLogger("uvicorn.access").setLevel(logging.ERROR)
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
timeline = StartupTimeline(logger, "Bedolaga Remnawave Bot")
timeline.log_banner(
@@ -246,6 +317,28 @@ async def main():
stage.warning(f"Ошибка запуска ротации игр: {e}")
logger.error(f"❌ Ошибка запуска ротации игр: {e}")
if settings.is_log_rotation_enabled():
async with timeline.stage(
"Ротация логов",
"📋",
success_message="Сервис ротации логов готов",
) as stage:
try:
log_rotation_service.set_bot(bot)
await log_rotation_service.start()
status = log_rotation_service.get_status()
stage.log(f"Время ротации: {status.rotation_time}")
stage.log(f"Хранение архивов: {status.keep_days} дней")
if status.send_to_telegram:
stage.log("Отправка в Telegram: включена")
if status.next_rotation:
from datetime import datetime
next_dt = datetime.fromisoformat(status.next_rotation)
stage.log(f"Следующая ротация: {next_dt.strftime('%d.%m.%Y %H:%M')}")
except Exception as e:
stage.warning(f"Ошибка запуска сервиса ротации логов: {e}")
logger.error(f"❌ Ошибка запуска сервиса ротации логов: {e}")
async with timeline.stage(
"Автосинхронизация RemnaWave",
"🔄",
@@ -673,6 +766,13 @@ async def main():
except Exception as e:
logger.error(f"Ошибка остановки ротации игр: {e}")
if settings.is_log_rotation_enabled():
logger.info("ℹ️ Остановка сервиса ротации логов...")
try:
await log_rotation_service.stop()
except Exception as e:
logger.error(f"Ошибка остановки сервиса ротации логов: {e}")
logger.info("ℹ️ Остановка очереди чеков NaloGO...")
try:
await nalogo_queue_service.stop()