Аналитика для Telegram-бота своими руками: middleware в aiogram 3, батчи, ретраи и грабли асинхронности

У Telegram-бота нет аналитики из коробки. Совсем. Сколько человек нажали /start на этой неделе, откуда они пришли, какими командами пользуются, кто заблокировал бота после первого же сообщения — Bot API молчит, в логах шум, а BotFather в лучшем случае покажет, что бот жив.

Хорошая новость: в aiogram 3 есть механизм, который будто специально придуман для этой задачи, — middleware. Плохая новость: наивная реализация «перехватил апдейт — отправил в HTTP» способна уронить бота эффектнее любого бага в хендлерах.

В этой статье мы напишем аналитику для бота с нуля и по дороге разберём middleware в aiogram 3, фоновые таски, батчинг, экспоненциальный бэкофф с джиттером и graceful shutdown — паттерны, которые пригодятся далеко за пределами аналитики. Весь код рабочий, проверен на aiogram 3.

Дисклеймер честности: у меня есть опенсорсная библиотека ровно на эту тему, ссылка будет одним абзацем в самом конце. Статья написана так, чтобы быть полезной и без неё.

Что вообще стоит измерять в боте

Прежде чем писать код, договоримся о событиях. Минимальный джентльменский набор выглядит так.

/start и его параметр. Ссылка вида t.me/yourbot?start=habr_post — это UTM-метка мира ботов: один хвост ставите в статью, другой в закреп канала, третий в рекламу — и становится видно, какой источник приводит живых людей, а какой сжигает бюджет. Параметр приезжает прямо в тексте команды, грех не забрать.

Команды. Через месяц после запуска выясняется, что половиной команд из ботфазеровского меню не пользуется никто. Полезно знать это по цифрам, а не по ощущениям.

Нажатия inline-кнопок. Callback-запросы — это и есть «клики» вашего интерфейса.

Блокировки. Самое недооценённое: Telegram присылает событие, когда пользователь блокирует бота и когда возвращается. Это ваш отток в реальном времени, и мы к нему ещё вернёмся.

И не менее важный список — чего мы измерять не будем: тексты сообщений и данные профиля. В ботов люди пишут очень личные вещи, и правило «собираем всё, вдруг пригодится» — прямой путь к ситуации, когда утечка вашей аналитической базы становится утечкой чужих переписок. Решение «что мы не собираем» стоит принимать до первой строчки кода, а не после первого неудобного вопроса.

Middleware в aiogram 3 за пять минут

Middleware — это обёртка вокруг обработки события: код до, вызов следующего звена, код после. Классическая луковица, как в любом веб-фреймворке:

Аналитика для Telegram-бота своими руками: middleware в aiogram 3, батчи, ретраи и грабли асинхронности

Тонкость номер один — куда его вешать. Middleware регистрируются на конкретный тип события конкретного роутера: dp.message.middleware(...) сработает на сообщения, dp.callback_query.middleware(...) — на кнопки. Для аналитики это неудобно: пришлось бы вешать одно и то же на пять типов событий. Но есть уровень выше — Update, тип-контейнер, через который проходит вообще всё:

pythondp.update.middleware(MyMiddleware())

Внутри такого middleware event — это целый Update, и по его полям (event.message, event.callback_query, event.my_chat_member, ...) видно, что именно прилетело. Одна точка входа на весь трафик бота.

Тонкость номер два — outer против inner. У каждого observer'а есть outer_middleware (вызывается для каждого события, ещё до фильтров) и просто middleware (inner — вызывается после фильтров, только если нашёлся хендлер). Для dp.update разница почти стирается: апдейты диспетчер обрабатывает всегда, так что inner-middleware на этом уровне сработает на каждый апдейт ровно один раз. Его и берём.

Тонкость номер три — словарь data. Он живёт от фильтров до хендлера, через него aiogram прокидывает зависимости (bot, state, ...), и через него же ваш middleware может передавать что-то дальше. Нам это сегодня почти не понадобится, но знать полезно.

Версия 1: наивная, она же вредная

