Files
remnawave-bedolaga-telegram…/app/middlewares/button_stats.py
T
Fringg 1f0fef114b refactor: complete structlog migration with contextvars, kwargs, and logging hardening
- Add ContextVarsMiddleware for automatic user_id/chat_id/username binding
  via structlog contextvars (aiogram) and http_method/http_path (FastAPI)
- Use bound_contextvars() context manager instead of clear_contextvars()
  to safely restore previous state instead of wiping all context
- Register ContextVarsMiddleware as outermost middleware (before GlobalError)
  so all error logs include user context
- Replace structlog.get_logger() with structlog.get_logger(__name__) across
  270 calls in 265 files for meaningful logger names
- Switch wrapper_class from BoundLogger to make_filtering_bound_logger()
  for pre-processor level filtering (performance optimization)
- Migrate 1411 %-style positional arg logger calls to structlog kwargs
  style across 161 files via AST script
- Migrate log_rotation_service.py from stdlib logging to structlog
- Add payment module prefixes to TelegramNotifierProcessor.IGNORED_LOGGER_PREFIXES
  and ExcludePaymentFilter.PAYMENT_MODULES to prevent payment data leaking
  to Telegram notifications and general log files
- Fix LoggingMiddleware: add from_user null-safety for channel posts,
  switch time.time() to time.monotonic() for duration measurement
- Remove duplicate logger assignments in purchase.py, config.py,
  inline.py, and admin/payments.py
2026-02-16 09:18:12 +03:00

210 lines
7.3 KiB
Python

"""Middleware для автоматического логирования кликов по кнопкам."""
import asyncio
from collections.abc import Awaitable, Callable
from typing import Any
import structlog
from aiogram import BaseMiddleware
from aiogram.types import CallbackQuery, TelegramObject
from app.config import settings
from app.database.database import AsyncSessionLocal
logger = structlog.get_logger(__name__)
# Известные builtin callback_data из меню
BUILTIN_CALLBACKS: set[str] = {
# Основные кнопки меню
'subscription_connect',
'subscription_happ_download',
'menu_subscription',
'buy_traffic',
'menu_balance',
'menu_trial',
'menu_buy',
'simple_subscription_purchase',
'return_to_saved_cart',
'menu_promocode',
'menu_referrals',
'contests_menu',
'menu_support',
'menu_info',
'menu_language',
'admin_panel',
'moderator_panel',
# Навигация
'back_to_menu',
'menu_faq',
'menu_info_promo_groups',
'menu_privacy_policy',
'menu_public_offer',
'menu_rules',
'menu_server_status',
# Баланс
'balance_history',
'balance_topup',
# Подписка
'subscription_extend',
'subscription_autopay',
'subscription_settings',
'open_subscription_link',
'subscription_add_countries',
'subscription_reset_traffic',
'subscription_switch_traffic',
'subscription_change_devices',
'subscription_manage_devices',
'subscription_upgrade',
# Устройства
'device_guide_ios',
'device_guide_android',
'device_guide_windows',
'device_guide_mac',
'device_guide_tv',
'device_guide_appletv',
# Happ
'happ_download_ios',
'happ_download_android',
'happ_download_macos',
'happ_download_windows',
# Рефералы
'referral_create_invite',
'referral_show_qr',
'referral_list',
'referral_analytics',
# Поддержка
'create_ticket',
'my_tickets',
# Триал
'trial_activate',
# Покупка
'clear_saved_cart',
'subscription_confirm',
'subscription_cancel',
}
class ButtonStatsMiddleware(BaseMiddleware):
"""Middleware для автоматического логирования статистики кликов по кнопкам."""
async def __call__(
self,
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: dict[str, Any],
) -> Any:
"""Перехватывает CallbackQuery и логирует клики по кнопкам."""
# Обрабатываем только CallbackQuery
if not isinstance(event, CallbackQuery):
return await handler(event, data)
# Пропускаем, если статистика отключена
if not settings.MENU_LAYOUT_ENABLED:
return await handler(event, data)
# Логируем клик асинхронно, не блокируя обработку
try:
# Получаем callback_data
callback_data = event.data
if not callback_data:
return await handler(event, data)
# Получаем user_id
user_id = event.from_user.id if event.from_user else None
# Определяем тип кнопки по callback_data
button_type = self._determine_button_type(callback_data)
# Получаем текст кнопки, если возможно
button_text = None
if event.message and hasattr(event.message, 'reply_markup'):
button_text = self._extract_button_text(event.message.reply_markup, callback_data)
# Логируем в фоне, не блокируя обработку
asyncio.create_task(
self._log_button_click_async(
button_id=callback_data,
user_id=user_id,
callback_data=callback_data,
button_type=button_type,
button_text=button_text,
)
)
except Exception as e:
# Не прерываем обработку при ошибке логирования
logger.error('Ошибка логирования клика по кнопке', error=e, exc_info=True)
# Продолжаем обработку
return await handler(event, data)
def _determine_button_type(self, callback_data: str) -> str:
"""Определяет тип кнопки по callback_data.
Примечание: URL и MiniApp кнопки не имеют callback_data,
поэтому они не отслеживаются через этот middleware.
Для их отслеживания нужен отдельный механизм на стороне клиента.
"""
# Проверяем по известному списку builtin кнопок
if callback_data in BUILTIN_CALLBACKS:
return 'builtin'
# Дополнительная проверка по префиксам для динамических callback_data
builtin_prefixes = (
'menu_',
'admin_',
'subscription_',
'balance_',
'referral_',
'device_guide_',
'happ_download_',
)
if callback_data.startswith(builtin_prefixes):
return 'builtin'
# Всё остальное - кастомные callback кнопки
return 'callback'
def _extract_button_text(self, reply_markup, callback_data: str) -> str:
"""Извлекает текст кнопки из клавиатуры."""
try:
if not reply_markup or not hasattr(reply_markup, 'inline_keyboard'):
return None
for row in reply_markup.inline_keyboard:
for button in row:
if hasattr(button, 'callback_data') and button.callback_data == callback_data:
if hasattr(button, 'text'):
return button.text
except Exception:
pass
return None
async def _log_button_click_async(
self,
button_id: str,
user_id: int = None,
callback_data: str = None,
button_type: str = None,
button_text: str = None,
):
"""Асинхронно логирует клик по кнопке."""
try:
async with AsyncSessionLocal() as db:
try:
from app.services.menu_layout_service import MenuLayoutService
await MenuLayoutService.log_button_click(
db,
button_id=button_id,
user_id=user_id,
callback_data=callback_data,
button_type=button_type,
button_text=button_text,
)
except Exception as e:
logger.warning('Ошибка записи клика в БД', button_id=button_id, error=e)
except Exception as e:
logger.warning('Ошибка создания сессии БД для логирования клика', error=e)