1f0fef114b
- 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
210 lines
7.3 KiB
Python
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)
|