Первое, что приходит в голову, — отправлять событие сразу:

Аналитика для Telegram-бота своими руками: middleware в aiogram 3, батчи, ретраи и грабли асинхронности

Работает. На локальной машине, с ботом на три пользователя и живым эндпоинтом. Теперь грабли по порядку.

Запрос на каждый апдейт. Бот на тысяче сообщений в минуту превращается в генератор тысячи HTTP-запросов в минуту. Вы устроили DDoS собственной аналитике и платите за это латентностью и ресурсами.

Аналитика легла — и понеслось. aiogram обрабатывает каждый апдейт в отдельной asyncio-задаче, поэтому другие пользователи ждать не будут. Но каждая задача теперь висит до таймаута httpx (по умолчанию — щедрые пять секунд на connect и дольше на read). Тысячи подвисших задач, растущая память, и всё это — из-за сервиса, который вообще не обязателен для работы бота.

Исключения летят наружу. httpx.ConnectError из middleware улетит в обработку апдейта: в лучшем случае — спам в error-хендлерах, в худшем — сломанная логика. Аналитика уронила бота. Абсурд, но именно так это и происходит.

Мы ничего толком не измерили. Даже время работы хендлера не засекли, а это почти бесплатная метрика.

Отсюда главный принцип, вокруг которого построено всё дальнейшее: вспомогательная система не имеет права влиять на основную. Никакой сети в горячем пути, никаких исключений наружу, потеря событий лучше деградации бота.

Версия 2: finally и ни одного await в горячем пути

Разносим сбор и отправку. Middleware только собирает событие и кладёт его в буфер — синхронно, за микросекунды:

Аналитика для Telegram-бота своими руками: middleware в aiogram 3, батчи, ретраи и грабли асинхронности

Здесь три осознанных решения.

Сбор события живёт в finally — оно запишется, даже если хендлер упал с исключением. Причём само исключение мы не трогаем: finally без return его не глотает, ошибка честно долетит до error-хендлеров aiogram. А заодно бесплатно получаем duration_ms — время работы хендлера. Если через месяц /report начнёт отвечать по три секунды, вы увидите это в аналитике раньше, чем в жалобах пользователей.

Внутренний try/except Exception страхует от багов в нашем же коде извлечения: опечатка в _extract не должна ронять обработку апдейтов. Предупреждение в лог — и едем дальше.

Само извлечение — скучный разбор Update:

Аналитика для Telegram-бота своими руками: middleware в aiogram 3, батчи, ретраи и грабли асинхронности

Обратите внимание на явный return None для обычных текстовых сообщений — это то самое решение «не собираем», зафиксированное в коде, а не в добрых намерениях. И на последнюю ветку: my_chat_member со статусом kicked в приватном чате — это пользователь нажал «Заблокировать бота». Событие, которое большинство ботов молча выбрасывает, хотя это готовая метрика оттока: выкатили спорную фичу — и всплеск блокировок виден в тот же день, а не через месяц по ощущениям.

Версия 3: батчер и фоновая таска

Теперь тот, кто разгребает буфер. Отправляем пачками: либо накопилось 30 событий, либо прошло 60 секунд — что раньше:

Аналитика для Telegram-бота своими руками: middleware в aiogram 3, батчи, ретраи и грабли асинхронности

Самая важная строчка здесь — batch, self._buf = self._buf, []. Пока await self._send(batch) ходит по сети, в бот продолжают сыпаться апдейты и middleware продолжает делать add(). Если бы мы отправляли self._buf напрямую и очищали его после, то всё, что добавилось во время запроса, было бы потеряно (или отправлено дважды при ретрае). Атомарный своп решает это без единого лока: мы в одном событийном цикле, между двумя синхронными операциями никто не вклинится. Гонки в asyncio живут не между строчками, а между await'ами — стоит запомнить это до того, как оно укусит.

Вторая деталь: add() остался синхронным. Флаш по размеру уезжает в asyncio.create_task, так что даже переполнение буфера не заставляет хендлер ждать сеть. Два флаша могут выполняться параллельно (по размеру и по таймеру) — и это безопасно ровно благодаря свопу: каждый забирает свою пачку.

