Логирование и обработка ошибок

Учим «Цыплёнка-помощника» вести дневник своих действий и не падать замертво, когда что-то идёт не так, а спокойно извиняться перед пользователем.
Логирование — это привычка бота записывать в специальный журнал, что он делал и где споткнулся: кто, когда, какую команду нажал и какая ошибка вылетела. Без такого журнала ты как врач без анализов — лечишь вслепую.

Привет! В прошлых уроках наш «Цыплёнок-помощник» уже умеет отвечать на команды, показывать кнопки, вести диалог через анкету и даже ходить за погодой в большой интернет. И вот ты впервые выкатил его «в люди»: скинул ссылку друзьям в чат игрового клана, гордо смотришь, как он отвечает… а через десять минут пишет одноклассник: «слушай, твой бот завис, ничего не пишет». Ты открываешь терминал, а там бот молча упал. И главный вопрос — почему? — повисает в воздухе. Этому уроку и посвящено: научить бота рассказывать, что с ним происходит, и не умирать от первой же ошибки.

Зачем боту дневник

Представь, что ты ведёшь дневник тренировок. Сегодня пробежал 3 км, завтра 5, послезавтра колено заболело. Когда колено заболит сильно, ты открываешь дневник и видишь: «ага, я слишком резко увеличил нагрузку вот в этот день». Без записей ты бы просто гадал.

С ботом то же самое. Пока он крутится на твоём компьютере и ты сидишь рядом — ты видишь всё прямо в терминале. Но настоящий бот работает без тебя: на сервере, ночью, пока ты спишь или сидишь на уроке. Когда утром кто-то напишет «бот сломался», единственный способ понять, что случилось — это заглянуть в его журнал (лог). Если журнала нет, ты узнаешь только факт «сломался», но не причину.

Многие новички решают эту задачу через print(): натыкают по коду print("сюда дошли") и смотрят в консоль. Это работает, пока ты рядом. Но print() не умеет ставить время, не различает «обычное событие» и «жуткую ошибку», и его невозможно аккуратно выключить или записать в файл. Поэтому в Python есть специальный инструмент — модуль logging.

logging — это умный print со штампиком

Главное отличие logging от print() — у каждого сообщения есть уровень важности, как у новостей: бывает «просто к сведению», а бывает «срочно, всё горит». Уровней пять, от самого спокойного к самому тревожному:

DEBUGМелкие подробности для отладки: «зашёл в функцию», «переменная равна 5». Обычно скрыты.
INFOНормальные события: «бот запустился», «пользователь нажал /start».
WARNINGЧто-то странное, но бот выжил: «API ответил медленно», «пустое сообщение».
ERRORПроизошла ошибка, действие не выполнилось: «не смог отправить сообщение».
CRITICALСовсем плохо, бот может упасть целиком.

Ты говоришь логгеру: «показывай мне всё начиная с уровня INFO» — и тогда DEBUG-болтовня прячется, а важное остаётся. Поменял настройку на DEBUG — и видишь вообще каждый шаг. Удобно: один переключатель регулирует «громкость» бота.

Настраиваем logging в боте

Давай сначала разберёмся с самим модулем на маленьком чистом примере, который можно запустить прямо в браузере — без всякого Telegram. Тут нет ни bot, ни dp, только стандартная библиотека Python.

import logging

# basicConfig настраивает журнал один раз на всю программу
logging.basicConfig(
    level=logging.INFO,  # показывать INFO и всё, что тревожнее
    format="%(asctime)s %(levelname)s %(message)s",
)

logging.debug("Это мелкая деталь — её не покажут, уровень ниже INFO")
logging.info("Бот запустился")
logging.warning("Пользователь прислал пустое сообщение")
logging.error("Не получилось сохранить данные")

Вывод:

2026-06-19 10:00:00,000 INFO Бот запустился
2026-06-19 10:00:00,001 WARNING Пользователь прислал пустое сообщение
2026-06-19 10:00:00,002 ERROR Не получилось сохранить данные

Видишь? Строчку с debug журнал проглотил, потому что мы попросили показывать от INFO и выше. А у каждой оставшейся строки спереди штампик: дата, время, уровень. Это и есть то, чего print() дать не может.

Разберём basicConfig по косточкам:

  • level=logging.INFO — порог «громкости». Всё, что тише INFO (то есть DEBUG), не печатается.
  • format="..." — шаблон строки. %(asctime)s подставит время, %(levelname)s — уровень, %(message)s — сам текст. Эти три кусочка как поля в почтовом штампе.

Подключаем журнал к «Цыплёнку»

