Refactor project structure and enhance Docker support

- Removed unnecessary `__init__.py` and `Dockerfile` to streamline project organization.
- Updated `.dockerignore` and `.gitignore` to improve exclusion patterns for build artifacts and environment files.
- Enhanced `Makefile` with new commands for managing Docker containers and added help documentation.
- Introduced `pyproject.toml` for better project metadata management and dependency tracking.
- Updated `requirements.txt` to reflect changes in dependencies for metrics and monitoring.
- Refactored various handler files to improve code organization and maintainability.
This commit is contained in:
2025-08-29 16:49:28 +03:00
parent 8cee629e28
commit c68db87901
37 changed files with 2177 additions and 175 deletions

View File

@@ -0,0 +1,189 @@
"""
Examples of how to use metrics decorators in your bot handlers.
These examples show how to integrate metrics without modifying existing logic.
"""
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
# Import metrics decorators
from ..utils.metrics import track_time, track_errors, db_query_time, metrics
router = Router()
# Example 1: Basic command handler with timing and error tracking
@router.message(Command("start"))
@track_time("start_command", "private_handler")
@track_errors("private_handler", "start_command")
async def start_command(message: Message, state: FSMContext):
"""Start command handler with metrics."""
# Your existing logic here
await message.answer("Welcome! Bot started.")
# Optionally record custom metrics
metrics.record_command("start", "private_handler", "user")
# Example 2: Group command handler with custom labels
@router.message(Command("help"), F.chat.type.in_({"group", "supergroup"}))
@track_time("help_command", "group_handler")
@track_errors("group_handler", "help_command")
async def help_command(message: Message):
"""Help command handler for groups."""
await message.answer("Group help information.")
# Record command with group context
metrics.record_command("help", "group_handler", "group_user")
# Example 3: Callback handler with timing
@router.callback_query(F.data.startswith("menu:"))
@track_time("menu_callback", "callback_handler")
@track_errors("callback_handler", "menu_callback")
async def menu_callback(callback: CallbackQuery):
"""Menu callback handler."""
data = callback.data
await callback.answer(f"Menu: {data}")
# Record callback processing
metrics.record_message("callback_query", "callback", "callback_handler")
# Example 4: Database operation with query timing
@db_query_time("user_lookup", "users", "select")
async def get_user_info(user_id: int):
"""Example database function with timing."""
# Your database query here
# result = await db.fetch_one("SELECT * FROM users WHERE id = ?", user_id)
return {"user_id": user_id, "status": "active"}
# Example 5: Complex handler with multiple metrics
@router.message(Command("stats"))
@track_time("stats_command", "admin_handler")
@track_errors("admin_handler", "stats_command")
async def stats_command(message: Message):
"""Stats command with detailed metrics."""
try:
# Record command execution
metrics.record_command("stats", "admin_handler", "admin_user")
# Your stats logic here
stats = await get_bot_stats()
# Record successful execution
await message.answer(f"Bot stats: {stats}")
except Exception as e:
# Error is automatically tracked by decorator
await message.answer("Error getting stats")
raise
# Example 6: Message handler with message type tracking
@router.message()
@track_time("message_processing", "general_handler")
async def handle_message(message: Message):
"""General message handler."""
# Message type is automatically detected by middleware
# But you can add custom tracking
if message.photo:
# Custom metric for photo processing
metrics.record_message("photo", "general", "photo_handler")
# Your message handling logic
await message.answer("Message received")
# Example 7: Error-prone operation with custom error tracking
@track_errors("file_handler", "file_upload")
async def upload_file(file_data: bytes, filename: str):
"""File upload with error tracking."""
try:
# Your file upload logic
# result = await upload_service.upload(file_data, filename)
return {"status": "success", "filename": filename}
except Exception as e:
# Custom error metric
metrics.record_error(
type(e).__name__,
"file_handler",
"file_upload"
)
raise
# Example 8: Background task with metrics
async def background_metrics_collection():
"""Background task for collecting periodic metrics."""
while True:
try:
# Collect custom metrics
active_users = await count_active_users()
metrics.set_active_users(active_users, "current")
# Wait before next collection
await asyncio.sleep(300) # 5 minutes
except Exception as e:
metrics.record_error(
type(e).__name__,
"background_task",
"metrics_collection"
)
await asyncio.sleep(60) # Wait 1 minute on error
# Example 9: Custom metric collection in service
class UserService:
"""Example service with integrated metrics."""
@db_query_time("user_creation", "users", "insert")
async def create_user(self, user_data: dict):
"""Create user with database timing."""
# Your user creation logic
# user_id = await self.db.execute("INSERT INTO users ...")
return {"user_id": 123, "status": "created"}
@track_time("user_update", "user_service")
async def update_user(self, user_id: int, updates: dict):
"""Update user with timing."""
# Your update logic
# await self.db.execute("UPDATE users SET ...")
return {"user_id": user_id, "status": "updated"}
# Example 10: Middleware integration example
async def custom_middleware(handler, event, data):
"""Custom middleware that works with metrics system."""
from ..utils.metrics import track_middleware
async with track_middleware("custom_middleware"):
# Your middleware logic
result = await handler(event, data)
return result
# Helper function for stats (placeholder)
async def get_bot_stats():
"""Get bot statistics."""
return {
"total_users": 1000,
"active_today": 150,
"commands_processed": 5000
}
# Helper function for user counting (placeholder)
async def count_active_users():
"""Count active users."""
return 150
# Import asyncio for background task
import asyncio

