Дежурный бот вырос еще раз: отдел логистики, алерты, постмортемы

Привет, я Максим Королёв из Петрович-Тех. В прошлой статье я рассказывал, как дежурный бот вырос из инструмента для оповещений в помощника, который берёт на себя часть рутинных действий во время инцидента. В этой — покажу следующий шаг: как мы сделали коммуникацию с бизнесом более адресной, начали безопасно подключать к процессу сигналы мониторинга и добавили отдельный сценарий для разбора инцидентов после закрытия.
Пилот решили начать с отдела транспортной логистики. Одна из киллер-фич большого “Петровича”, когда до 10 тонн самого разнообразного строительного материала можно получить на адресе через 2 часа после оформления заказа.
В этой статье - четыре связанные темы:
Канал логистики - бизнес получает только релевантные уведомления.
Alerta → LifePOS - первый шаг к сбоям из мониторинга (с контролем человека).
Postmortem - разбор после закрытия без смешения с новым сбоем.
Web UI - те же сценарии, когда MAX не под рукой.

Часть 1. Канал логистики: сбой, который касается только тебя
Проблема: логистика тонула в чужом шуме
Раньше все уведомления об инцидентах уходили в один общий канал. Для IT-команды это нормально - там разбираются, какой сервис их касается, а какой нет. Для логистики (как отдельного бизнес-направления) это оказалось неудобно: чтобы понять, важен ли для них конкретный сбой, приходилось читать техническое сообщение целиком и додумывать, относится ли оно к их процессам.
Логистика прямо попросила: присылайте только то, что касается нас, и в понятном виде - без необходимости разбираться, в каком сервисе что упало.
Что сделали
Добавили отдельный MAX-канал (MAX_LOGISTICS_CHANNEL_ID) и новый опциональный шаг в сценарии создания сбоя - FAL1: «Управление логистикой» (то же значение, что в Jira для метрик по бизнес-домену).
При создании сбоя дежурный видит выбор:
«Управление логистикой» - сбой размечается как относящийся к этому домену, летит отдельным сообщением в канал логистики;
«Пропустить» - обычный сценарий, без дублирования.

Если выбрана логистика, в канал уходит сообщение в упрощенном формате - специально не техническом:
🚨 Технический сбой
• Проблема: <краткое описание>
• Сервис: <сервис>
• Исправим до: <ETA> (P85 ≈ <часы>)
• Ответственный: <ФИО из эскалации>