Теперь то же самое, но в нашем файле bot.py. Код бота на aiogram в браузере не запускается (там сервер и async), поэтому даю его как обычный текст, а ниже описываю, что ты увидишь в терминале.

import logging
import os
import asyncio

from aiogram import Bot, Dispatcher
from aiogram.filters import CommandStart
from aiogram.types import Message

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)

bot = Bot(token=os.environ["BOT_TOKEN"])
dp = Dispatcher()


@dp.message(CommandStart())
async def start_handler(message: Message):
    logger.info("Пользователь %s нажал /start", message.from_user.id)
    await message.answer("Привет! Я Цыплёнок-помощник 🐤")


async def main():
    logger.info("Бот запускается...")
    await dp.start_polling(bot)


if __name__ == "__main__":
    asyncio.run(main())

Результат: в чате бот ответит «Привет! Я Цыплёнок-помощник 🐤», а у тебя в терминале появятся строчки вроде 2026-06-19 10:00:00 INFO __main__: Бот запускается... и 2026-06-19 10:00:05 INFO __main__: Пользователь 123456789 нажал /start. Теперь ты видишь, кто и когда дёргает бота.

Пара важных деталей:

  • logger = logging.getLogger(__name__) — создаём «свой» логгер с именем текущего файла. Так в большом проекте будет видно, из какого модуля прилетела запись (%(name)s в формате как раз это и печатает).
  • logger.info("Пользователь %s нажал /start", message.from_user.id) — обрати внимание: мы не склеиваем строку через + или f-строку, а ставим %s и передаём значение отдельным аргументом. Это привычка профи: если уровень INFO выключен, logging даже не будет тратить силы на сборку строки.

Пишем журнал не только в консоль, но и в файл

Пока бот крутится в твоём терминале, логи бегут прямо на экран. Но как только ты закроешь окно — вся история пропадёт. А ведь самое интересное часто случается именно тогда, когда ты не смотришь. Поэтому журнал стоит дублировать в файл: тогда наутро ты просто откроешь его и прочитаешь всё, что бот накопил за ночь.

Самый простой способ — добавить в basicConfig параметр filename. Покажу на чистом примере (его можно запустить, но он создаст файл, поэтому смотри на сам код — суть в настройке):

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
    handlers=[
        logging.FileHandler("bot.log", encoding="utf-8"),  # пишем в файл
        logging.StreamHandler(),  # и одновременно в консоль
    ],
)

logging.info("Эта строка попадёт и на экран, и в файл bot.log")
print("Готово: загляни в файл bot.log рядом с программой")

Вывод:

2026-06-19 10:00:00,000 INFO Эта строка попадёт и на экран, и в файл bot.log
Готово: загляни в файл bot.log рядом с программой

Тут вместо одного места вывода мы передали список handlers — «приёмников» записей. FileHandler("bot.log") складывает всё в файл (обязательно с encoding="utf-8", иначе русские буквы и эмодзи могут превратиться в кракозябры), а StreamHandler() по-прежнему печатает на экран. Один logger.info(...) — и запись летит сразу в оба места. Когда выкатишь «Цыплёнка» на сервер, именно файл bot.log станет твоим главным свидетелем.

Учимся читать traceback — это не страшно

Когда что-то ломается, Python печатает traceback — это не ругань, а маршрут ошибки: «я был тут, потом зашёл туда, и вот на этой строчке всё рухнуло». Читать его нужно снизу вверх: самая нижняя строка говорит, что случилось, а строки выше — где по пути это произошло. Например:

Traceback (most recent call last):
  File "bot.py", line 18, in start_handler
    result = 10 / 0
ZeroDivisionError: division by zero

Результат: нижняя строка ZeroDivisionError: division by zero прямо говорит — «деление на ноль», а строка выше показывает файл bot.py, строку 18 и функцию start_handler. То есть ты сразу знаешь и причину, и адрес. Именно такой traceback и приложит logger.exception(...) к твоей записи в журнале — поэтому он так ценен.

Глобальный обработчик ошибок

Журнал — это половина дела. Вторая половина — сделать так, чтобы бот не падал от ошибки. Сейчас, если внутри любого хэндлера вылетит исключение (например, ты обратился к data["name"], а такого ключа нет), aiogram честно напечатает в консоль длинный страшный traceback, а пользователь… просто не получит ответа. Он будет сидеть и думать, что бот его игнорирует.

Аналогия: бот — это официант. Если на кухне разбилась тарелка, плохой официант просто исчезает и не возвращается к столику. Хороший — подходит и говорит: «Извините, небольшая заминка, сейчас всё исправим». Глобальный обработчик ошибок учит «Цыплёнка» быть хорошим официантом.