View File

@@ -1,4 +1,8 @@
from typing import Annotated, Dict, Any
from typing import Dict, Any
try:
from typing import Annotated
except ImportError:
from typing_extensions import Annotated
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject

View File

@@ -1,5 +1,4 @@
import html
from tkinter import S
import traceback
from aiogram import Router

View File

@@ -1,14 +1,14 @@
"""Constants for group handlers"""
from typing import Final
from typing import Final, Dict
# FSM States
FSM_STATES: Final[dict[str, str]] = {
FSM_STATES: Final[Dict[str, str]] = {
"CHAT": "CHAT"
}
# Error messages
ERROR_MESSAGES: Final[dict[str, str]] = {
ERROR_MESSAGES: Final[Dict[str, str]] = {
"NO_REPLY_TO_MESSAGE": "Блять, выдели сообщение!",
"USER_NOT_FOUND": "Не могу найти кому ответить в базе, проебали сообщение."
}

View File

@@ -1,9 +1,9 @@
"""Constants for private handlers"""
from typing import Final
from typing import Final, Dict
# FSM States
FSM_STATES: Final[dict[str, str]] = {
FSM_STATES: Final[Dict[str, str]] = {
"START": "START",
"SUGGEST": "SUGGEST",
"PRE_CHAT": "PRE_CHAT",
@@ -11,7 +11,7 @@ FSM_STATES: Final[dict[str, str]] = {
}
# Button texts
BUTTON_TEXTS: Final[dict[str, str]] = {
BUTTON_TEXTS: Final[Dict[str, str]] = {
"SUGGEST_POST": "📢Предложить свой пост",
"SAY_GOODBYE": "👋🏼Сказать пока!",
"LEAVE_CHAT": "Выйти из чата",
@@ -21,7 +21,7 @@ BUTTON_TEXTS: Final[dict[str, str]] = {
}
# Error messages
ERROR_MESSAGES: Final[dict[str, str]] = {
ERROR_MESSAGES: Final[Dict[str, str]] = {
"UNSUPPORTED_CONTENT": (
'Я пока не умею работать с таким сообщением. '
'Пришли текст и фото/фоты(ы). А лучше перешли это сообщение админу @kerrad1\n'

View File

@@ -24,6 +24,14 @@ from helper_bot.utils.helper_func import (
check_user_emoji
)
# Local imports - metrics
from helper_bot.utils.metrics import (
metrics,
track_time,
track_errors,
db_query_time
)
# Local imports - modular components
from .constants import FSM_STATES, BUTTON_TEXTS, ERROR_MESSAGES
from .services import BotSettings, UserService, PostService, StickerService
@@ -91,16 +99,23 @@ class PrivateHandlers:
await message.answer('Я перезапущен!', reply_markup=markup, parse_mode='HTML')
@error_handler
@track_time("start_message_handler", "private_handler")
@track_errors("private_handler", "start_message_handler")
async def handle_start_message(self, message: types.Message, state: FSMContext, **kwargs):
"""Handle start command and return to bot button"""
"""Handle start command and return to bot button with metrics tracking"""
# Record start command metrics
metrics.record_command("start", "private_handler", "user" if not message.from_user.is_bot else "bot")
metrics.record_message("command", "private", "private_handler")
# User service operations with metrics
await self.user_service.log_user_message(message)
await self.user_service.ensure_user_exists(message)
await state.set_state(FSM_STATES["START"])
# Send sticker
# Send sticker with metrics
await self.sticker_service.send_random_hello_sticker(message)
# Send welcome message
# Send welcome message with metrics
markup = get_reply_keyboard(self.db, message.from_user.id)
hello_message = messages.get_message(get_first_name(message), 'HELLO_MESSAGE')
await message.answer(hello_message, reply_markup=markup, parse_mode='HTML')

View File

@@ -30,6 +30,14 @@ from helper_bot.utils.helper_func import (
)
from helper_bot.keyboards import get_reply_keyboard_for_post
# Local imports - metrics
from helper_bot.utils.metrics import (
metrics,
track_time,
track_errors,
db_query_time
)
class DatabaseProtocol(Protocol):
"""Protocol for database operations"""
@@ -65,13 +73,18 @@ class UserService:
self.db = db
self.settings = settings
@track_time("update_user_activity", "user_service")
@track_errors("user_service", "update_user_activity")
@db_query_time("update_user_activity", "users", "update")
async def update_user_activity(self, user_id: int) -> None:
"""Update user's last activity timestamp"""
"""Update user's last activity timestamp with metrics tracking"""
current_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self.db.update_date_for_user(current_date, user_id)
@track_time("ensure_user_exists", "user_service")
@track_errors("user_service", "ensure_user_exists")
async def ensure_user_exists(self, message: types.Message) -> None:
"""Ensure user exists in database, create if needed"""
"""Ensure user exists in database, create if needed with metrics tracking"""
user_id = message.from_user.id
full_name = message.from_user.full_name
username = message.from_user.username or "private_username"
@@ -82,14 +95,17 @@ class UserService:
current_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if not self.db.user_exists(user_id):
# Record database operation
self.db.add_new_user_in_db(
user_id, first_name, full_name, username, is_bot, language_code,
"", current_date, current_date
)
metrics.record_db_query("add_new_user", 0.0, "users", "insert")
else:
is_need_update = check_username_and_full_name(user_id, username, full_name, self.db)
if is_need_update:
self.db.update_username_and_full_name(user_id, username, full_name)
metrics.record_db_query("update_username_fullname", 0.0, "users", "update")
safe_full_name = html.escape(full_name) if full_name else "Неизвестный пользователь"
safe_username = html.escape(username) if username else "Без никнейма"
@@ -100,9 +116,12 @@ class UserService:
text=f'Для пользователя: {user_id} обновлены данные в БД.\nНовое имя: {safe_full_name}\nНовый ник:{safe_username}')
self.db.update_date_for_user(current_date, user_id)
metrics.record_db_query("update_date_for_user", 0.0, "users", "update")
@track_time("log_user_message", "user_service")
@track_errors("user_service", "log_user_message")
async def log_user_message(self, message: types.Message) -> None:
"""Forward user message to logs group"""
"""Forward user message to logs group with metrics tracking"""
await message.forward(chat_id=self.settings.group_for_logs)
def get_safe_user_info(self, message: types.Message) -> tuple[str, str]:
@@ -210,7 +229,7 @@ class PostService:
message_id=media_group_message_id, helper_message_id=help_message_id
)
async def process_post(self, message: types.Message, album: Union[list[types.Message], None] = None) -> None:
async def process_post(self, message: types.Message, album: Union[list, None] = None) -> None:
"""Process post based on content type"""
first_name = get_first_name(message)
@@ -248,8 +267,10 @@ class StickerService:
def __init__(self, settings: BotSettings) -> None:
self.settings = settings
@track_time("send_random_hello_sticker", "sticker_service")
@track_errors("sticker_service", "send_random_hello_sticker")
async def send_random_hello_sticker(self, message: types.Message) -> None:
"""Send random hello sticker"""
"""Send random hello sticker with metrics tracking"""
name_stick_hello = list(Path('Stick').rglob('Hello_*'))
if not name_stick_hello:
return
@@ -258,8 +279,10 @@ class StickerService:
await message.answer_sticker(random_stick_hello)
await asyncio.sleep(0.3)
@track_time("send_random_goodbye_sticker", "sticker_service")
@track_errors("sticker_service", "send_random_goodbye_sticker")
async def send_random_goodbye_sticker(self, message: types.Message) -> None:
"""Send random goodbye sticker"""
"""Send random goodbye sticker with metrics tracking"""
name_stick_bye = list(Path('Stick').rglob('Universal_*'))
if not name_stick_bye:
return

View File

@@ -1,6 +1,13 @@
from aiogram import types
from aiogram.utils.keyboard import ReplyKeyboardBuilder, InlineKeyboardBuilder
# Local imports - metrics
from helper_bot.utils.metrics import (
metrics,
track_time,
track_errors
)
def get_reply_keyboard_for_post():
builder = InlineKeyboardBuilder()
@@ -16,6 +23,8 @@ def get_reply_keyboard_for_post():
return markup
@track_time("get_reply_keyboard", "keyboard_service")
@track_errors("keyboard_service", "get_reply_keyboard")
def get_reply_keyboard(BotDB, user_id):
builder = ReplyKeyboardBuilder()
builder.row(types.KeyboardButton(text="📢Предложить свой пост"))
@@ -49,7 +58,7 @@ def get_reply_keyboard_admin():
return markup
def create_keyboard_with_pagination(page: int, total_items: int, array_items: list[tuple[any, any]], callback: str):
def create_keyboard_with_pagination(page: int, total_items: int, array_items: list, callback: str):
"""
Создает клавиатуру с пагинацией для заданного набора элементов и устанавливает необходимый callback

View File

@@ -9,6 +9,7 @@ from helper_bot.handlers.group import group_router
from helper_bot.handlers.private import private_router
from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
from helper_bot.middlewares.metrics_middleware import MetricsMiddleware, ErrorMetricsMiddleware
async def start_bot(bdf):
@@ -19,6 +20,12 @@ async def start_bot(bdf):
), timeout=30.0) # Добавляем таймаут для предотвращения зависаний
dp = Dispatcher(storage=MemoryStorage(), fsm_strategy=FSMStrategy.GLOBAL_USER)
# ✅ Middleware для метрик (добавляем первыми)
dp.message.middleware(MetricsMiddleware())
dp.callback_query.middleware(MetricsMiddleware())
dp.message.middleware(ErrorMetricsMiddleware())
dp.callback_query.middleware(ErrorMetricsMiddleware())
# ✅ Глобальная middleware для всех роутеров
dp.update.outer_middleware(DependenciesMiddleware())

View File

@@ -0,0 +1,117 @@
"""
Example integration of metrics monitoring in the main bot file.
This shows how to integrate the metrics system without modifying existing handlers.
"""
import asyncio
import logging
from aiogram import Bot, Dispatcher
from aiogram.enums import ParseMode
from aiogram.fsm.storage.memory import MemoryStorage
# Import metrics components
from .utils.metrics import metrics
from .utils.metrics_exporter import MetricsManager
from .middlewares.metrics_middleware import MetricsMiddleware, ErrorMetricsMiddleware
# Import your existing bot components
# from .handlers import ... # Your existing handlers
# from .database.db import BotDB # Your existing database class
class BotWithMetrics:
"""Bot class with integrated metrics monitoring."""
def __init__(self, token: str, metrics_port: int = 8000):
self.bot = Bot(token=token, parse_mode=ParseMode.HTML)
self.storage = MemoryStorage()
self.dp = Dispatcher(storage=self.storage)
# Initialize metrics manager
# You can pass your database instance here if needed
# self.metrics_manager = MetricsManager(port=metrics_port, db=your_db_instance)
self.metrics_manager = MetricsManager(port=metrics_port)
# Setup middlewares
self._setup_middlewares()
# Setup handlers (your existing handlers)
# self._setup_handlers()
self.logger = logging.getLogger(__name__)
def _setup_middlewares(self):
"""Setup metrics middlewares."""
# Add metrics middleware first to capture all events
self.dp.message.middleware(MetricsMiddleware())
self.dp.callback_query.middleware(MetricsMiddleware())
# Add error tracking middleware
self.dp.message.middleware(ErrorMetricsMiddleware())
self.dp.callback_query.middleware(ErrorMetricsMiddleware())
# Your existing middlewares can go here
# self.dp.message.middleware(YourExistingMiddleware())
def _setup_handlers(self):
"""Setup bot handlers."""
# Import and register your existing handlers here
# from .handlers.admin import admin_router
# from .handlers.private import private_router
# from .handlers.group import group_router
# from .handlers.callback import callback_router
#
# self.dp.include_router(admin_router)
# self.dp.include_router(private_router)
# self.dp.include_router(group_router)
# self.dp.include_router(callback_router)
pass
async def start(self):
"""Start the bot with metrics."""
try:
# Start metrics collection
await self.metrics_manager.start()
self.logger.info("Metrics system started")
# Start bot polling
await self.dp.start_polling(self.bot)
except Exception as e:
self.logger.error(f"Error starting bot: {e}")
raise
finally:
await self.stop()
async def stop(self):
"""Stop the bot and metrics."""
try:
# Stop metrics collection
await self.metrics_manager.stop()
self.logger.info("Metrics system stopped")
# Stop bot
await self.bot.session.close()
self.logger.info("Bot stopped")
except Exception as e:
self.logger.error(f"Error stopping bot: {e}")
# Example usage function
async def main():
"""Main function to run the bot with metrics."""
# Your bot token
TOKEN = "YOUR_BOT_TOKEN_HERE"
# Create and start bot
bot = BotWithMetrics(TOKEN)
try:
await bot.start()
except KeyboardInterrupt:
await bot.stop()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,173 @@
"""
Metrics middleware for aiogram 3.x.
Automatically collects metrics for message processing, command execution, and errors.
"""
from typing import Any, Awaitable, Callable, Dict
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject, Message, CallbackQuery
from aiogram.enums import ChatType
import time
from ..utils.metrics import metrics, track_middleware
class MetricsMiddleware(BaseMiddleware):
"""Middleware for automatic metrics collection in aiogram handlers."""
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
"""Process event and collect metrics."""
async with track_middleware("metrics_middleware"):
# Record message processing
if isinstance(event, Message):
await self._record_message_metrics(event, data)
elif isinstance(event, CallbackQuery):
await self._record_callback_metrics(event, data)
# Execute handler and collect timing
start_time = time.time()
try:
result = await handler(event, data)
duration = time.time() - start_time
# Record successful execution
handler_name = handler.__name__ if hasattr(handler, '__name__') else "unknown"
metrics.record_method_duration(
handler_name,
duration,
"handler",
"success"
)
return result
except Exception as e:
duration = time.time() - start_time
# Record error and timing
handler_name = handler.__name__ if hasattr(handler, '__name__') else "unknown"
metrics.record_method_duration(
handler_name,
duration,
"handler",
"error"
)
metrics.record_error(
type(e).__name__,
"handler",
handler_name
)
raise
async def _record_message_metrics(self, message: Message, data: Dict[str, Any]):
"""Record metrics for message processing."""
# Determine message type
message_type = "text"
if message.photo:
message_type = "photo"
elif message.video:
message_type = "video"
elif message.audio:
message_type = "audio"
elif message.document:
message_type = "document"
elif message.voice:
message_type = "voice"
elif message.sticker:
message_type = "sticker"
elif message.animation:
message_type = "animation"
# Determine chat type
chat_type = "private"
if message.chat.type == ChatType.GROUP:
chat_type = "group"
elif message.chat.type == ChatType.SUPERGROUP:
chat_type = "supergroup"
elif message.chat.type == ChatType.CHANNEL:
chat_type = "channel"
# Determine handler type
handler_type = "unknown"
if message.text and message.text.startswith('/'):
handler_type = "command"
# Record command specifically
command = message.text.split()[0][1:] # Remove '/' and get command name
metrics.record_command(
command,
"message_handler",
"user" if message.from_user else "unknown"
)
# Record message processing
metrics.record_message(message_type, chat_type, handler_type)
async def _record_callback_metrics(self, callback: CallbackQuery, data: Dict[str, Any]):
"""Record metrics for callback query processing."""
# Record callback processing
metrics.record_message(
"callback_query",
"callback",
"callback_handler"
)
# Record callback command if available
if callback.data:
# Extract command from callback data (assuming format like "command:param")
parts = callback.data.split(':', 1)
if parts:
command = parts[0]
metrics.record_command(
command,
"callback_handler",
"user" if callback.from_user else "unknown"
)
class DatabaseMetricsMiddleware(BaseMiddleware):
"""Middleware for database operation metrics."""
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
"""Process event and collect database metrics."""
# Check if this handler involves database operations
handler_name = handler.__name__ if hasattr(handler, '__name__') else "unknown"
# You can add specific database operation detection logic here
# For now, we'll just pass through and let individual decorators handle it
return await handler(event, data)
class ErrorMetricsMiddleware(BaseMiddleware):
"""Middleware for error tracking and metrics."""
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
"""Process event and collect error metrics."""
try:
return await handler(event, data)
except Exception as e:
# Record error metrics
handler_name = handler.__name__ if hasattr(handler, '__name__') else "unknown"
metrics.record_error(
type(e).__name__,
"handler",
handler_name
)
raise

View File

@@ -3,6 +3,7 @@ import os
import random
from datetime import datetime, timedelta
from time import sleep
from typing import List, Dict, Any, Optional
try:
import emoji as _emoji_lib
@@ -14,6 +15,14 @@ from aiogram.types import InputMediaPhoto, FSInputFile, InputMediaVideo, InputMe
from helper_bot.utils.base_dependency_factory import BaseDependencyFactory, get_global_instance
from logs.custom_logger import logger
# Local imports - metrics
from .metrics import (
metrics,
track_time,
track_errors,
db_query_time
)
bdf = get_global_instance()
BotDB = bdf.get_db()
GROUP_FOR_LOGS = bdf.settings['Telegram']['group_for_logs']
@@ -43,6 +52,8 @@ def safe_html_escape(text: str) -> str:
return html.escape(str(text))
@track_time("get_first_name", "helper_func")
@track_errors("helper_func", "get_first_name")
def get_first_name(message: types.Message) -> str:
"""
Безопасно получает и экранирует имя пользователя для использования в HTML разметке.
@@ -234,7 +245,7 @@ async def add_in_db_media(sent_message, bot_db):
async def send_media_group_message_to_private_chat(chat_id: int, message: types.Message,
media_group: list[InputMediaPhoto], bot_db):
media_group: List, bot_db):
sent_message = await message.bot.send_media_group(
chat_id=chat_id,
media=media_group,
@@ -245,7 +256,7 @@ async def send_media_group_message_to_private_chat(chat_id: int, message: types.
return message_id
async def send_media_group_to_channel(bot, chat_id: int, post_content: list[tuple[str]], post_text: str):
async def send_media_group_to_channel(bot, chat_id: int, post_content: List, post_text: str):
"""
Отправляет медиа-группу с подписью к последнему файлу.
@@ -458,6 +469,9 @@ def delete_user_blacklist(user_id: int, bot_db):
return bot_db.delete_user_blacklist(user_id=user_id)
@track_time("check_username_and_full_name", "helper_func")
@track_errors("helper_func", "check_username_and_full_name")
@db_query_time("get_username_and_full_name", "users", "select")
def check_username_and_full_name(user_id: int, username: str, full_name: str, bot_db):
username_db, full_name_db = bot_db.get_username_and_full_name(user_id=user_id)
return username != username_db or full_name != full_name_db
@@ -479,6 +493,8 @@ def unban_notifier(self):
self.bot.send_message(self.GROUP_FOR_MESSAGE, message)
@track_time("update_user_info", "helper_func")
@track_errors("helper_func", "update_user_info")
async def update_user_info(source: str, message: types.Message):
# Собираем данные
full_name = message.from_user.full_name
@@ -495,10 +511,12 @@ async def update_user_info(source: str, message: types.Message):
if not BotDB.user_exists(user_id):
BotDB.add_new_user_in_db(user_id, first_name, full_name, username, is_bot, language_code, user_emoji, date,
date)
metrics.record_db_query("add_new_user_in_db", 0.0, "users", "insert")
else:
is_need_update = check_username_and_full_name(user_id, username, full_name, BotDB)
if is_need_update:
BotDB.update_username_and_full_name(user_id, username, full_name)
metrics.record_db_query("update_username_and_full_name", 0.0, "users", "update")
if source != 'voice':
await message.answer(
f"Давно не виделись! Вижу что ты изменился;) Теперь буду звать тебя: {full_name}")
@@ -506,17 +524,25 @@ async def update_user_info(source: str, message: types.Message):
text=f'Для пользователя: {user_id} обновлены данные в БД.\nНовое имя: {full_name}\nНовый ник:{username}. Новый эмодзи:{user_emoji}')
sleep(1)
BotDB.update_date_for_user(date, user_id)
metrics.record_db_query("update_date_for_user", 0.0, "users", "update")
@track_time("check_user_emoji", "helper_func")
@track_errors("helper_func", "check_user_emoji")
@db_query_time("check_emoji_for_user", "users", "select")
def check_user_emoji(message: types.Message):
user_id = message.from_user.id
user_emoji = BotDB.check_emoji_for_user(user_id=user_id)
if user_emoji is None or user_emoji in ("Смайл еще не определен", "Эмоджи не определен", ""):
user_emoji = get_random_emoji()
BotDB.update_emoji_for_user(user_id=user_id, emoji=user_emoji)
metrics.record_db_query("update_emoji_for_user", 0.0, "users", "update")
return user_emoji
@track_time("get_random_emoji", "helper_func")
@track_errors("helper_func", "get_random_emoji")
@db_query_time("check_emoji", "users", "select")
def get_random_emoji():
attempts = 0
while attempts < 100:

View File

@@ -1,6 +1,15 @@
import html
# Local imports - metrics
from .metrics import (
metrics,
track_time,
track_errors
)
@track_time("get_message", "message_service")
@track_errors("message_service", "get_message")
def get_message(username: str, type_message: str):
constants = {
'HELLO_MESSAGE': "Привет, username!👋🏼&Меня зовут Виби, я бот канала 'Влюбленный Бийск'❤🤖"

300
helper_bot/utils/metrics.py Normal file
View File

@@ -0,0 +1,300 @@
"""
Metrics module for Telegram bot monitoring with Prometheus.
Provides predefined metrics for bot commands, errors, performance, and user activity.
"""
from typing import Dict, Any, Optional
from prometheus_client import Counter, Histogram, Gauge, generate_latest, CONTENT_TYPE_LATEST
from prometheus_client.core import CollectorRegistry
import time
from functools import wraps
import asyncio
from contextlib import asynccontextmanager
class BotMetrics:
"""Central class for managing all bot metrics."""
def __init__(self):
self.registry = CollectorRegistry()
# Bot commands counter
self.bot_commands_total = Counter(
'bot_commands_total',
'Total number of bot commands processed',
['command_type', 'handler_type', 'user_type'],
registry=self.registry
)
# Method execution time histogram
self.method_duration_seconds = Histogram(
'method_duration_seconds',
'Time spent executing methods',
['method_name', 'handler_type', 'status'],
buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0],
registry=self.registry
)
# Errors counter
self.errors_total = Counter(
'errors_total',
'Total number of errors',
['error_type', 'handler_type', 'method_name'],
registry=self.registry
)
# Active users gauge
self.active_users = Gauge(
'active_users',
'Number of currently active users',
['user_type'],
registry=self.registry
)
# Database query metrics
self.db_query_duration_seconds = Histogram(
'db_query_duration_seconds',
'Time spent executing database queries',
['query_type', 'table_name', 'operation'],
buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5],
registry=self.registry
)
# Message processing metrics
self.messages_processed_total = Counter(
'messages_processed_total',
'Total number of messages processed',
['message_type', 'chat_type', 'handler_type'],
registry=self.registry
)
# Middleware execution metrics
self.middleware_duration_seconds = Histogram(
'middleware_duration_seconds',
'Time spent in middleware execution',
['middleware_name', 'status'],
buckets=[0.01, 0.05, 0.1, 0.25, 0.5],
registry=self.registry
)
def record_command(self, command_type: str, handler_type: str = "unknown", user_type: str = "unknown"):
"""Record a bot command execution."""
self.bot_commands_total.labels(
command_type=command_type,
handler_type=handler_type,
user_type=user_type
).inc()
def record_error(self, error_type: str, handler_type: str = "unknown", method_name: str = "unknown"):
"""Record an error occurrence."""
self.errors_total.labels(
error_type=error_type,
handler_type=handler_type,
method_name=method_name
).inc()
def record_method_duration(self, method_name: str, duration: float, handler_type: str = "unknown", status: str = "success"):
"""Record method execution duration."""
self.method_duration_seconds.labels(
method_name=method_name,
handler_type=handler_type,
status=status
).observe(duration)
def set_active_users(self, count: int, user_type: str = "total"):
"""Set the number of active users."""
self.active_users.labels(user_type=user_type).set(count)
def record_db_query(self, query_type: str, duration: float, table_name: str = "unknown", operation: str = "unknown"):
"""Record database query duration."""
self.db_query_duration_seconds.labels(
query_type=query_type,
table_name=table_name,
operation=operation
).observe(duration)
def record_message(self, message_type: str, chat_type: str = "unknown", handler_type: str = "unknown"):
"""Record a processed message."""
self.messages_processed_total.labels(
message_type=message_type,
chat_type=chat_type,
handler_type=handler_type
).inc()
def record_middleware(self, middleware_name: str, duration: float, status: str = "success"):
"""Record middleware execution duration."""
self.middleware_duration_seconds.labels(
middleware_name=middleware_name,
status=status
).observe(duration)
def get_metrics(self) -> bytes:
"""Generate metrics in Prometheus format."""
return generate_latest(self.registry)
# Global metrics instance
metrics = BotMetrics()
# Decorators for easy metric collection
def track_time(method_name: str = None, handler_type: str = "unknown"):
"""Decorator to track execution time of functions."""
def decorator(func):
@wraps(func)
async def async_wrapper(*args, **kwargs):
start_time = time.time()
try:
result = await func(*args, **kwargs)
duration = time.time() - start_time
metrics.record_method_duration(
method_name or func.__name__,
duration,
handler_type,
"success"
)
return result
except Exception as e:
duration = time.time() - start_time
metrics.record_method_duration(
method_name or func.__name__,
duration,
handler_type,
"error"
)
metrics.record_error(
type(e).__name__,
handler_type,
method_name or func.__name__
)
raise
@wraps(func)
def sync_wrapper(*args, **kwargs):
start_time = time.time()
try:
result = func(*args, **kwargs)
duration = time.time() - start_time
metrics.record_method_duration(
method_name or func.__name__,
duration,
handler_type,
"success"
)
return result
except Exception as e:
duration = time.time() - start_time
metrics.record_method_duration(
method_name or func.__name__,
duration,
handler_type,
"error"
)
metrics.record_error(
type(e).__name__,
handler_type,
method_name or func.__name__
)
raise
if asyncio.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
return decorator
def track_errors(handler_type: str = "unknown", method_name: str = None):
"""Decorator to track errors in functions."""
def decorator(func):
@wraps(func)
async def async_wrapper(*args, **kwargs):
try:
return await func(*args, **kwargs)
except Exception as e:
metrics.record_error(
type(e).__name__,
handler_type,
method_name or func.__name__
)
raise
@wraps(func)
def sync_wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except Exception as e:
metrics.record_error(
type(e).__name__,
handler_type,
method_name or func.__name__
)
raise
if asyncio.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
return decorator
def db_query_time(query_type: str = "unknown", table_name: str = "unknown", operation: str = "unknown"):
"""Decorator to track database query execution time."""
def decorator(func):
@wraps(func)
async def async_wrapper(*args, **kwargs):
start_time = time.time()
try:
result = await func(*args, **kwargs)
duration = time.time() - start_time
metrics.record_db_query(query_type, duration, table_name, operation)
return result
except Exception as e:
duration = time.time() - start_time
metrics.record_db_query(query_type, duration, table_name, operation)
metrics.record_error(
type(e).__name__,
"database",
func.__name__
)
raise
@wraps(func)
def sync_wrapper(*args, **kwargs):
start_time = time.time()
try:
result = func(*args, **kwargs)
duration = time.time() - start_time
metrics.record_db_query(query_type, duration, table_name, operation)
return result
except Exception as e:
duration = time.time() - start_time
metrics.record_db_query(query_type, duration, table_name, operation)
metrics.record_error(
type(e).__name__,
"database",
func.__name__
)
raise
if asyncio.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
return decorator
@asynccontextmanager
async def track_middleware(middleware_name: str):
"""Context manager to track middleware execution time."""
start_time = time.time()
try:
yield
duration = time.time() - start_time
metrics.record_middleware(middleware_name, duration, "success")
except Exception as e:
duration = time.time() - start_time
metrics.record_middleware(middleware_name, duration, "error")
metrics.record_error(
type(e).__name__,
"middleware",
middleware_name
)
raise

View File

@@ -0,0 +1,201 @@
"""
Metrics exporter for Prometheus.
Provides HTTP endpoint for metrics collection and background metrics collection.
"""
import asyncio
import logging
from aiohttp import web
from typing import Optional, Dict, Any
from .metrics import metrics
class MetricsExporter:
"""HTTP server for exposing Prometheus metrics."""
def __init__(self, host: str = "0.0.0.0", port: int = 8000):
self.host = host
self.port = port
self.app = web.Application()
self.runner: Optional[web.AppRunner] = None
self.site: Optional[web.TCPSite] = None
self.logger = logging.getLogger(__name__)
# Setup routes
self.app.router.add_get('/metrics', self.metrics_handler)
self.app.router.add_get('/health', self.health_handler)
self.app.router.add_get('/', self.root_handler)
async def start(self):
"""Start the metrics server."""
try:
self.runner = web.AppRunner(self.app)
await self.runner.setup()
self.site = web.TCPSite(self.runner, self.host, self.port)
await self.site.start()
self.logger.info(f"Metrics server started on {self.host}:{self.port}")
except Exception as e:
self.logger.error(f"Failed to start metrics server: {e}")
raise
async def stop(self):
"""Stop the metrics server."""
if self.site:
await self.site.stop()
if self.runner:
await self.runner.cleanup()
self.logger.info("Metrics server stopped")
async def metrics_handler(self, request: web.Request) -> web.Response:
"""Handle /metrics endpoint for Prometheus."""
try:
# Log request for debugging
self.logger.info(f"Metrics request from {request.remote}: {request.headers.get('User-Agent', 'Unknown')}")
metrics_data = metrics.get_metrics()
self.logger.debug(f"Generated metrics: {len(metrics_data)} bytes")
return web.Response(
body=metrics_data,
content_type='text/plain; version=0.0.4'
)
except Exception as e:
self.logger.error(f"Error generating metrics: {e}")
return web.Response(
text=f"Error generating metrics: {e}",
status=500
)
async def health_handler(self, request: web.Request) -> web.Response:
"""Handle /health endpoint for health checks."""
return web.json_response({
"status": "healthy",
"service": "telegram-bot-metrics"
})
async def root_handler(self, request: web.Request) -> web.Response:
"""Handle root endpoint with basic info."""
return web.json_response({
"service": "Telegram Bot Metrics Exporter",
"endpoints": {
"/metrics": "Prometheus metrics",
"/health": "Health check",
"/": "This info"
}
})
class BackgroundMetricsCollector:
"""Background service for collecting periodic metrics."""
def __init__(self, db: Optional[Any] = None, interval: int = 60):
self.db = db
self.interval = interval
self.running = False
self.logger = logging.getLogger(__name__)
async def start(self):
"""Start background metrics collection."""
self.running = True
self.logger.info("Background metrics collector started")
while self.running:
try:
await self._collect_metrics()
await asyncio.sleep(self.interval)
except Exception as e:
self.logger.error(f"Error in background metrics collection: {e}")
await asyncio.sleep(self.interval)
async def stop(self):
"""Stop background metrics collection."""
self.running = False
self.logger.info("Background metrics collector stopped")
async def _collect_metrics(self):
"""Collect periodic metrics."""
try:
# Collect active users count if database is available
if self.db:
await self._collect_user_metrics()
# Collect system metrics
await self._collect_system_metrics()
except Exception as e:
self.logger.error(f"Error collecting metrics: {e}")
async def _collect_user_metrics(self):
"""Collect user-related metrics from database."""
try:
if hasattr(self.db, 'fetch_one'):
# Try to get active users from database if it has async methods
try:
active_users_query = """
SELECT COUNT(DISTINCT user_id) as active_users
FROM our_users
WHERE date_added > datetime('now', '-1 day')
"""
result = await self.db.fetch_one(active_users_query)
if result:
metrics.set_active_users(result['active_users'], 'daily')
else:
metrics.set_active_users(0, 'daily')
except Exception as db_error:
self.logger.warning(f"Database query failed, using placeholder: {db_error}")
metrics.set_active_users(0, 'daily')
else:
# For now, set a placeholder value
metrics.set_active_users(0, 'daily')
except Exception as e:
self.logger.error(f"Error collecting user metrics: {e}")
metrics.set_active_users(0, 'daily')
async def _collect_system_metrics(self):
"""Collect system-level metrics."""
try:
# Example: collect memory usage, CPU usage, etc.
# This can be extended based on your needs
pass
except Exception as e:
self.logger.error(f"Error collecting system metrics: {e}")
class MetricsManager:
"""Main class for managing metrics collection and export."""
def __init__(self, host: str = "0.0.0.0", port: int = 8000, db: Optional[Any] = None):
self.exporter = MetricsExporter(host, port)
self.collector = BackgroundMetricsCollector(db)
self.logger = logging.getLogger(__name__)
async def start(self):
"""Start metrics collection and export."""
try:
# Start metrics exporter
await self.exporter.start()
# Start background collector
asyncio.create_task(self.collector.start())
self.logger.info("Metrics manager started successfully")
except Exception as e:
self.logger.error(f"Failed to start metrics manager: {e}")
raise
async def stop(self):
"""Stop metrics collection and export."""
try:
await self.collector.stop()
await self.exporter.stop()
self.logger.info("Metrics manager stopped successfully")
except Exception as e:
self.logger.error(f"Error stopping metrics manager: {e}")
raise