Почему так:
бизнесу не нужны детали реализации - только «что», «когда почините» и «к кому вопрос»;
шаг опциональный - не ломает обычный флоу для сбоев, которые логистики не касаются;
название поля сохранили от Jira, чтобы не плодить два разных термина для одной сущности в отчётности и в боте.
Конфиг и условие «это логистика»
В .env заводим отдельный канал; FAL1 при создании сбоя пишется в state:
MAX_LOGISTICS_CHANNEL_ID= -12345678901234
LOGISTICS_P85_MINUTES=60 # fallback, если Jira недоступна
Константа домена - одна строка, чтобы не разъехались Jira и бот:
# domain/constants.py
FAL1_LOGISTICS = "Управление логистикой"
Дублирование в канал логистики - не «второй пост везде», а проверка FAL1 при каждом событии (создание, продление, закрытие):
# utils/channel_helpers.py
async def maybe_send_to_logistics_for_alarm(alarm: dict, text: str) -> bool:
if (alarm.get("fal1") or "").strip() != FAL1_LOGISTICS:
return False
return await send_to_logistics_channel(text)Откуда берётся ETA: P85
Время «исправим до» не берётся с потолка и не является фиксированным SLA. Это 85-й перцентиль времени решения инцидентов с тем же FAL1 за прошедший период - сейчас считаем за текущий календарный год по закрытым FA в Jira.
Идея простая: обещать точное время невозможно, но можно опираться на статистику похожих случаев и дать разумный ориентир, который в 85% случаев не будет превышен. Это честнее, чем произвольная оценка дежурного, и не требует от него экспертизы в конкретном сервисе, чтобы прикинуть срок.
Расчёт в коде - JQL по закрытым FA с нужным FAL1 за текущий год, разница TimeEndProblem − TimeStartProblem, затем 85-й перцентиль:
# services/logistics_p85_service.py (упрощенно)
def _p85_minutes(sorted_minutes: list[float]) -> Optional[float]:
return float(statistics.quantiles(sorted_minutes, n=100)[84])
# fix_until = now + P85; при < MIN_SAMPLES - LOGISTICS_P85_MINUTES из .envОтветственный - не из общего справочника, а из таблицы эскалации
Раньше ответственных бот искал по справочнику сервисов в Confluence - там для каждого сервиса указаны те, кто устраняет проблему, и те, кто должен быть в курсе. Для канала логистики этого было недостаточно: нужен был человек, который отвечает перед бизнесом, а не обязательно тот же, кто чинит.
Добавили отдельную таблицу эскалации: по каждому сервису бот сначала подставляет ответственного за устранение, а при необходимости эскалации - уже владельца сервиса из отдельной колонки. Это дополнительный уровень поверх существующего справочника, а не замена ему.
/status и дисциплина коммуникации
Логистике важно не только узнать про сбой, но и получать обновления, не дергая дежурного вручную. Добавили команду /status и кнопку «Статус» в меню «Управлять»: бот проводит краткий опрос (причина, что делаем, обход, что нужно от логистов, когда следующий апдейт) и публикует структурированную карточку в канал логистики, а не простыню из FA-чата.
Интервал между апдейтами задаёт тот, кто заполняет статус (последний вопрос опроса - «следующее обновление через N минут»). За 3 минуты до этого времени бот присылает автору карточки напоминание.
Важная деталь: если ответственный не отреагировал на напоминание, в текущей реализации бот просто ждёт - без эскалации и повторных пингов. Это осознанное упрощение на первом этапе: сначала проверяем, работает ли сама механика регулярных апдейтов, усложнять поведение при пропуске будем по факту использования.
Продление и остановка сбоя, если он размечен как относящийся к логистике, автоматически дублируются тем же упрощенным сообщением в канал - без ручного повтора со стороны дежурного.
Команды и карточка в канале
Дежурный в личке или FA-чате:
/status FA-1000 или: Управлять → Сбои → FA-1000 → Статус
После опроса бот собирает карточку и пишет срок следующего апдейта в state (для напоминания за 3 минуты):
# adapters/max/status_flow.py (фрагмент)
minutes = int(data.get("next_minutes") or 15)
next_at = now + timedelta(minutes=minutes)
text = MessageFormatter.format_logistics_status_text(
alarm_id=alarm_id,
now_hm=now.strftime("%H:%M"),
next_hm=next_at.strftime("%H:%M"),
what_broken=...,
cause=data.get("cause"),
doing=data.get("doing"),
...
)
await send_to_logistics_channel(text)
bot_state.active_alarms[alarm_id]["status_next_at"] = next_at
bot_state.active_alarms[alarm_id]["status_author_user_id"] = user_id
Шаблон карточки - отдельная функция, поля без ответа в опросе не попадают в текст:
# services/message_formatter.py (фрагмент)
lines = [
f"📌 СТАТУС {alarm_id}",
f"• Обновлено: {now_hm}",
f"• След. обновление: {next_hm}",
]
if cause:
lines.append(f"• Причина (предварительно): {cause}")
lines.append(f"• Что не работает: {what_broken or 'не указано'}")
# ...