В aiogram 3.x для этого есть специальный декоратор @dp.errors(). Хэндлер с этим декоратором ловит любую ошибку, которая вылетела в обычных хэндлерах, — как сетка-страховка под канатоходцем.

import logging

from aiogram import Bot, Dispatcher
from aiogram.filters import CommandStart
from aiogram.types import Message, ErrorEvent

logger = logging.getLogger(__name__)

dp = Dispatcher()


@dp.message(CommandStart())
async def start_handler(message: Message):
    # специально ломаем: делим на ноль, чтобы поймать ошибку
    result = 10 / 0
    await message.answer(f"Результат: {result}")


@dp.errors()
async def errors_handler(event: ErrorEvent):
    # event.exception — само исключение, event.update — что пришло от Telegram
    logger.exception("Ошибка при обработке обновления: %s", event.exception)

    # пробуем вежливо ответить пользователю, если знаем, куда писать
    if event.update.message:
        await event.update.message.answer(
            "Ой, я споткнулся 🐤 Уже разбираюсь, попробуй ещё раз чуть позже."
        )
    return True  # говорим aiogram: ошибку обработали, можно жить дальше

Результат: пользователь нажимает /start, внутри случается деление на ноль, но вместо тишины он видит «Ой, я споткнулся 🐤 Уже разбираюсь, попробуй ещё раз чуть позже.», а в терминале появляется запись уровня ERROR с полным traceback'ом — ты потом спокойно его прочитаешь и починишь.

Разберём ключевые места:

  • event: ErrorEvent — это специальный объект aiogram, внутри которого лежит event.exception (само исключение) и event.update (то обновление, на котором всё сломалось).
  • logger.exception(...) — особый метод: он пишет сообщение уровня ERROR и автоматически добавляет traceback. Использовать его можно только внутри блока обработки ошибки. Это твой лучший друг при отладке.
  • if event.update.message: — проверяем, что ошибка случилась именно на сообщении (а не, скажем, на нажатии inline-кнопки), чтобы знать, куда отправить извинение.
  • return True — сигнал диспетчеру «я разрулил, дальше можно работать». Без него aiogram может посчитать обновление необработанным.

Ловим конкретную ошибку прямо в хэндлере

Глобальный обработчик — это сетка-страховка на крайний случай. Но если ты заранее знаешь, что какое-то место рискованное (например, поход за погодой по сети, как в уроке про хэндлеры сообщений мы учились отвечать на текст), лучше обернуть именно его в try/except и обработать аккуратно на месте.

@dp.message(Command("weather"))
async def weather_handler(message: Message):
    try:
        temp = await get_weather("Москва")  # вдруг сеть отвалится
    except Exception:
        logger.exception("Не смог получить погоду")
        await message.answer("Не дотянулся до прогноза 🌧 Попробуй через минуту.")
        return

    await message.answer(f"Сейчас в Москве {temp}°C")

Результат: если сервис погоды доступен — бот пришлёт «Сейчас в Москве 5°C»; если сеть подвела — пользователь получит «Не дотянулся до прогноза 🌧 Попробуй через минуту.», а ты — запись об ошибке в журнале. Заметь: тут мы решаем проблему точечно и даём пользователю осмысленный совет, а не общую отписку.

Правило простое: известный риск ловим на месте через try/except с понятным сообщением, а глобальный @dp.errors() держим как страховку от того, что мы не предусмотрели.

Частые ошибки и подводные камни

Вот на чём чаще всего спотыкаются новички, когда добавляют логирование и обработку ошибок.

  • Забыли вызвать basicConfig — и логов нет. Если просто написать logging.info("привет") без настройки, Python по умолчанию показывает только WARNING и выше, поэтому твои info-записи будто исчезают. Решение: один раз в начале bot.py вызвать logging.basicConfig(level=logging.INFO).
  • Ловят ошибку и молча её прячут. Самое опасное — написать except Exception: pass. Так ошибка действительно исчезает с глаз, но вместе с ней исчезает и причина: бот тихо не работает, а ты не знаешь почему. Всегда хотя бы логируй: except Exception: logger.exception("...").
  • Пишут токен в лог. Соблазнительно при отладке вывести logger.info("токен: %s", BOT_TOKEN). Не делай этого никогда: лог-файл легко утекает (скриншот, репозиторий, чужой сервер), а токен — это пароль от твоего бота. Кто его получит, тот заберёт бота себе.
  • Используют logger.error вместо logger.exception внутри except. Оба напишут сообщение уровня ERROR, но только exception приложит traceback. А без traceback ты увидишь «что-то сломалось», но не где именно — и снова гадаешь.
  • Возвращают извинение, но не делают return после него. В try/except после отправки сообщения об ошибке нужно прервать функцию (return), иначе код пойдёт дальше с «битыми» данными и упадёт второй раз, уже по-настоящему.

