Шаг 07. Логирование, prod-конфиг и финальный чеклист

📁 Серия: Капстоун A ⏱️ ~40 мин 🎯 Сложность: Средняя
#logging #postman #checklist

⚡ Кратко: что делаем на этом шаге

Цель: Настроить Python logging (ротируемые файлы + stdout), production-конфиг через .env, проверить API в Postman, пройти финальный чеклист production-ready приложения.

  • Файлы: config.py (обновление), app/__init__.py (logging), .env.example
  • Инструмент: Postman / curl для полного smoke-тест прогона
  • Результат: проект готов к деплою: конфиг через env, логирование, все эндпоинты проверены

🎯 Цель этапа

Последний шаг переводит проект из «работает на моём ноутбуке» в «готово к деплою». Три задачи: настроить стандартное Python-логирование (не print!), вынести конфиг в переменные окружения, провести полный smoke-тест через Postman.

💡 Почему logging, а не print: print() не имеет уровней (DEBUG/INFO/WARNING/ERROR), не поддерживает ротацию файлов, не различает источник сообщения и нельзя отключить в продакшне. Python logging решает всё это. В Flask логирование уже встроено: app.logger — это стандартный logging.Logger.

После этого шага у нас будет

  • Логирование в stdout (dev) и в ротируемый файл (prod)
  • Все секреты и URLs в переменных окружения
  • Полная Postman-коллекция / curl smoke-тест
  • Пройденный production-ready чеклист

📄 Затрагиваемые файлы

ФайлДействиеОписание
app/logging_config.pyСоздатьКонфигурация Python logging
app/__init__.pyИзменитьПодключить logging в create_app()
config.pyИзменитьДобавить LOG_LEVEL в конфиги
.env.exampleИзменитьПолный список переменных окружения

🔨 Шаги

1. Конфигурация логирования

📄 app/logging_config.py
# app/logging_config.py
import logging
import logging.handlers
import os
from flask import Flask


def configure_logging(app: Flask) -> None:
    """
    Настраивает логирование для Flask-приложения.

    Dev (DEBUG=True):  лог в stdout, уровень DEBUG
    Prod (DEBUG=False): лог в файл с ротацией (10 МБ, 5 файлов) + stdout WARNING
    """
    log_level_str: str = app.config.get("LOG_LEVEL", "INFO")
    log_level: int = getattr(logging, log_level_str.upper(), logging.INFO)

    # Формат логов: время · уровень · модуль · сообщение
    fmt = logging.Formatter(
        fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    )

    # Очищаем существующие handlers (Flask добавляет свой по умолчанию)
    app.logger.handlers.clear()

    # Handler 1: stdout — всегда
    stdout_handler = logging.StreamHandler()
    stdout_handler.setFormatter(fmt)
    stdout_handler.setLevel(log_level)
    app.logger.addHandler(stdout_handler)

    # Handler 2: ротируемый файл — только в production
    if not app.debug:
        logs_dir = os.path.join(os.path.dirname(app.root_path), "logs")
        os.makedirs(logs_dir, exist_ok=True)
        log_path = os.path.join(logs_dir, "community_pulse.log")

        file_handler = logging.handlers.RotatingFileHandler(
            log_path,
            maxBytes=10 * 1024 * 1024,  # 10 МБ
            backupCount=5,
            encoding="utf-8",
        )
        file_handler.setFormatter(fmt)
        file_handler.setLevel(logging.WARNING)  # в файл только WARNING и выше
        app.logger.addHandler(file_handler)

    app.logger.setLevel(log_level)
    app.logger.info("Logging configured: level=%s debug=%s", log_level_str, app.debug)

2. Обновляем config.py — добавляем LOG_LEVEL

📄 config.py (финальная версия)
# config.py
import os
from dotenv import load_dotenv

load_dotenv()


class Config:
    SECRET_KEY: str = os.environ.get("SECRET_KEY", "dev-secret-key-change-in-prod")
    SQLALCHEMY_TRACK_MODIFICATIONS: bool = False
    LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")


class DevelopmentConfig(Config):
    DEBUG: bool = True
    LOG_LEVEL: str = "DEBUG"
    SQLALCHEMY_DATABASE_URI: str = os.environ.get(
        "DEV_DATABASE_URL",
        "sqlite:///community_pulse_dev.db",
    )
    # Логировать SQL-запросы в dev (полезно для отладки)
    SQLALCHEMY_ECHO: bool = os.environ.get("SQLALCHEMY_ECHO", "false").lower() == "true"