Почему так:
бизнес получает предсказуемый ритм информации, а не тишину до момента закрытия;
интервал апдейта задаёт человек в момент статуса, а не жёсткий таймер в конфиге, потому что реальная частота зависит от характера сбоя;
Обратная связь логистики
После нескольких недель использования канала и статус-карточек команда логистики ответила так (с разрешения, без привязки к конкретному человеку):
“Полностью устраивает, стало прозрачнее и понятнее, что происходит. Удобно следить за ситуацией и вносить корректировки в логистические процессы, чтобы доставка оставалась на высоте.”
Для нас это главный критерий: не «красивый бот», а управляемая коммуникация - бизнес видит сбой в своём формате и может реагировать на процессы.
Часть 2. Alerta → LifePOS: мониторинг в контур инцидента
Зачем
Все предыдущие сценарии начинались с того, что кто-то - дежурный или сотрудник техподдержки - вручную инициировал создание сбоя. Для сервиса LifePOS решили попробовать другое: сигнал приходит из системы мониторинга Alerta, а бот предлагает завести инцидент.
Alerta - открытая платформа для консолидации и дедупликации алертов из разных источников мониторинга. У нас она уже используется для отслеживания состояния сервисов; новый шаг - научить «Дежурного» слушать её и превращать открытые алерты в инциденты ITSM.
Минимальный конфиг пилота
Переменная | Значение | Назначение |
|---|---|---|
ALERTA_URL | адрес инстанса Alerta | Подключение к API мониторинга |
ALERTA_API_KEY | ключ доступа | Авторизация запросов к Alerta |
ALERTA_WATCH_SERVICE | delivery_payment | Какую группу алертов слушать |
ALERTA_POLL_INTERVAL_SEC | 30 | Интервал опроса, подобран эмпирически |
ALERTA_CREATE_JIRA | 0 (на пилоте) | Отключает создание тикетов в Jira |
ALERTA_PUBLISH_PETLOCAL | 0 (на пилоте) | Отключает публикацию в Петлокал |
Как это работает
Бот раз в ~30 секунд опрашивает Alerta на предмет открытых алертов по группе delivery_payment. Интервал выбрали без специальных расчетов - взяли разумное значение для тестового периода, дальше подстраиваем через конфиг и админ-команды, без релиза кода.
Когда находится новый открытый алерт, администраторам бота (MAX_ADMIN_IDS) приходит сообщение с двумя кнопками: «Завести сбой» и «Пропустить». Полная автоматизация здесь намеренно не сделана - на этапе пилота решение всё ещё принимает человек, бот только избавляет его от необходимости искать источник и оформлять сбой руками.
При подтверждении бот создаёт сбой с заранее заданными дефолтами для этого сервиса:
тип «Другое: LifePOS»;
домен FAL1 - логистика (сообщение дублируется в канал логистики по описанной выше логике);
ETA - тот же P85, посчитанный по FAL1;
ответственный - «Ведущий специалист технической поддержки LifePOS» (роль в сообщениях), потому что LifePOS пока не принят на официальную поддержку и не описан в общей таблице эскалации. Как только сервис туда попадёт, ответственный будет подставляться по общим правилам, как для остальных сервисов.
Что улучшили после первого пилота
На старте было достаточно «алерт → кнопки → сбой». В боевом использовании всплыли два типичных шума:
повторные алерты, пока сбой LifePOS уже открыт - бот снова предлагал «Завести сбой»;
ложное ощущение автоматики - один и тот же алерт мог дёргать несколько раз.
Было | Стало |
|---|---|
Каждый новый алерт из Alerta предлагал завести сбой | Пока сбой LifePOS активен, повторные алерты delivery_payment не предлагаются |
Опрос Alerta продолжался без учёта уже открытого сбоя | При закрытии сбоя опрос возобновляется автоматически |
Дежурного могло дёргать несколько раз одним и тем же алертом | Человек-шлагбаум сохранён, но без спама |
При создании сбоя из Alerta pending-записи помечаются как подавленные; при закрытии - снимается mute:
# services/alerta_watch_worker.py (упрощённо)
def on_alerta_lifepos_alarm_closed(alarm_id: str, alarm_info: dict | None = None) -> int:
"""После закрытия сбоя LifePOS снова слушаем Alerta."""
for aid, entry in list(known.items()):
if entry.get("status") in ("suppressed_active_alarm", "accepted"):
known.pop(aid, None)
# лог: «снова слушаем delivery_payment»
Дефолты сбоя LifePOS задаются при accept - FAL1 логистика, ETA = P85, в поле ответственного - роль «Ведущий специалист технической поддержки LifePOS»:
# services/alerta_watch_worker.py (фрагмент create_alarm data)
data = {
"service": "Другое",
"service_other_spec": "LifePOS",
"fal1": FAL1_LOGISTICS,
"responsible_person_name": "Ведущий специалист технической поддержки LifePOS",
"alerta_alert_id": alert_id,
"alerta_snapshot": snapshot,
...
}Формат сообщения: суть сверху, техника снизу
Отдельно провозились с тем, как показать алерт из Alerta человеку, который должен принять решение за секунды. Сырой алерт содержит служебные поля (service, resource, event), которые ничего не говорят о сути проблемы с первого взгляда. Разделили сообщение на две части - понятную сверху и техническую снизу, для тех, кто хочет свериться с первоисточником:
🚨 Технический сбой · LifePOS
• Что случилось: TEST Confluence UP status TEST
• Сервис: Другое: LifePOS
• Исправим до: 06.08.2026 22:51 (P85 ≈ 0.4 ч)
• Ответственный: Ведущий специалист технической поддержки LifePOS
Alerta Service: delivery_payment
• Resource: Test2host
• Event: TESTConfluenceUPstatus