Мини-практика: «Цыплёнок» ведёт честный дневник

Теперь твоя очередь. Возьми свой bot.py и доработай его по шагам:

  1. В самом начале файла настрой logging.basicConfig с уровнем INFO и форматом, где есть время, уровень и имя логгера. Создай свой логгер через logging.getLogger(__name__).
  2. В хэндлере /start добавь запись logger.info(...), которая печатает id и имя пользователя (message.from_user.first_name) — передавай их через %s, а не f-строкой.
  3. Добавь команду /divide, которая просит два числа в одном сообщении (например «10 0»), делит первое на второе и шлёт результат. Оберни деление в try/except: при делении на ноль ответь пользователю «На ноль делить нельзя 🙅», а в журнал запиши logger.exception(...).
  4. Добавь глобальный @dp.errors()-хэндлер, который логирует любую непойманную ошибку и отвечает пользователю общим извинением.

Проверь себя так: отправь боту «/divide 10 0» — он должен вежливо ответить про ноль, а не упасть, и при этом оставить запись в терминале. Потом отправь «/divide привет мир» — числа не распарсятся, вылетит другая ошибка, и тут уже сработает твоя глобальная сетка-страховка. Если оба случая обработаны и бот жив — ты справился!

Итоги

Сегодня «Цыплёнок-помощник» повзрослел: он перестал быть капризным малышом, который замолкает при первой неприятности, и стал ответственным помощником, который ведёт дневник и умеет извиняться. Что мы освоили:

  • Модуль logging — умный print() со штампиком времени и уровнем важности; настраивается один раз через basicConfig.
  • Пять уровней от DEBUG до CRITICAL — регулятор «громкости» бота.
  • Глобальный @dp.errors() — сетка-страховка, которая ловит любую непойманную ошибку, логирует её и даёт пользователю понятный ответ вместо тишины.
  • Точечный try/except с logger.exception — для мест, где ты заранее ждёшь беды (сеть, парсинг чисел, чужие ключи в данных).

Теперь, когда бот умеет рассказывать о себе и держать удар, самое время подумать о его защите от назойливых пользователей. В следующем уроке мы займёмся middleware и throttling'ом — научим «Цыплёнка» вежливо притормаживать тех, кто долбит команды по сто раз в секунду, чтобы бот не падал от спама. До встречи! 🐤

Проверьте себя
1. Чем модуль logging принципиально лучше обычного print() для бота, который работает на сервере без тебя?
Alogging работает быстрее, чем print(), на больших объёмах
BУ сообщений logging есть уровень важности, время и их можно отфильтровать или записать в файл
Cprint() вообще нельзя использовать внутри async-функций
Dlogging автоматически чинит ошибки в коде
2. Ты написал logging.info("бот стартовал"), но в терминале ничего не появилось. В чём вероятная причина?
AНе вызван logging.basicConfig, и по умолчанию показываются только WARNING и выше
Binfo нельзя писать русскими буквами
Clogging.info нужно вызывать с await
DСообщения уровня INFO Python печатает только в файл, никогда в консоль
3. Какой декоратор в aiogram 3.x ловит любую непойманную ошибку из хэндлеров?
A@dp.message()
B@dp.catch()
C@dp.errors()
D@dp.exception()
4. Почему внутри блока except лучше вызывать logger.exception(...), а не logger.error(...)?
Alogger.exception работает быстрее
Blogger.exception сам добавляет к записи traceback, показывая, где именно сломалось
Clogger.error не печатает текст сообщения
Dlogger.exception автоматически отправляет ошибку пользователю в чат
5. Почему категорически нельзя писать токен бота в лог (например logger.info("токен: %s", BOT_TOKEN))?
AЛоги становятся слишком длинными
BТокен — это пароль от бота, а лог легко утекает (скриншот, репозиторий, чужой сервер)
Clogging не умеет печатать длинные строки
DTelegram блокирует ботов, чьи токены попали в лог
6. Чем точечный try/except в конкретном хэндлере отличается по роли от глобального @dp.errors()?
AОни делают одно и то же, разницы нет
Btry/except — для заранее известных рисков с понятным ответом, а @dp.errors() — сетка-страховка от непредвиденного
C@dp.errors() ловит только ошибки сети, а try/except — все остальные
Dtry/except работает только в синхронном коде, а @dp.errors() — в асинхронном