class TestingConfig(Config):
    TESTING: bool = True
    LOG_LEVEL: str = "WARNING"
    SQLALCHEMY_DATABASE_URI: str = "sqlite:///:memory:"
    WTF_CSRF_ENABLED: bool = False


class ProductionConfig(Config):
    DEBUG: bool = False
    LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "WARNING")
    SQLALCHEMY_DATABASE_URI: str = os.environ.get("DATABASE_URL", "")

    def __init__(self) -> None:
        if not self.SQLALCHEMY_DATABASE_URI:
            raise RuntimeError(
                "DATABASE_URL environment variable is not set. "
                "Set it to a PostgreSQL connection string."
            )


config_map: dict[str, type[Config]] = {
    "development": DevelopmentConfig,
    "testing": TestingConfig,
    "production": ProductionConfig,
}

3. Подключаем logging в create_app()

📄 app/__init__.py (финальная полная версия)
# app/__init__.py
from flask import Flask, jsonify
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate

from config import config_map, Config

db: SQLAlchemy = SQLAlchemy()
migrate: Migrate = Migrate()


def create_app(config_name: str = "development") -> Flask:
    """
    Application factory — создаёт настроенный экземпляр Flask.

    Args:
        config_name: 'development' | 'testing' | 'production'

    Returns:
        Сконфигурированный Flask app
    """
    app = Flask(__name__)
    config_class: type[Config] = config_map[config_name]
    app.config.from_object(config_class)

    # Расширения
    db.init_app(app)
    migrate.init_app(app, db)

    # Импорт моделей для Alembic auto-detect
    with app.app_context():
        from app.models import Question, Response  # noqa: F401

    # API blueprints
    from app.api import register_blueprints
    register_blueprints(app)

    # Error handlers
    from app.errors import register_error_handlers
    register_error_handlers(app)

    # Логирование
    from app.logging_config import configure_logging
    configure_logging(app)

    @app.route("/health")
    def health_check():
        app.logger.debug("Health check called")
        return jsonify({"status": "ok", "env": config_name}), 200

    app.logger.info("Application '%s' created with config '%s'", app.name, config_name)
    return app

4. Обновлённый .env.example

📄 .env.example
# .env.example — скопируйте в .env и заполните своими значениями
# НЕ коммитьте .env в git!

# Окружение: development | testing | production
APP_ENV=development

# Секретный ключ Flask (обязателен в production, минимум 32 символа)
SECRET_KEY=your-very-long-secret-key-change-this-in-production

# URL базы данных
# Для разработки (SQLite):
DEV_DATABASE_URL=sqlite:///community_pulse_dev.db
# Для production (PostgreSQL):
# DATABASE_URL=postgresql://user:password@localhost:5432/community_pulse

# Уровень логирования: DEBUG | INFO | WARNING | ERROR
LOG_LEVEL=INFO

# Логировать SQL-запросы (только для отладки в dev):
# SQLALCHEMY_ECHO=true

5. Добавляем logs/ в .gitignore

📄 .gitignore (дополнение)
# Добавьте в существующий .gitignore:
logs/
*.log

🧠 Объяснение логики

Стратегия логирования: dev vs prod

В разработке мы хотим видеть все DEBUG-сообщения в терминале — это помогает отслеживать каждый запрос и SQL-запрос. В production важны только WARNING и ERROR — весь поток DEBUG-логов будет огромным и бесполезным. Поэтому два handler'а: stdout всегда, файл только в prod с уровнем WARNING.

RotatingFileHandler

RotatingFileHandler ограничивает размер лог-файла: когда файл достигает maxBytes, он переименовывается в .log.1, создаётся новый. Хранятся последние backupCount архивов. Без ротации лог-файлы растут до переполнения диска.

Переменные окружения — единственный источник секретов

SECRET_KEY, пароли БД, API-ключи — никогда в коде или git. python-dotenv читает .env при разработке. В production эти переменные устанавливает хостинг-платформа (Heroku, Railway, Docker Compose, Kubernetes secrets).

⚠️ Встроенный Flask dev-сервер не для production! python run.py запускает Werkzeug dev-сервер — однопоточный, без TLS, без graceful reload. Для production нужен WSGI-сервер: gunicorn или waitress (Windows).

✅ Полный smoke-тест (Postman / curl)

Запускаем сервер

💻 Терминал 1
python run.py

Smoke-тест — последовательность запросов

💻 Терминал 2 (или Postman)
# 1. Health check
curl http://localhost:5000/health
# Ожидаем: {"env": "development", "status": "ok"}