Формат ещё не финальный - сейчас подбираем баланс, который одинаково удобен и IT, и бизнесу.
Пилот: осторожно, без побочных эффектов
На время тестирования запись в Jira и публикация в Петлокал для этого сценария могут быть отключены флагами (ALERTA_CREATE_JIRA=0, ALERTA_PUBLISH_PETLOCAL=0). Логика сбоя при этом полностью отрабатывает - просто без создания внешних артефактов, пока проверяем корректность самого сценария.
Почему так:
фича-флаги позволяют гонять реальную логику на реальных алертах, не создавая шума во внешних системах;
когда логика подтвердится на практике, включение Jira и Петлокала - смена переменных в .env и перезапуск контейнера.
Опрос Alerta целиком можно отключить без деплоя:
/feature set ALERTA_ENABLED 0
Первые цифры
Статистики по Alerta пока минимум. В среднем от появления алерта до фактического заведения инцидента проходит 15 секунд - это время цикла опроса и нажатия «Завести сбой». Делать выводы рано, но путь «алерт → инцидент» уже укладывается в секунды вместо ручного оформления.
Куда движемся: сейчас человек подтверждает каждый алерт кнопкой. Долгосрочная цель полное автозаведение сбоя без подтверждения.
Часть 3. Postmortem: разбор после закрытия без потери контекста
Проблема, которую не видели на старте
Когда сбой закрывается, FA-чат в MAX нужно очистить - иначе следующий инцидент попадёт в чат, где ещё обсуждают последствия предыдущего.Но postmortem часто идёт после формального закрытия: причины, хронология, action items. Два крайних варианта были плохими:
очищать сразу - теряется живой контекст разбора;
не очищать - операционный чат смешивается с разбором, новый сбой открывается «поверх» старого.
Jira для разбора - формально правильно, но медленнее чата: люди быстрее отвечают в МАХ, чем в задаче.
Что сделали
После закрытия сбоя FA-чат переводится в режим postmortem:
Первичный архив - в Jira (как раньше).
Чат не очищается, переименовывается в 📋 FA-XXXX · разбор.
Бот шлёт краткое вводное: что закрыли, проблема, ссылка на Jira.
Чат исключается из пула операционных FA-чатов - новый сбой получит другой чат (сейчас до шести FA-чатов в ротации).
Когда разбор завершён - «Управлять → Постмортем → Закончить разбор» (или /postmortem done): дополнительный архив в Jira, очистка чата, возврат в пул.


