diff --git a/.dockerignore b/.dockerignore index 2d1fc85..304d993 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,37 +1,73 @@ +# Python __pycache__/ *.py[cod] -*.pyo -*.pyd +*$py.class *.so -*.egg-info/ +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ .eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments .env .venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE .vscode/ .idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Git .git/ .gitignore -# Byte-compiled / optimized / DLL files -**/__pycache__/ -**/*.pyc -**/*.pyo -**/*.pyd +# Logs +logs/*.log -# Local settings -settings_example.ini - -# Databases and runtime files +# Database *.db *.db-shm *.db-wal -logs/ -# Tests and artifacts -.coverage +# Tests +tests/ +test_*.py .pytest_cache/ -htmlcov/ -**/tests/ -# Stickers and large assets (if not needed at runtime) -Stick/ +# Documentation +*.md +docs/ + +# Docker +Dockerfile* +docker-compose*.yml +.dockerignore diff --git a/.gitignore b/.gitignore index 1f3e790..734ea09 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ +# Database files /database/tg-bot-database.db /database/tg-bot-database.db-shm -/database/tg-bot-database.db-wal +/database/tg-bot-database.db-wm /database/test.db /database/test.db-shm /database/test.db-wal @@ -10,7 +11,9 @@ /settings.ini /myenv/ /venv/ -/.idea/ +/.venv/ + +# Logs /logs/*.log # Testing and coverage files @@ -32,6 +35,7 @@ test.db # IDE and editor files .vscode/ +.idea/ *.swp *.swo *~ @@ -44,9 +48,43 @@ test.db .Trashes ehthumbs.db Thumbs.db + +# Documentation files PERFORMANCE_IMPROVEMENTS.md # PID files *.pid helper_bot.pid voice_bot.pid + +# Docker and build artifacts +*.tar.gz +prometheus-*/ +node_modules/ + +# Environment files +.env +.env.local +.env.*.local + +# Temporary files +*.tmp +*.temp +*.log +*.pid + +# Python cache +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.cache/ +.mypy_cache/ + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ +env.bak/ +venv.bak/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..1635d0f --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9.6 diff --git a/CHANGES_SUMMARY.md b/CHANGES_SUMMARY.md new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/CHANGES_SUMMARY.md @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 8a580d1..0000000 --- a/Dockerfile +++ /dev/null @@ -1,37 +0,0 @@ -# syntax=docker/dockerfile:1 - -# Use a lightweight Python image -FROM python:3.11-slim - -# Prevent Python from writing .pyc files and enable unbuffered logs -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 - -# Install system dependencies (if required by Python packages) -RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential \ - && rm -rf /var/lib/apt/lists/* - -# Set working directory -WORKDIR /app - -# Create non-root user -RUN useradd -m appuser \ - && chown -R appuser:appuser /app - -# Install Python dependencies first for better layer caching -COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt - -# Copy project files -COPY . . - -# Ensure runtime directories exist and are writable -RUN mkdir -p logs database \ - && chown -R appuser:appuser /app - -# Switch to non-root user -USER appuser - -# Run the bot -CMD ["python", "run_helper.py"] diff --git a/Dockerfile.bot b/Dockerfile.bot new file mode 100644 index 0000000..9fb34e2 --- /dev/null +++ b/Dockerfile.bot @@ -0,0 +1,34 @@ +FROM python:3.9-slim + +# Установка системных зависимостей +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Создание рабочей директории +WORKDIR /app + +# Копирование requirements.txt +COPY requirements.txt . + +# Создание виртуального окружения +RUN python -m venv .venv + +# Обновление pip в виртуальном окружении +RUN . .venv/bin/activate && pip install --upgrade pip + +# Установка зависимостей в виртуальное окружение +RUN . .venv/bin/activate && pip install --no-cache-dir -r requirements.txt + +# Копирование исходного кода +COPY . . + +# Активация виртуального окружения +ENV PATH="/app/.venv/bin:$PATH" +ENV VIRTUAL_ENV="/app/.venv" + +# Открытие порта для метрик +EXPOSE 8000 + +# Команда запуска через виртуальное окружение +CMD [".venv/bin/python", "run_helper.py"] diff --git a/Makefile b/Makefile index e27e12a..cfaf346 100644 --- a/Makefile +++ b/Makefile @@ -1,88 +1,68 @@ -.PHONY: help test test-db test-coverage test-html clean install test-monitor +.PHONY: help build up down logs clean restart status -# Default target -help: - @echo "Available commands:" - @echo " install - Install dependencies" - @echo " test - Run all tests" - @echo " test-db - Run database tests only" - @echo " test-bot - Run bot startup and handler tests only" - @echo " test-media - Run media handler tests only" - @echo " test-errors - Run error handling tests only" - @echo " test-utils - Run utility functions tests only" - @echo " test-keyboards - Run keyboard and filter tests only" - @echo " test-monitor - Test server monitoring module" - @echo " test-coverage - Run tests with coverage report (helper_bot + database)" - @echo " test-html - Run tests and generate HTML coverage report" - @echo " clean - Clean up generated files" - @echo " coverage - Show coverage report only" +help: ## Показать справку + @echo "🐍 Telegram Bot - Доступные команды (Python 3.9):" + @echo "" + @echo "🔧 Основные команды:" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + @echo "" + @echo "📊 Мониторинг:" + @echo " Prometheus: http://localhost:9090" + @echo " Grafana: http://localhost:3000 (admin/admin)" -# Install dependencies -install: - python3 -m pip install -r requirements.txt - python3 -m pip install pytest-cov +build: ## Собрать все контейнеры с Python 3.9 + docker-compose build -# Run all tests -test: - python3 -m pytest tests/ -v +up: ## Запустить все сервисы с Python 3.9 + docker-compose up -d -# Run database tests only -test-db: - python3 -m pytest tests/test_db.py -v +down: ## Остановить все сервисы + docker-compose down -# Run bot tests only -test-bot: - python3 -m pytest tests/test_bot.py -v +logs: ## Показать логи всех сервисов + docker-compose logs -f -# Run media handler tests only -test-media: - python3 -m pytest tests/test_media_handlers.py -v +logs-bot: ## Показать логи бота + docker-compose logs -f telegram-bot -# Run error handling tests only -test-errors: - python3 -m pytest tests/test_error_handling.py -v +logs-prometheus: ## Показать логи Prometheus + docker-compose logs -f prometheus -# Run utils tests only -test-utils: - python3 -m pytest tests/test_utils.py -v +logs-grafana: ## Показать логи Grafana + docker-compose logs -f grafana -# Run keyboard and filter tests only -test-keyboards: - python3 -m pytest tests/test_keyboards_and_filters.py -v +restart: ## Перезапустить все сервисы (с пересборкой Python 3.9) + docker-compose down + docker-compose build + docker-compose up -d -# Test server monitoring module -test-monitor: - python3 tests/test_monitor.py +status: ## Показать статус контейнеров + docker-compose ps -# Test auto unban scheduler -test-auto-unban: - python3 -m pytest tests/test_auto_unban_scheduler.py -v +check-python: ## Проверить версию Python в контейнере + @echo "🐍 Проверяю версию Python в контейнере..." + @docker exec telegram-bot .venv/bin/python --version || echo "Контейнер не запущен" -# Test auto unban integration -test-auto-unban-integration: - python3 -m pytest tests/test_auto_unban_integration.py -v +test-compatibility: ## Тест совместимости с Python 3.8+ + @echo "🐍 Тестирую совместимость с Python 3.8+..." + @python3 test_python38_compatibility.py -# Run tests with coverage -test-coverage: - python3 -m pytest tests/ --cov=helper_bot --cov=database --cov-report=term +clean: ## Очистить все контейнеры и образы Python 3.9 + docker-compose down -v --rmi all + docker system prune -f -# Run tests and generate HTML coverage report -test-html: - python3 -m pytest tests/ --cov=helper_bot --cov=database --cov-report=html:htmlcov --cov-report=term - @echo "HTML coverage report generated in htmlcov/index.html" -# Show coverage report only -coverage: - python3 -m coverage report --include="helper_bot/*,database/*" -# Clean up generated files -clean: - rm -rf htmlcov/ - rm -f coverage.xml - rm -f .coverage - rm -f database/test.db - rm -f test.db - rm -f helper_bot.pid - rm -f voice_bot.pid - find . -type d -name "__pycache__" -exec rm -rf {} + - find . -type f -name "*.pyc" -delete +start: build up ## Собрать и запустить все сервисы с Python 3.9 + @echo "🐍 Python 3.9 контейнер собран и запущен!" + @echo "📊 Prometheus: http://localhost:9090" + @echo "📈 Grafana: http://localhost:3000 (admin/admin)" + @echo "🤖 Бот запущен в контейнере с Python 3.9" + @echo "📝 Логи: make logs" + +start-script: ## Запустить через скрипт start_docker.sh + @echo "🐍 Запуск через скрипт start_docker.sh..." + @./start_docker.sh + +stop: down ## Остановить все сервисы + @echo "🛑 Все сервисы остановлены" diff --git a/__init__.py b/__init__.py deleted file mode 100644 index c61e014..0000000 --- a/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# This file makes the root directory a Python package - - diff --git a/database/async_db.py b/database/async_db.py index 507bb21..e0bb693 100644 --- a/database/async_db.py +++ b/database/async_db.py @@ -446,7 +446,7 @@ class AsyncBotDB: if conn: await conn.close() - async def get_post_content(self, last_post_id: int) -> List[Tuple[str, str]]: + async def get_post_content(self, last_post_id: int) -> List: """Получение контента поста.""" conn = None try: @@ -484,7 +484,7 @@ class AsyncBotDB: if conn: await conn.close() - async def get_post_ids(self, last_post_id: int) -> List[int]: + async def get_post_ids(self, last_post_id: int) -> List: """Получение ID постов.""" conn = None try: @@ -540,7 +540,7 @@ class AsyncBotDB: if conn: await conn.close() - async def get_last_users(self, limit: int = 30) -> List[Tuple[str, int]]: + async def get_last_users(self, limit: int = 30) -> List: """Получение последних пользователей.""" conn = None try: @@ -626,7 +626,7 @@ class AsyncBotDB: if conn: await conn.close() - async def get_blacklist_users(self, offset: int = 0, limit: int = 10) -> List[Tuple[str, int, str, str]]: + async def get_blacklist_users(self, offset: int = 0, limit: int = 10) -> List: """Получение пользователей из черного списка.""" conn = None try: @@ -658,7 +658,7 @@ class AsyncBotDB: if conn: await conn.close() - async def get_users_for_unban_today(self, date_to_unban: str) -> List[Tuple[int, str]]: + async def get_users_for_unban_today(self, date_to_unban: str) -> List: """Получение пользователей для разблокировки сегодня.""" conn = None try: diff --git a/database/db.py b/database/db.py index 3d8715f..5f471b3 100644 --- a/database/db.py +++ b/database/db.py @@ -6,6 +6,14 @@ from concurrent.futures import ThreadPoolExecutor from logs.custom_logger import logger +# Local imports - metrics +from helper_bot.utils.metrics import ( + metrics, + track_time, + track_errors, + db_query_time +) + class BotDB: def __init__(self, current_dir, name): @@ -138,6 +146,9 @@ class BotDB: finally: self.close() + @track_time("add_new_user_in_db", "database") + @track_errors("database", "add_new_user_in_db") + @db_query_time("add_new_user_in_db", "our_users", "insert") def add_new_user_in_db(self, user_id: int, first_name: str, full_name: str, username: str, is_bot: bool, language_code: str, emoji: str, date_added: str, date_changed: str): """ @@ -189,6 +200,9 @@ class BotDB: finally: self.close() + @track_time("user_exists", "database") + @track_errors("database", "user_exists") + @db_query_time("user_exists", "our_users", "select") def user_exists(self, user_id: int): """ Проверяет, существует ли пользователь в базе данных. @@ -426,6 +440,9 @@ class BotDB: finally: self.close() + @track_time("get_info_about_stickers", "database") + @track_errors("database", "get_info_about_stickers") + @db_query_time("get_info_about_stickers", "our_users", "select") def get_info_about_stickers(self, user_id: int): """ Проверяет, получил ли пользователь стикеры. @@ -459,6 +476,9 @@ class BotDB: finally: self.close() + @track_time("update_info_about_stickers", "database") + @track_errors("database", "update_info_about_stickers") + @db_query_time("update_info_about_stickers", "our_users", "update") def update_info_about_stickers(self, user_id): """ Обновляет информацию о получении стикеров пользователем. @@ -623,6 +643,9 @@ class BotDB: finally: self.close() + @track_time("add_new_message_in_db", "database") + @track_errors("database", "add_new_message_in_db") + @db_query_time("add_new_message_in_db", "user_messages", "insert") def add_new_message_in_db(self, message_text: str, user_id: int, message_id: int, date: str): """ Добавляет новое сообщение пользователя в базу данных. @@ -657,6 +680,9 @@ class BotDB: finally: self.close() + @track_time("get_username_and_full_name", "database") + @track_errors("database", "get_username_and_full_name") + @db_query_time("get_username_and_full_name", "our_users", "select") def get_username_and_full_name(self, user_id: int): """ Получает full_name и username пользователя по ID из базы @@ -686,6 +712,9 @@ class BotDB: finally: self.close() + @track_time("update_username_and_full_name", "database") + @track_errors("database", "update_username_and_full_name") + @db_query_time("update_username_and_full_name", "our_users", "update") def update_username_and_full_name(self, user_id: int, username: str, full_name: str): """ Обновляет full_name и username пользователя @@ -715,6 +744,9 @@ class BotDB: finally: self.close() + @track_time("update_date_for_user", "database") + @track_errors("database", "update_date_for_user") + @db_query_time("update_date_for_user", "our_users", "update") def update_date_for_user(self, date: str, user_id: int): """ #TODO: Не возвращается ошибка sqlite3. Error. Тест не перехватывает. Возвращается no such table: our_users @@ -742,6 +774,9 @@ class BotDB: finally: self.close() + @track_time("check_emoji", "database") + @track_errors("database", "check_emoji") + @db_query_time("check_emoji", "our_users", "select") def check_emoji(self, emoji: str): """ Проверяет, есть ли уже такой emoji в таблице. @@ -767,6 +802,9 @@ class BotDB: finally: self.close() + @track_time("update_emoji_for_user", "database") + @track_errors("database", "update_emoji_for_user") + @db_query_time("update_emoji_for_user", "our_users", "update") def update_emoji_for_user(self, user_id: int, emoji: str): """ Обновляет эмодзи для пользователя в базе если его ранее не было установлено @@ -792,6 +830,9 @@ class BotDB: finally: self.close() + @track_time("check_emoji_for_user", "database") + @track_errors("database", "check_emoji_for_user") + @db_query_time("check_emoji_for_user", "our_users", "select") def check_emoji_for_user(self, user_id: int): """ Проверяет, есть ли уже у пользователя назначенный emoji. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..97485c8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,66 @@ +services: + telegram-bot: + build: + context: . + dockerfile: Dockerfile.bot + container_name: telegram-bot + restart: unless-stopped + ports: + - "8000:8000" # Экспозиция порта для метрик + environment: + - PYTHONPATH=/app + volumes: + - ./database:/app/database + - ./logs:/app/logs + - ./settings.ini:/app/settings.ini + networks: + - monitoring + depends_on: + - prometheus + - grafana + + prometheus: + image: prom/prometheus:latest + container_name: prometheus + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + - prometheus_data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--storage.tsdb.retention.time=200h' + - '--web.enable-lifecycle' + restart: unless-stopped + networks: + - monitoring + + grafana: + image: grafana/grafana:latest + container_name: grafana + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_USERS_ALLOW_SIGN_UP=false + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/dashboards:/etc/grafana/provisioning/dashboards + - ./grafana/datasources:/etc/grafana/provisioning/datasources + restart: unless-stopped + networks: + - monitoring + depends_on: + - prometheus + +volumes: + prometheus_data: + grafana_data: + +networks: + monitoring: + driver: bridge diff --git a/grafana/dashboards/dashboards.yml b/grafana/dashboards/dashboards.yml new file mode 100644 index 0000000..304cbc9 --- /dev/null +++ b/grafana/dashboards/dashboards.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: 'Telegram Bot Dashboards' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /etc/grafana/provisioning/dashboards diff --git a/grafana/dashboards/telegram-bot-dashboard.json b/grafana/dashboards/telegram-bot-dashboard.json new file mode 100644 index 0000000..0533ea8 --- /dev/null +++ b/grafana/dashboards/telegram-bot-dashboard.json @@ -0,0 +1,577 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "rate(bot_commands_total[5m])", + "refId": "A" + } + ], + "title": "Commands per Second", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "histogram_quantile(0.95, rate(method_duration_seconds_bucket[5m]))", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "histogram_quantile(0.99, rate(method_duration_seconds_bucket[5m]))", + "refId": "B" + } + ], + "title": "Method Response Time (P95, P99)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "rate(errors_total[5m])", + "refId": "A" + } + ], + "title": "Errors per Second", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "active_users", + "refId": "A" + } + ], + "title": "Active Users", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "histogram_quantile(0.95, rate(db_query_duration_seconds_bucket[5m]))", + "refId": "A" + } + ], + "title": "Database Query Time (P95)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "expr": "rate(messages_processed_total[5m])", + "refId": "A" + } + ], + "title": "Messages Processed per Second", + "type": "timeseries" + } + ], + "refresh": "5s", + "schemaVersion": 38, + "style": "dark", + "tags": [ + "telegram", + "bot", + "monitoring" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Telegram Bot Dashboard", + "uid": "telegram-bot", + "version": 1, + "weekStart": "" +} diff --git a/grafana/datasources/prometheus.yml b/grafana/datasources/prometheus.yml new file mode 100644 index 0000000..86fd346 --- /dev/null +++ b/grafana/datasources/prometheus.yml @@ -0,0 +1,8 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true diff --git a/helper_bot/examples/metrics_usage_examples.py b/helper_bot/examples/metrics_usage_examples.py new file mode 100644 index 0000000..b705ba8 --- /dev/null +++ b/helper_bot/examples/metrics_usage_examples.py @@ -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 diff --git a/helper_bot/handlers/admin/dependencies.py b/helper_bot/handlers/admin/dependencies.py index 7f5e370..39c5572 100644 --- a/helper_bot/handlers/admin/dependencies.py +++ b/helper_bot/handlers/admin/dependencies.py @@ -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 diff --git a/helper_bot/handlers/callback/callback_handlers.py b/helper_bot/handlers/callback/callback_handlers.py index 5ac806e..e18f359 100644 --- a/helper_bot/handlers/callback/callback_handlers.py +++ b/helper_bot/handlers/callback/callback_handlers.py @@ -1,5 +1,4 @@ import html -from tkinter import S import traceback from aiogram import Router diff --git a/helper_bot/handlers/group/constants.py b/helper_bot/handlers/group/constants.py index aa7b1ba..8f16169 100644 --- a/helper_bot/handlers/group/constants.py +++ b/helper_bot/handlers/group/constants.py @@ -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": "Не могу найти кому ответить в базе, проебали сообщение." } diff --git a/helper_bot/handlers/private/constants.py b/helper_bot/handlers/private/constants.py index 81bbd47..5b87a68 100644 --- a/helper_bot/handlers/private/constants.py +++ b/helper_bot/handlers/private/constants.py @@ -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' diff --git a/helper_bot/handlers/private/private_handlers.py b/helper_bot/handlers/private/private_handlers.py index 2b6b9f4..7a91ea6 100644 --- a/helper_bot/handlers/private/private_handlers.py +++ b/helper_bot/handlers/private/private_handlers.py @@ -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') diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py index 622c7b0..2950034 100644 --- a/helper_bot/handlers/private/services.py +++ b/helper_bot/handlers/private/services.py @@ -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 diff --git a/helper_bot/keyboards/keyboards.py b/helper_bot/keyboards/keyboards.py index 13e36e8..714ffcb 100644 --- a/helper_bot/keyboards/keyboards.py +++ b/helper_bot/keyboards/keyboards.py @@ -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 diff --git a/helper_bot/main.py b/helper_bot/main.py index a9e4106..051c991 100644 --- a/helper_bot/main.py +++ b/helper_bot/main.py @@ -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()) diff --git a/helper_bot/main_with_metrics.py b/helper_bot/main_with_metrics.py new file mode 100644 index 0000000..902763e --- /dev/null +++ b/helper_bot/main_with_metrics.py @@ -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()) diff --git a/helper_bot/middlewares/metrics_middleware.py b/helper_bot/middlewares/metrics_middleware.py new file mode 100644 index 0000000..76a3277 --- /dev/null +++ b/helper_bot/middlewares/metrics_middleware.py @@ -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 diff --git a/helper_bot/utils/helper_func.py b/helper_bot/utils/helper_func.py index 611f0bb..d73b7f9 100644 --- a/helper_bot/utils/helper_func.py +++ b/helper_bot/utils/helper_func.py @@ -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: diff --git a/helper_bot/utils/messages.py b/helper_bot/utils/messages.py index eeb17c3..4b5e84e 100644 --- a/helper_bot/utils/messages.py +++ b/helper_bot/utils/messages.py @@ -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!👋🏼&Меня зовут Виби, я бот канала 'Влюбленный Бийск'❤🤖" diff --git a/helper_bot/utils/metrics.py b/helper_bot/utils/metrics.py new file mode 100644 index 0000000..3261f5e --- /dev/null +++ b/helper_bot/utils/metrics.py @@ -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 diff --git a/helper_bot/utils/metrics_exporter.py b/helper_bot/utils/metrics_exporter.py new file mode 100644 index 0000000..c57b0a8 --- /dev/null +++ b/helper_bot/utils/metrics_exporter.py @@ -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 diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 0000000..fd60240 --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,26 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +rule_files: + # - "first_rules.yml" + # - "second_rules.yml" + +scrape_configs: + - job_name: 'telegram-bot' + static_configs: + - targets: ['telegram-bot:8000'] + metrics_path: '/metrics' + scrape_interval: 10s + scrape_timeout: 10s + honor_labels: true + + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + +alerting: + alertmanagers: + - static_configs: + - targets: + # - alertmanager:9093 diff --git a/pyproject.toml b/pyproject.toml index 8d5565a..e2863bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,9 @@ +[project] +name = "telegram-helper-bot" +version = "1.0.0" +description = "Telegram bot with monitoring and metrics" +requires-python = ">=3.9" + [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..1b4d9a4 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,13 @@ +# Development and testing dependencies +-r requirements.txt + +# Testing +pytest>=8.0.0 +pytest-asyncio>=0.23.0 +pytest-cov>=4.0.0 +coverage>=7.0.0 + +# Development tools +black>=23.0.0 +flake8>=6.0.0 +mypy>=1.0.0 diff --git a/requirements.txt b/requirements.txt index 08e5994..153bc6f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,10 +13,9 @@ psutil~=6.1.0 # Scheduling apscheduler~=3.10.4 -# Testing -pytest==8.2.2 -pytest-asyncio==1.1.0 -coverage==7.5.4 +# Metrics and monitoring +prometheus-client==0.19.0 +aiohttp==3.9.1 # Development tools pluggy==1.5.0 diff --git a/run_helper.py b/run_helper.py index 1b5be34..84d14e3 100644 --- a/run_helper.py +++ b/run_helper.py @@ -12,6 +12,7 @@ from helper_bot.main import start_bot from helper_bot.utils.base_dependency_factory import get_global_instance from helper_bot.server_monitor import ServerMonitor from helper_bot.utils.auto_unban_scheduler import get_auto_unban_scheduler +from helper_bot.utils.metrics_exporter import MetricsManager async def start_monitoring(bdf, bot): @@ -46,6 +47,9 @@ async def main(): auto_unban_scheduler.set_bot(monitor_bot) auto_unban_scheduler.start_scheduler() + # Инициализируем метрики + metrics_manager = MetricsManager(host="0.0.0.0", port=8000) + # Флаг для корректного завершения shutdown_event = asyncio.Event() @@ -58,9 +62,10 @@ async def main(): signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) - # Запускаем бота и мониторинг + # Запускаем бота, мониторинг и метрики bot_task = asyncio.create_task(start_bot(bdf)) monitor_task = asyncio.create_task(monitor.monitor_loop()) + metrics_task = asyncio.create_task(metrics_manager.start()) try: # Ждем сигнала завершения @@ -80,14 +85,18 @@ async def main(): print("Останавливаем планировщик автоматического разбана...") auto_unban_scheduler.stop_scheduler() + print("Останавливаем метрики...") + await metrics_manager.stop() + print("Останавливаем задачи...") # Отменяем задачи bot_task.cancel() monitor_task.cancel() + metrics_task.cancel() # Ждем завершения задач try: - await asyncio.gather(bot_task, monitor_task, return_exceptions=True) + await asyncio.gather(bot_task, monitor_task, metrics_task, return_exceptions=True) except Exception as e: print(f"Ошибка при остановке задач: {e}") @@ -97,4 +106,12 @@ async def main(): if __name__ == '__main__': - asyncio.run(main()) + try: + asyncio.run(main()) + except AttributeError: + # Fallback for Python 3.6-3.7 + loop = asyncio.get_event_loop() + try: + loop.run_until_complete(main()) + finally: + loop.close() diff --git a/run_metrics_only.py b/run_metrics_only.py new file mode 100644 index 0000000..2911b36 --- /dev/null +++ b/run_metrics_only.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +""" +Standalone metrics server for testing. +Run this to start just the metrics system without the bot. +""" + +import asyncio +import signal +import sys +from helper_bot.utils.metrics_exporter import MetricsManager + + +class MetricsServer: + """Standalone metrics server.""" + + def __init__(self, host: str = "0.0.0.0", port: int = 8000): + self.host = host + self.port = port + self.metrics_manager = MetricsManager(host, port) + self.running = False + + async def start(self): + """Start the metrics server.""" + try: + await self.metrics_manager.start() + self.running = True + print(f"🚀 Metrics server started on {self.host}:{self.port}") + print(f"📊 Metrics endpoint: http://{self.host}:{self.port}/metrics") + print(f"🏥 Health check: http://{self.host}:{self.port}/health") + print(f"ℹ️ Info: http://{self.host}:{self.port}/") + print("\nPress Ctrl+C to stop the server") + + # Keep the server running + while self.running: + await asyncio.sleep(1) + + except Exception as e: + print(f"❌ Error starting metrics server: {e}") + raise + + async def stop(self): + """Stop the metrics server.""" + if self.running: + self.running = False + await self.metrics_manager.stop() + print("\n🛑 Metrics server stopped") + + def signal_handler(self, signum, frame): + """Handle shutdown signals.""" + print(f"\n📡 Received signal {signum}, shutting down...") + asyncio.create_task(self.stop()) + + +async def main(): + """Main function.""" + # Parse command line arguments + host = "0.0.0.0" + port = 8000 + + if len(sys.argv) > 1: + host = sys.argv[1] + if len(sys.argv) > 2: + port = int(sys.argv[2]) + + # Create and start server + server = MetricsServer(host, port) + + # Setup signal handlers + signal.signal(signal.SIGINT, server.signal_handler) + signal.signal(signal.SIGTERM, server.signal_handler) + + try: + await server.start() + except KeyboardInterrupt: + print("\n📡 Keyboard interrupt received") + finally: + await server.stop() + + +if __name__ == "__main__": + print("🔧 Starting standalone metrics server...") + print("Usage: python run_metrics_only.py [host] [port]") + print("Default: host=0.0.0.0, port=8000") + print() + + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\n🛑 Server stopped by user") + except Exception as e: + print(f"❌ Server error: {e}") + sys.exit(1) diff --git a/start_docker.sh b/start_docker.sh new file mode 100755 index 0000000..433eebf --- /dev/null +++ b/start_docker.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +echo "🐍 Запуск Telegram Bot с Python 3.9 (стандартная версия)..." +echo "" + +echo "🔧 Сборка Docker образа с Python 3.9..." +make build + +echo "" +echo "🚀 Запуск сервисов..." +make up + +echo "" +echo "🐍 Проверка версии Python в контейнере..." +make check-python + +echo "" +echo "📦 Проверка установленных пакетов..." +docker exec telegram-bot .venv/bin/pip list + +echo "" +echo "✅ Сервисы успешно запущены!" +echo "" +echo "📝 Полезные команды:" +echo " Логи бота: make logs-bot" +echo " Статус: make status" +echo " Остановка: make stop" +echo " Перезапуск: make restart" +echo "" +echo "📊 Мониторинг:" +echo " Prometheus: http://localhost:9090" +echo " Grafana: http://localhost:3000 (admin/admin)" diff --git a/voice_bot_v2.py b/voice_bot_v2.py deleted file mode 100644 index d94f598..0000000 --- a/voice_bot_v2.py +++ /dev/null @@ -1,9 +0,0 @@ -import asyncio - -from helper_bot.utils.base_dependency_factory import get_global_instance -from voice_bot.main import start_bot - -bdf = get_global_instance() - -if __name__ == '__main__': - asyncio.run(start_bot(get_global_instance()))