И да, Batcher нужно создавать внутри async def main() — create_task требует запущенного event loop.

Версия 4: сеть — штука ненадёжная

Флаш упал — события потеряны. Иногда это правильно, но «сервер моргнул на две секунды» — не повод терять пачку. Добавляем ретраи:

Аналитика для Telegram-бота своими руками: middleware в aiogram 3, батчи, ретраи и грабли асинхронности

Три классических паттерна в двадцати строчках.

Ретраить только то, что имеет смысл ретраить. 429 и 5xx — временные, шанс есть. 400 — ваш payload кривой и кривым останется; 401 — ключ протух. Долбить сервер повторами таких запросов — вредительство.

Джиттер. Представьте, что у вас двадцать инстансов бота, и аналитика легла на минуту. Без случайности все двадцать проснутся синхронно и ударят по едва ожившему серверу одновременно — это называется thundering herd, и именно так «сервис почти поднялся» превращается в «сервис лёг обратно». random.uniform(0.5, 1.5) размазывает повторы по времени.

Дроп как осознанная стратегия. Если после всех ретраев не получилось — события выбрасываются с warning'ом. Это не лень, это выбор: альтернатива — бесконечная очередь в памяти, которая при долгом даунтайме аналитики съест ваш инстанс. Для платёжных событий так нельзя. Для телеметрии — единственно верно: она вспомогательная, см. главный принцип.

Версия 5: graceful shutdown, или куда деваются хвосты

Остался незаметный, но регулярный источник потерь. У ботов деплой — это рестарт, а рестарт — это до 30 событий и 60 секунд буфера, улетающих в никуда. Каждый выкат. Лечится в четыре строчки:

Аналитика для Telegram-бота своими руками: middleware в aiogram 3, батчи, ретраи и грабли асинхронности

Гасим фоновую таску (обязательно дожидаясь отмены — иначе получите warning про destroyed pending task), затем финальный флаш. Осталось собрать всё вместе:

Аналитика для Telegram-бота своими руками: middleware в aiogram 3, батчи, ретраи и грабли асинхронности

dp.shutdown.register — приятная мелочь aiogram: при остановке поллинга колбэки вызовутся сами, и хвост событий доедет до сервера.

Последний штрих: режим «покажи, что ты отправляешь»

Один совет из горького опыта. Сделайте своей аналитике debug-режим, который логирует каждый собранный payload и каждое пропущенное событие с причиной:

[analytics] capture start user=42 param=habr_post duration=12ms [analytics] skip: plain message from user=42 (text is not collected) [analytics] → POST /v1/events (2 events) ← 200 in 143ms

Во-первых, вы сами через полгода забудете, что именно собираете. Во-вторых, когда пользователь (или юрист, или безопасник) спросит «а что бот отправляет наружу?», ответ «вот флаг, включи и посмотри на каждый байт» звучит несравнимо лучше, чем «ну... события какие-то». Прозрачность — это фича, и стоит она пятнадцать строчек с logger.debug.

Что получилось и что осталось за кадром

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

Честно скажу, чего тут нет: сервера, который эти события принимает, хранилища и — главное — дашборда. События без графиков — это просто строчки в базе; SQL к ним по вторникам быстро надоедает. Вот на этом месте DIY-аналитика из «вечернего проекта» превращается просто в проект.

Если поддерживать своё не хочется — всё описанное выше (плюс дашборд с источниками, командами и оттоком) я собрал в опенсорсную библиотеку anystat для бесплатного дашбоарда anystat.me для aiogram 3: подключение в две строки, текст сообщений не собирается по умолчанию, debug=True показывает каждый уходящий байт, MIT. А если хочется — теперь у вас есть рабочий каркас и, надеюсь, понимание, почему он устроен именно так.

Расскажите в комментариях, какие middleware живут в ваших ботах и как вы понимаете, что ботом вообще пользуются. Подозреваю, у многих ответ до сих пор — «вроде пишут что-то».