# 2. Создать 2 вопроса
curl -X POST http://localhost:5000/api/questions/ \
  -H "Content-Type: application/json" \
  -d "{\"text\": \"Согласны ли вы с четырёхдневной рабочей неделей?\"}"

curl -X POST http://localhost:5000/api/questions/ \
  -H "Content-Type: application/json" \
  -d "{\"text\": \"Стоит ли переходить на Python 3.14?\"}"

# 3. Список вопросов
curl http://localhost:5000/api/questions/
# Ожидаем: {"items": [...], "total": 2}

# 4. Поиск по тексту
curl "http://localhost:5000/api/questions/?search=python"
# Ожидаем: один вопрос про Python

# 5. Добавить голоса
curl -X POST http://localhost:5000/api/questions/1/responses/ \
  -H "Content-Type: application/json" \
  -d "{\"is_agree\": true}"
curl -X POST http://localhost:5000/api/questions/1/responses/ \
  -H "Content-Type: application/json" \
  -d "{\"is_agree\": true}"
curl -X POST http://localhost:5000/api/questions/1/responses/ \
  -H "Content-Type: application/json" \
  -d "{\"is_agree\": false}"

# 6. Статистика
curl http://localhost:5000/api/stats/questions/
# Ожидаем: agree_count=2, disagree_count=1, total_count=3, agree_percent=66.7

curl http://localhost:5000/api/stats/questions/1
# Ожидаем: статистика по вопросу 1

# 7. Обновить вопрос
curl -X PUT http://localhost:5000/api/questions/2 \
  -H "Content-Type: application/json" \
  -d "{\"text\": \"Стоит ли изучать Python 3.14 прямо сейчас?\"}"

# 8. Тест валидации — слишком короткий текст
curl -X POST http://localhost:5000/api/questions/ \
  -H "Content-Type: application/json" \
  -d "{\"text\": \"Hi\"}"
# Ожидаем: 422 Unprocessable Entity

# 9. Несуществующий ресурс — JSON, не HTML
curl http://localhost:5000/api/questions/99999
# Ожидаем: {"error": "Not found", "detail": "..."}  код 404

# 10. Удалить вопрос (каскадно удалятся его ответы)
curl -X DELETE http://localhost:5000/api/questions/1
curl http://localhost:5000/api/questions/1/responses/
# Ожидаем: 404 (вопрос удалён)

Ожидаемый результат

Все 10 шагов smoke-теста проходят:
  • Health check → 200 OK
  • CRUD вопросов работает (GET/POST/PUT/DELETE)
  • Фильтрация ilike работает
  • Голосование работает
  • Статистика считается верно
  • Валидация Pydantic отклоняет короткий текст (422)
  • 404 возвращается в JSON, не в HTML
  • Каскадное удаление работает

✅ Финальный чеклист production-ready

Архитектура и код

  • Application Factory — нет глобального app
  • Config-классы без FLASK_ENV
  • Blueprints — маршруты разбиты по модулям
  • Pydantic v2 — входные данные валидируются, выходные сериализуются
  • SQLAlchemy 2.x — Mapped[T] / mapped_column()

База данных

  • Flask-Migrate — миграции в git, не db.create_all()
  • Каскадное удаление — нет осиротевших записей
  • Индекс на question_id в таблице ответов
  • Агрегация через SQL GROUP BY, не N+1 запросов

Безопасность и конфиг

  • Секреты в .env, не в коде
  • .env в .gitignore
  • Централизованные error handlers (JSON, не HTML)
  • Логирование через Python logging, не print()

Что добавить для настоящего production

⚠️ Проверить по документации — следующие шаги выходят за рамки этого капстоуна:
  • WSGI-сервер: gunicorn (Linux/Mac) или waitress (Windows)
  • PostgreSQL вместо SQLite в production
  • Rate limiting: Flask-Limiter
  • Аутентификация: Flask-JWT-Extended или flask-login
  • CORS: flask-cors для frontend
  • Тесты: pytest + pytest-flask
  • Контейнеризация: Dockerfile + docker-compose

🎓 Что дальше

Вы завершили Капстоун A — Community Pulse API. Проект демонстрирует production-grade подход к Flask-приложению: factory, config-классы, SQLAlchemy 2.x, Flask-Migrate, Pydantic v2, blueprints, централизованные ошибки и логирование.

Темы callout-verify из уроков 09 и 11 закрыты: правильный конфиг без FLASK_ENV, Mapped/mapped_column, фильтрация через ilike.

Продолжение в следующих капстоунах