Как это выглядит в коде
Postmortem-чаты хранятся в state.json отдельно от активных сбоев:
{
"postmortem_chats": {
"-71063779478219": {
"alarm_id": "FA-3700",
"closed_at": "2026-08-31T09:19:00",
"jira_key": "FA-3700",
"issue": "Тест 1"
}
}
}При выборе FA-чата для нового сбоя postmortem-чаты исключаются из пула:
# services/postmortem_service.py
def occupied_fa_chat_ids() -> set[str]:
used = {a["max_chat_id"] for a in bot_state.active_alarms.values() if a.get("max_chat_id")}
used |= set(bot_state.postmortem_chats.keys())
return used
# core/creation.py
fa_chat_id = get_next_max_fa_chat_id(occupied_fa_chat_ids())При закрытии сбоя архив в Jira выполняется сразу, очистка чата - только после явного завершения разбора:
# services/max_archive.py
await process_max_chat_on_alarm_close(..., postmortem=True) # архив, чат не трогаем
# ...
await process_max_chat_on_alarm_close(..., postmortem=False) # /postmortem done → очистка
Почему так:
один FA-чат = одна фаза жизни инцидента (операционка или разбор, не оба сразу);
контекст разбора остаётся там, где шла работа по сбою;
Jira остаётся системой записи и постановки реальных задач, MAX - средой быстрого обсуждения;
завершение разбора явное действие, а не «когда-нибудь почистим».
Postmortem включается флагом MAX_ALARM_POSTMORTEM_ON_CLOSE (по умолчанию включен); при необходимости откатывается через /feature set без рестарта.
Часть 4. Web UI: те же сценарии без мессенджера
Появился веб-интерфейс (FastAPI): те же кнопки «Сообщить», «Управлять», «Постмортем»
Зачем:
Не нужно искать чат с ботом
просмотр MAX-чатов и отправка сообщений из браузера
страница статистики «время без сбоев»;
запасной канал, если с MAX что-то не так.
Web UI не дублирует бизнес-логику - он ходит в тот же core/ и те же сценарии, что MAX-бот. Это не «вторая версия продукта», а второй вход в один процесс.
# adapters/web/gateway.py
class WebChatGateway:
"""Один операторский чат: те же сценарии, что в MAX"""
async def handle_callback(payload: str, ...):
if payload == "manage_postmortem":
items = list_postmortem_items()
await reply_fn("📋 Выберите сбой в разборе:", postmortem_list_keyboard(items))
if payload.startswith("action_pm_finish_"):
ok, msg = await finish_postmortem(alarm_id=item_id)
Payload кнопок (manage_postmortem, select_pm_FA-3700, ...) те же, что в MAX - один обработчик сценариев, два транспорта.

Уроки на середине пилота
Не жди готового решения от бизнеса - спрашивай, что мешает. Запрос на отдельный канал логистики появился именно потому, что мы спросили, а не додумали сами
Автоматизация не обязана быть полной сразу. Кнопки «Завести сбой» / «Пропустить» - осознанный компромисс, пока не набралось доверие к автоматике
Статистические ориентиры лучше произвольных обещаний. P85 честнее фиксированного SLA, потому что опирается на реальную историю
Фича-флаги - про безопасность пилотов, а не только про быстрый релиз: логика на проде, внешние системы - по переключателю
Жизненный цикл важнее «создали сбой». Postmortem родился из конфликта «очистить чат vs сохранить разбор» - типичный симптом зрелости процесса
Один сценарий - несколько клиентов. Web UI имеет смысл только как второй вход в ту же логику, а не как отдельный продукт.
Что дальше
Новый отдельный канал, возможно для контакт-центра
Alerta: накопить статистику ложных срабатываний, исправить. Добавить алерты и реакции бота
Postmortem: сколько разборов одновременно, хватает ли шести FA-чатов
Web: закрепить как рабочий инструмент смены, а не только запасной инструмент
Если делали похожее (разделение бизнес/IT каналов, postmortem-чаты, полуавтомат Alerta) - напишите в комментариях, особенно интересно, как закрывали разбор после закрытия без потери контекста.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.