интеграция zabbix и planfix

Интеграция Zabbix и ПланФикс: жизненный цикл задачи из алерта мониторинга

Связали Zabbix 7.0 (около 260 хостов) с таск-трекером ПланФикс так, что событие мониторинга проходит весь жизненный цикл задачи: проблема создаёт задачу, нажатие Acknowledge переводит её «В работе», восстановление триггера закрывает, отмена эскалации отменяет.

Схема построена на штатных средствах Zabbix: медиатип Webhook со скриптом на JavaScript и действия с эскалацией, без промежуточного сервиса и внешней базы соответствий. Ниже — архитектура, настройка медиатипа и вебхуков ПланФикса, разбор сбоя 414 URI Too Long, полный код скрипта и контроль доставки алертов.

Обновлено 28.09.2026: скрипт переведён с GET-запросов на POST JSON, повторную доставку отсекает поиск задачи по Event ID, недоставленные алерты ловит отдельный триггер.

Интеграция Zabbix и ПланФикс: жизненный цикл задачи из алерта мониторинга

Главный вывод обновления: данные алерта передавайте в теле POST-запроса, а не в URL. При percent-encoding кириллическая буква занимает 6 байт, и текст алерта около 1 800 символов уже упирается в лимит сервера на длину строки запроса — 8 КБ.

Содержание

Задача: алерты Zabbix вручную превращаются в задачи ПланФикс

Интеграция Zabbix и ПланФикс закрывает разрыв между мониторингом и учётом работ: пока проблема видна только в Zabbix, задача для дежурной смены заводится в ПланФикс руками. На наших проектах это означало потерянные инциденты, отсутствие истории «проблема ↔ задача» и ручную рутину на каждом срабатывании триггера.

Дано: боевая инсталляция Zabbix 7.0, около 260 хостов разных клиентов и порядка 150 шаблонов. Заявки и работа дежурных у нас ведутся в ПланФикс, поэтому именно туда должны попадать значимые события мониторинга. До автоматизации связка держалась на внимательности оператора: увидел алерт, скопировал, создал задачу, не забыл закрыть.

Цель — двусторонняя автоматическая связка «проблема Zabbix ↔ задача ПланФикс» без ручного труда и без промежуточного сервиса. Любой дополнительный сервис пришлось бы мониторить отдельно, а это новая точка отказа в системе, которая как раз и следит за отказами.

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

Не пытайтесь решить это «уведомлением на почту с парсером»: почтовый канал не возвращает в Zabbix идентификатор задачи, и связать восстановление триггера с конкретной задачей потом нечем. Именно требование вернуть task id обратно в событие определило всю дальнейшую архитектуру.

Задача: алерты Zabbix вручную превращаются в задачи ПланФикс

Архитектура: webhook-медиатип, действия и теги события как «память»

Интеграция Zabbix и ПланФикс держится на двух штатных сущностях Zabbix и одном приёме: тип оповещения media type (webhook), действия (actions) с фильтрами и эскалацией, а в роли «памяти» о созданной задаче выступают теги события. Внешней базы соответствий между проблемами и задачами нет, и это сознательное решение.

Поток данных такой. Триггер переходит в проблему, событие попадает в одно из трёх действий, где фильтр проверяет severity, группы хостов и флаг suppressed. Если проблема прожила дольше одного периода эскалации и её не подтвердили, на шаге эскалации 2 операция-сообщение уходит служебному пользователю ПланФикс, а тот доставляет его через media type «Planfix Tasks». Скрипт отправляет POST JSON во входящий вебхук zabbix-new, ПланФикс отвечает номером задачи: {"task": N}.

Дальнейшие переходы используют теги события. Когда триггер возвращается в OK, recovery-операция (тип 11) закрывает задачу; когда инженер нажимает Acknowledge, update-операция (тип 12) переводит её в работу; при отмене эскалации уведомление приходит благодаря notify_if_canceled. Во всех случаях скрипт берёт taskId из тега. Для удобства дежурного включён show_event_menu: из карточки проблемы в Zabbix открывается связанная задача по ссылке __zbx_planfix_link. Подробности механизма — в документации Zabbix: media type webhook и действия и эскалации.

При создании задачи скрипт возвращает Zabbix два тега: __zbx_planfix_taskid с номером задачи и __zbx_planfix_link со ссылкой на неё. Zabbix прикрепляет их к событию (process_tags=1), и закрытие, acknowledge и отмена находят ту же задачу по этим тегам.

Теги работают, когда задача уже создана. Повторную отправку самого создания (ретрай Zabbix, перезапуск эскалации) отсекает второй механизм, на стороне ПланФикса: сценарий zabbix-new сначала ищет задачу по полю Event ID и создаёт новую, только если не нашёл. Задача новая или найденная — Zabbix получает одни и те же теги:

				
					{
  "tags": {
    "__zbx_planfix_taskid": "12345",
    "__zbx_planfix_link": "https://<аккаунт>.planfix.ru/task/12345"
  }
}
				
			

Настройка media type и действий: пошагово

Ниже порядок настройки: медиатип и действия в Zabbix, входящие вебхуки и обратный сценарий в ПланФиксе. Полный код скрипта приведён отдельным разделом.

Шаг 1. Создайте media type. В Zabbix откройте Оповещения → Способы оповещений → Создать способ оповещения, тип — Webhook, имя — например «Planfix Tasks». Это контейнер для скрипта, параметров и шаблонов сообщений.

Шаг 2. Вставьте webhook-скрипт в поле Скрипт — полный код в разделе «Полный код webhook-скрипта» ниже. Скрипт получает все данные события одним JSON в переменной value и отправляет POST-запрос во входящий вебхук ПланФикса.

Шаг 3. Задайте параметры в разделе Параметры — пары «имя → макрос Zabbix». Скрипт читает их из value через JSON.parse(value); адреса вебхуков, адрес ПланФикса и проект тоже передаются параметрами, поэтому код переносится между инсталляциями без правок:

ПараметрЗначение (макрос Zabbix)
alert_message{ALERT.MESSAGE}
alert_subject{ALERT.SUBJECT}
event_id{EVENT.ID}
event_value{EVENT.VALUE}
event_source{EVENT.SOURCE}
event_update_status{EVENT.UPDATE.STATUS}
severity{EVENT.SEVERITY}
host_name{HOST.NAME}
host_ip{HOST.IP}
event_time{EVENT.DATE} {EVENT.TIME}
trigger_id{TRIGGER.ID}
runbook_url{TRIGGER.URL}
tenant_id{EVENT.TAGS.owner} — код клиента; у нас тег owner приходит на хост из Nautobot
zabbix_basehttps://zabbix.example.com — адрес фронтенда Zabbix: из него собирается ссылка на событие, им же дополняется относительный {TRIGGER.URL}
planfix_urlhttps://<аккаунт>.planfix.ru — из него собирается ссылка на задачу в теге __zbx_planfix_link
planfix_projectZABBIX
planfix_hook_newhttps://<аккаунт>.planfix.ru/webhook/json/<id>
planfix_hook_edithttps://<аккаунт>.planfix.ru/webhook/json/<id>
planfix_hook_ackhttps://<аккаунт>.planfix.ru/webhook/json/<id>
planfix_hook_unackhttps://<аккаунт>.planfix.ru/webhook/json/<id>
planfix_acknowledge_user{USER.FULLNAME}
planfix_taskid{EVENT.TAGS.__zbx_planfix_taskid}
planfix_default_useridСотрудники ТП

Четыре параметра planfix_hook_* обязательны: без любого из них скрипт остановится на проверке параметров. runbook_url, tenant_id и zabbix_base необязательны: если макрос не развернулся, поле уходит пустым, а строка-ссылка в задаче не появляется.

Шаг 4. Настройте шаблоны сообщений для трёх состояний события: проблема (recovery=0), восстановление (recovery=1) и обновление/acknowledge (recovery=2). Без шаблона {ALERT.MESSAGE} придёт пустым и webhook упадёт на валидации. В шаблоне проблемы передайте как минимум {EVENT.NAME}, {HOST.NAME}, {EVENT.SEVERITY}, {EVENT.ID} и ссылку на событие. Acknowledge и его снятие скрипт распознаёт по первой строке шаблона обновления, куда {EVENT.UPDATE.ACTION} разворачивается в acknowledged или unacknowledged.

Шаг 5. Задайте параметры доставки — они определяют порядок и надёжность отправки:

ПараметрЗначениеЗачем
maxsessions1строгий порядок: create обязан уйти раньше, чем edit или close по той же задаче
maxattempts3пережить кратковременную недоступность ПланФикса; повтор создания не даёт дубля благодаря поиску по Event ID
attempt_interval45sпауза между повторами
timeout60sлимит исполнения скрипта
notify_if_canceled1без него не придёт уведомление об отмене эскалации

Значение maxsessions=1 повышать нельзя: иначе закрытие задачи обгонит её создание и edit придёт по ещё не существующей задаче. Включите также process_tags=1 (скрипт пишет теги обратно в событие) и show_event_menu со ссылкой {EVENT.TAGS.__zbx_planfix_link} — тогда из карточки проблемы в Zabbix открывается связанная задача.

Шаг 6. Создайте четыре входящих вебхука в ПланФиксе. Тип — «Входящий вебхук», формат — JSON. ПланФикс выдаёт адрес вида https://<аккаунт>.planfix.ru/webhook/json/<id>, его вписывают в соответствующий параметр planfix_hook_*. Скрипт отправляет POST с заголовком Content-Type: application/json, в сценарии поля читаются через JsonPath $.имя_поля. Каждый вебхук отвечает {"task": N}.

ВебхукКогда вызываетсяПоля JSONДействие в ПланФиксе
zabbix-newновая проблема (шаг эскалации 2)name, description, project, event_id, trigger_id, host_name, host_ip, severity, event_time, tenant_idнайти задачу, у которой Event ID = $.event_id; если её нет — создать (название, описание, проект, Event ID, Trigger ID)
zabbix-editвосстановление, отмена эскалации, комментарийtask, comment, statusкомментарий + статус «Завершенная» или «Отмененная»
zabbix-ackacknowledgetask, comment, status, userfullnameстатус «В работе», назначить исполнителя
zabbix-unackснятие acknowledgetask, comment, userfullnameвернуть исполнителя по умолчанию

Пустые поля скрипт в edit, ack и unack не отправляет. Поле Event ID в ПланФиксе числовое. Значение с буквами ПланФикс молча не запишет, и тогда поиск по Event ID ничего не найдёт, поэтому тестовые запросы делайте с числовым event_id. Формат входящих запросов — в справке ПланФикс по входящим вебхукам. Пример тела запроса zabbix-new:

				
					{
  "name": "[PROBLEM] <название проблемы>",
  "description": "Problem started at 06:16:49 on 2026.09.28<br>Host: <хост><br>...<br>Zabbix event: https://zabbix.example.com/tr_events.php?triggerid=<id>&eventid=<id>",
  "project": "ZABBIX",
  "event_id": "999001",
  "trigger_id": "999002",
  "host_name": "<хост>",
  "host_ip": "<ip>",
  "severity": "High",
  "event_time": "2026.09.28 06:16:49",
  "tenant_id": "<код клиента>"
}
				
			

Шаг 7. Создайте действия (actions), которые маршрутизируют проблемы в ПланФикс по уровню важности. Три действия с одинаковым фильтром, отличающиеся только severity:

ДействиеSeverityПериод эскалацииЗадача создаётся на шаге
PlanFix | Report disasterDisaster (5)2 мин2
PlanFix | Report highHigh (4)5 мин2
PlanFix | Report averageAverage (3)5 мин2

Условия фильтра соединены по AND: совпадение severity, хост не входит в служебные группы ГРУППА-A, ГРУППА-B и ГРУППА-C, проблема не подавлена (не suppressed). Операция-сообщение стоит на шаге эскалации 2 с условием «Event acknowledged = No» — это шумовой фильтр: задача заведётся, только если проблема прожила дольше одного периода эскалации и её не подтвердили. Recovery- и update-операции (тип Notify all involved) обрабатывают восстановление и acknowledge, а notify_if_canceled=1 обязателен, иначе webhook не получит уведомление об отмене эскалации. Все действия шлют сообщения служебному пользователю ПланФикс, у которого единственный канал — этот медиатип.

Шаг 8. Настройте обратный сценарий ПланФикс → Zabbix. Связка двусторонняя: когда оператор принимает задачу в ПланФикс, событие в Zabbix должно подтвердиться (acknowledge) — с ответственным и комментарием. В ПланФикс это автоматический сценарий: событие — «Задача принята»; условия — статус ≠ «Завершенная» и проект = ZABBIX; операция — послать HTTP-запрос POST на адрес вашего Zabbix — https://zabbix.example.com/api_jsonrpc.php (Content-Type: application/json):

{
  "jsonrpc": "2.0",
  "method": "event.acknowledge",
  "params": {
    "eventids": ["{{Задача.Event ID}}"],
    "action": 6,
    "message": "ACK from Planfix by {{Текущий пользователь.ФИО}}"
  },
  "auth": "API_TOKEN",
  "id": 1
}

Здесь action: 6 — битовая маска Zabbix (2 «подтвердить» + 4 «добавить сообщение»): событие переходит в acknowledged и получает комментарий. eventids берётся из поля задачи {{Задача.Event ID}} — того самого event_id, что скрипт передал при создании, поэтому ПланФикс всегда знает, какое именно событие подтверждать. Адрес https://zabbix.example.com/api_jsonrpc.php — это JSON-RPC API вашего сервера Zabbix (подставьте свой хост). В итоге инженер работает там, где удобнее: нажал «принял» в Zabbix — задача в ПланФикс уходит «В работе»; взял задачу в ПланФикс — подтверждается событие в Zabbix. Токен Zabbix API храните в защищённом поле, а не открытым текстом в теле вебхука.

Полный жизненный цикл задачи: создание → в работе → закрытие → отмена

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

Состояние ZabbixКак распознаётсяВебхукСтатус задачи
Эскалация отмененатекст Escalation canceledzabbix-editОтмененная
Восстановление (OK)event_value = 0zabbix-editЗавершенная
Acknowledgeпервая строка содержит acknowledgedzabbix-ackВ работе
Снятие acknowledgeпервая строка содержит unacknowledgedzabbix-unackисполнитель по умолчанию
Новая проблемаevent_value=1 , event_update_status=0zabbix-newсоздание (или найденная по Event ID)
Прочее обновлениени одно из условий вышеzabbix-editбез смены статуса

Разберём типовой сценарий. Триггер сработал, проблема прожила дольше одного периода эскалации, на шаге 2 уходит оповещение. Скрипт видит event_value=1 и event_update_status=0, попадает в ветку новой проблемы и вызывает zabbix-new. ПланФикс создаёт задачу и возвращает её номер, который скрипт кладёт в теги события. С этого момента задача и проблема связаны.

Дальше инженер берёт проблему в работу и нажимает Acknowledge. Приходит обновление, первая строка которого содержит acknowledged, скрипт вызывает zabbix-ack, передаёт исполнителя и переводит задачу в статус «В работе». Если acknowledge сняли, отрабатывает zabbix-unack и исполнитель возвращается к значению по умолчанию (у нас это группа сотрудников техподдержки).

Когда триггер восстанавливается, событие приходит с event_value=0, скрипт распознаёт восстановление и через zabbix-edit переводит задачу в «Завершенная». Отдельный случай — отмена эскалации: проблема ещё активна, но эскалацию погасили, отключив хост или триггер. Такое событие маскируется под новую проблему, поэтому его проверка стоит первой, и задача уходит в «Отмененная», а не дублируется.

Везде, кроме создания, скрипт берёт номер задачи из тега __zbx_planfix_taskid. Если тега нет (например, проблема случилась раньше, чем мы развернули интеграцию), ветка молча завершается без вызова ПланФикс: закрывать или комментировать нечего. Это спасает от ошибок на «исторических» событиях, которые ещё живут в системе после включения связки.

Кейс: почему задачи перестали создаваться (414 URI Too Long)

С 24 сентября 2026 года часть проблем перестала превращаться в задачи (первая такая ошибка появилась ещё 22-го). В журнале алертов Zabbix у них стояла ошибка HTTP GET failed: 414 URI Too Long — ответ сервера ПланФикса (Jetty 12). Все три попытки доставки заканчивались тем же ответом.

Что пострадало. Только триггеры с длинным описанием — проверки доступности внешних сервисов: ЭДО, маркировки «Честный знак», ЕСИА и госуслуг. В описании такого триггера лежит инструкция дежурному по классам ошибок — около 1 300 символов кириллицы. До исправления утром 28 сентября так не дошли 33 алерта, и задачи по этим проблемам не создались.

Причина — цепочка из четырёх звеньев:

  1. Шаблон сообщения действия вставляет {TRIGGER.DESCRIPTION} в текст алерта: вместе с инструкцией получается около 1 800 символов.
  2. Скрипт передавал весь текст в URL GET-запроса, в параметре description.
  3. При percent-encoding кириллическая буква занимает 6 байт (%D0%9A): два байта UTF-8, каждый записан тремя символами. Описание выросло до 7,6 КБ, URL целиком — до 8 231 байт.
  4. Jetty по умолчанию принимает строку запроса вместе с заголовками в пределах 8 192 байт. Всё длиннее получает 414.

Как нашли. Собрали URL реального алерта тем же кодированием, что даёт encodeURIComponent: 8 231 байт, на 39 больше порога. Прогон старого скрипта на этом алерте воспроизвёл 414.

Почему Zabbix не сообщил о сбое. Недоставленный алерт в Zabbix — не событие. Ошибка остаётся только в журнале алертов (в базе — alerts.status = 2), и триггера на неё нет, пока его не создать.

Что сделали:

  1. В день обнаружения добавили в GET-скрипт обрезку описания, чтобы URL укладывался в 7 000 байт; строки-ссылки в конце задачи сохранялись. Задачи снова начали создаваться.
  2. Поставили контроль доставки — отдельный триггер на недоставленные алерты (раздел ниже).
  3. В ПланФиксе создали четыре входящих вебхука типа JSON: сценарии скопировали из GET-версий и добавили в zabbix-new поиск по Event ID.
  4. Перевели скрипт на POST JSON, адреса вебхуков вынесли в параметры медиатипа.
  5. Проверили на тестовой задаче и на живых событиях, затем отключили старые GET-вебхуки. Старый адрес теперь отвечает {"error":"webhook not active"}.

Инструкции в описаниях триггеров сокращать не стали: в теле POST-запроса лимита длины URL нет, а дежурному инструкция нужна в задаче. Предохранитель в скрипте обрезает только текст длиннее 4 000 символов и оставляет ссылку на событие в Zabbix.

Полный код webhook-скрипта

Скрипт целиком — три логические части в одном файле: объект Planfix (проверка параметров, отправка POST и методы для четырёх вебхуков), помощники clean() и fitHtml() и блок MAIN с конечным автоматом, который по флагам события выбирает нужную ветку. В движке Zabbix всё это исполняется как единый скрипт, поэтому приводим его одним блоком.

Запросы edit, ack и unack отправляет общий помощник postComment(). Текст длиннее 4 000 символов fitHtml() обрезает с пометкой, не разрезая эмодзи пополам; строки-ссылки (Runbook, Zabbix event) добавляются после обрезки. При ошибке HTTP в журнал алертов попадают код ответа и первые 300 символов тела, а не HTML-страница целиком. В нашей рабочей версии есть ещё маршрутизация задач по проектам клиентов, в статье она опущена.

Куда вставить: в Zabbix откройте Оповещения → Способы оповещений, создайте media type типа Webhook и вставьте код в поле Скрипт. Параметры из таблицы в шаге 3 добавьте в разделе Параметры того же медиатипа — скрипт читает их из value через JSON.parse(value).

				
					// Zabbix → Planfix webhook (адаптация для Zabbix 7)

var Planfix = {
  params: {},
  logEnabled: false,

  setParams: function (params) {
    if (typeof params !== 'object') throw 'Planfix params must be object.';
    Planfix.params = params;
  },

  log: function (level, msg) {
    if (Planfix.logEnabled) Zabbix.log(3, '[Planfix Webhook] ' + msg);
  },

  validateParams: function () {
    var required = [
      'alert_message', 'alert_subject', 'event_id',
      'event_source', 'event_update_status', 'event_value',
      'planfix_url', 'planfix_project',
      'planfix_hook_new', 'planfix_hook_edit', 'planfix_hook_ack', 'planfix_hook_unack'
    ];
    required.forEach(function (key) {
      if (!Planfix.params[key] || Planfix.params[key].toString().trim() === '')
        throw 'Missing parameter: ' + key;
    });
    if (Planfix.params.planfix_url.indexOf('http') !== 0)
      throw 'planfix_url must start with http/https';
  },

  httpPost: function (url, body) {
    Planfix.log(3, 'POST: ' + url + ' ' + JSON.stringify(body));
    var request = new HttpRequest();
    request.addHeader('Content-Type: application/json');
    var response = request.post(url, JSON.stringify(body));
    Planfix.log(3, 'Raw response: ' + response);
    if (request.getStatus() < 200 || request.getStatus() >= 300)
      throw 'HTTP POST failed: ' + request.getStatus() + ' ' + String(response).substring(0, 300);
    try { return JSON.parse(response); }
    catch (e) { throw 'JSON parse error: ' + response; }
  },

  createTask: function () {
    // строки-ссылки в конце задачи: добавляются после обрезки описания и не теряются
    var tail = '';
    var zabbixBase = clean(Planfix.params.zabbix_base);
    var runbookUrl = clean(Planfix.params.runbook_url);
    if (runbookUrl && !/^https?:\/\//.test(runbookUrl) && zabbixBase) runbookUrl = zabbixBase + '/' + runbookUrl;
    if (runbookUrl) tail += '<br>Runbook: ' + runbookUrl;
    if (zabbixBase) tail += '<br>Zabbix event: ' + zabbixBase + '/tr_events.php?triggerid='
      + encodeURIComponent(clean(Planfix.params.trigger_id)) + '&eventid=' + encodeURIComponent(clean(Planfix.params.event_id));

    var body = {
      name:        clean(Planfix.params.alert_subject),
      description: fitHtml(clean(Planfix.params.alert_message), tail),
      project:     clean(Planfix.params.planfix_project),
      event_id:    clean(Planfix.params.event_id),
      trigger_id:  clean(Planfix.params.trigger_id),
      host_name:   clean(Planfix.params.host_name),
      host_ip:     clean(Planfix.params.host_ip),
      severity:    clean(Planfix.params.severity),
      event_time:  clean(Planfix.params.event_time),
      tenant_id:   clean(Planfix.params.tenant_id)
    };
    var data = Planfix.httpPost(Planfix.params.planfix_hook_new, body);
    if (!data.task) throw 'No task id in Planfix response: ' + JSON.stringify(data);
    return data.task;
  },

  updateTask: function (taskId, comment, status, userfullname) {
    return Planfix.postComment(Planfix.params.planfix_hook_edit, 'Update: ', taskId, comment, status, userfullname);
  },

  ackTask: function (taskId, comment, status, userfullname) {
    return Planfix.postComment(Planfix.params.planfix_hook_ack, 'ACK: ', taskId, comment, status, userfullname);
  },

  unAckTask: function (taskId, comment, userfullname) {
    return Planfix.postComment(Planfix.params.planfix_hook_unack, 'UNACK: ', taskId, comment, null, userfullname);
  },

  postComment: function (hook, label, taskId, comment, status, userfullname) {
    var body = { task: String(taskId) };
    comment = fitHtml(clean(comment), '');
    if (comment)      body.comment = comment;
    if (status)       body.status = status;
    if (userfullname) body.userfullname = userfullname;
    var data = Planfix.httpPost(hook, body);
    if (!data.task) throw label + 'No task id in Planfix response: ' + JSON.stringify(data);
    return data.task;
  },

  getTaskLink: function (taskId) {
    return Planfix.params.planfix_url + '/task/' + taskId;
  }
};

// helpers
// Данные идут телом POST в JSON-вебхуки ПланФикса. Лимита URL больше нет,
// но текст длиннее MAX_TEXT символов обрезается, ссылки из tail сохраняются.
var MAX_TEXT = 4000;
function toHtml(v) { return v.replace(/\r?\n/g, '<br>').replace(/(<br>\s*)+$/g, ''); }
function fitHtml(raw, tail) {
  if (raw.length <= MAX_TEXT) return toHtml(raw) + tail;
  var cut = raw.substring(0, MAX_TEXT);
  if (/[\uD800-\uDBFF]$/.test(cut)) cut = cut.slice(0, -1);
  return toHtml(cut) + '<br>…(текст обрезан, полностью — в Zabbix event)' + tail;
}
function clean(v) {
  if (v === null || v === undefined) return '';
  v = v.toString();
  if (v === '*UNKNOWN*') return '';
  if (/^\{.*\}$/.test(v)) return ''; // голый макрос {SOMETHING}
  return v;
}
function isUnset(v) { return clean(v) === ''; }

// статусы
var statusWork  = 'В работе';
var statusClose = 'Завершенная';
var statusCancel = 'Отмененная';

// MAIN
Planfix.logEnabled = false;
Planfix.log(3, 'Webhook started');

try {
  var params = JSON.parse(value);
  Planfix.log(3, 'Params: ' + JSON.stringify(params));

  var planfix = {};
  Object.keys(params).forEach(function (k) { planfix[k] = params[k]; });

  Planfix.setParams(planfix);
  Planfix.validateParams();

  var result  = { tags: {} };

  var taskId  = clean(params.planfix_taskid);
  var comment = clean(params.alert_message || '');
  var ackUser = clean(params.planfix_acknowledge_user || '');
  var userId  = clean(params.planfix_default_userid || '');

  // user@domain из скобок
  var ackUserUsername = '';
  var m = ackUser.match(/\(([^)]+)\)/);
  if (m && m[1]) { ackUserUsername = m[1]; Planfix.log(3, 'ackUserUsername extracted: ' + ackUserUsername); }

  // флаги
  var actionLine = (comment.split('\n')[0] || '').toLowerCase();
  var isUnAcknowledge = actionLine.indexOf('unacknowledged') !== -1;
  var isAcknowledge   = !isUnAcknowledge && actionLine.indexOf('acknowledged') !== -1;

  var isRecovery   = (params.event_source === '0' && params.event_value === '0');
  var isProblemNew = (params.event_source === '0' && params.event_value === '1' && params.event_update_status === '0');
  var isEscalationCanceled = /escalation\s+cancell?ed/i.test(comment);
  var haveTask     = !isUnset(taskId);

  Planfix.log(3, 'Flags: isAck=' + isAcknowledge + ', isUnAck=' + isUnAcknowledge + ', isRecovery=' + isRecovery + ', isProblemNew=' + isProblemNew + ', isCancel=' + isEscalationCanceled + ', taskId=' + (taskId || '*empty*'));

  // выбор действия
  if (isEscalationCanceled) {
    if (!haveTask) { Planfix.log(3, 'skip cancel: no taskId'); return JSON.stringify(result); }
    Planfix.log(3, 'Escalation canceled → cancel task');
    Planfix.updateTask(taskId, comment, statusCancel, ackUserUsername || '');

  } else if (isRecovery) {
    if (!haveTask) { Planfix.log(3, 'skip recovery: no taskId'); return JSON.stringify(result); }
    Planfix.log(3, 'Recovery → close task');
    Planfix.updateTask(taskId, comment, statusClose, ackUserUsername || '');

  } else if (isAcknowledge) {
    if (!haveTask) { Planfix.log(3, 'skip ack: no taskId'); return JSON.stringify(result); }
    Planfix.log(3, 'ACK → assign executor');
    Planfix.ackTask(taskId, comment, statusWork, ackUserUsername || '');

  } else if (isUnAcknowledge) {
    if (!haveTask) { Planfix.log(3, 'skip unack: no taskId'); return JSON.stringify(result); }
    Planfix.log(3, 'UNACK → return to default');
    Planfix.unAckTask(taskId, comment, userId || '');

  } else if (isProblemNew) {
    Planfix.log(3, 'New PROBLEM → create task');
    var newTaskId = Planfix.createTask();
    result.tags['__zbx_planfix_taskid'] = newTaskId;
    result.tags['__zbx_planfix_link']   = Planfix.getTaskLink(newTaskId);

  } else {
    if (!haveTask) { Planfix.log(3, 'skip update: no taskId'); return JSON.stringify(result); }
    Planfix.log(3, 'Regular update → add comment');
    Planfix.updateTask(taskId, comment, null, ackUserUsername || ackUser || '');
  }

  Planfix.log(3, 'Result: ' + JSON.stringify(result));
  return JSON.stringify(result);

} catch (error) {
  Planfix.log(3, '[ Planfix Webhook ] ERROR: ' + error);
  throw 'Sending failed: ' + error;
}
				
			

Контроль доставки: как узнать, что алерты не доходят до ПланФикса

Как показал кейс выше, недоставленный алерт в Zabbix не становится событием, поэтому контроль сделан отдельно. Раз в 5 минут cron на сервере Zabbix считает в базе недоставленные алерты медиатипа ПланФикс за последний час и отправляет число через zabbix_sender в элемент данных типа Zabbix trapper с ключом planfix.alerts.failed на хосте «Zabbix server».

Схема интеграции Zabbix и ПланФикс: POST JSON во входящий вебхук и контроль недоставленных алертов с уведомлением в Telegram
				
					#!/bin/sh
# /usr/local/bin/planfix_alerts_failed.sh
# Недоставленные (status=2) алерты медиатипа ПланФикс за последний час.
# При ошибке БД ничего не отправляем: сработает триггер nodata.
# Запуск: /etc/cron.d/planfix-alerts-failed
#   */5 * * * * root /usr/local/bin/planfix_alerts_failed.sh
C=/etc/zabbix/zabbix_server.conf
export PGPASSWORD="$(sed -n "s/^DBPassword=//p" $C)"
SQL="select count(*) from alerts
     where mediatypeid = <id> and status = 2
       and clock > extract(epoch from now())::int - 3600"
N=$(psql -h "$(sed -n "s/^DBHost=//p" $C)" -U zabbix -d zabbix -AtX -c "$SQL") || exit 1
exec zabbix_sender -z 127.0.0.1 -s "Zabbix server" -k planfix.alerts.failed -o "$N" >/dev/null
				
			

В таблице alerts время хранится как целое число (Unix time), поэтому граница окна считается через extract(epoch from now()). Для MySQL то же условие пишется как clock > UNIX_TIMESTAMP() - 3600. Параметры подключения скрипт берёт из zabbix_server.conf, поэтому cron запускается от root; вместо этого можно завести отдельного пользователя БД с правом только на чтение таблицы alerts. Если запрос к базе не прошёл, скрипт ничего не отправляет, и через 20 минут срабатывает второй триггер.

На элемент данных два триггера:

ТриггерВыражениеКогда срабатывает
Planfix: алерты не доставляютсяlast(/Zabbix server/planfix.alerts.failed)>0за последний час есть хотя бы один недоставленный алерт; закрывается сам, когда час проходит без ошибок
Planfix: контроль доставки не присылает данныеnodata(/Zabbix server/planfix.alerts.failed,20m)=1проверка сама перестала работать: cron, база или zabbix_sender

Оба триггера с важностью High, уведомления по ним уходят в Telegram — другим каналом, не через ПланФикс. Если сломается сам ПланФикс, уведомление через него не дойдёт. По той же причине проверьте, что фильтр действий «PlanFix | Report …» эти триггеры не захватывает: с важностью High они подходят под «PlanFix | Report high», если хост «Zabbix server» не входит в исключённые служебные группы. Первая ошибка 414 появилась в журнале алертов 22 сентября: с таким триггером о сбое стало бы известно через 5 минут, а не спустя почти шесть дней.

Чек-лист и проверка перед запуском

Перенос схемы на другую инсталляцию — это правка параметров медиатипа и четырёх вебхуков на стороне ПланФикса. Чек-лист, собранный из того, что мы переписывали по ходу проекта:

  • Media type = Webhook: скрипт-автомат на JavaScript, статусы ПланФикса вынесены в переменные.
  • Данные — в теле POST, не в URL: JSON-вебхуки ПланФикса, в URL только адрес. Кириллица в строке запроса быстро упирается в лимит 8 КБ.
  • Теги события как память: возвращайте __zbx_* теги при создании и ищите задачу по ним в остальных ветках; включите process_tags=1 и show_event_menu для ссылки из карточки.
  • Дубли: сценарий zabbix-new ищет задачу по Event ID и создаёт новую, только если не нашёл; поле Event ID — числовое. maxsessions=1 держит порядок: закрытие не обгонит создание.
  • Шумовой фильтр через эскалацию: операция создания на шаге не ниже 2 плюс условие «Event acknowledged = No», чтобы короткие мигания и быстро подтверждённые проблемы не плодили задачи.
  • Шаблоны для всех состояний: problem, recovery и update, иначе пустой {ALERT.MESSAGE} уронит webhook.
  • Отмена эскалации первой веткой: notify_if_canceled=1 плюс проверка текста «Escalation canceled» раньше ветки восстановления.
  • Исключайте служебные группы из маршрутизации и фильтруйте suppressed.
  • Сервисный пользователь с единственным каналом даёт чистую схему «действие → пользователь → media type».
  • Контроль доставки через другой канал: триггер на недоставленные алерты и на пропавшие данные проверки, уведомление в обход ПланФикса.
  • Проверяйте доставку по каждому действию отдельно, а не по факту «что-то приходит»: целый класс алертов может не доходить до трекера, пока остальные доходят.

Перед первым реальным алертом проверьте вебхуки вручную. Первый запрос имитирует zabbix-new: если в ответе нет поля task, скрипт бросит исключение. Повтор того же запроса должен вернуть тот же номер — так проверяется поиск по Event ID. Второй запрос проверяет zabbix-edit без создания задач:

				
					# zabbix-new: создать задачу (повтор вернёт тот же номер — дубля нет)
curl -X POST 'https://<аккаунт>.planfix.ru/webhook/json/<id-new>' \
  -H 'Content-Type: application/json' \
  -d '{"name":"[TEST] проверка интеграции","description":"тестовая задача из Zabbix",
       "project":"ZABBIX","event_id":"999001","host_name":"host-07","severity":"High"}'
# ожидаемый ответ: {"task": 12345}

# zabbix-edit с несуществующей задачей: вебхук принял JSON и прочитал $.task
curl -X POST 'https://<аккаунт>.planfix.ru/webhook/json/<id-edit>' \
  -H 'Content-Type: application/json' \
  -d '{"task":"999999999","comment":"проверка"}'
# ожидаемый ответ: {"error":"task not found"}
				
			

Итог

Часто задаваемые вопросы

Через штатный медиатип Webhook в Zabbix. Скрипт на JavaScript отправляет POST-запросы во входящие JSON-вебхуки ПланФикса. Действия Zabbix шлют уведомления служебному пользователю, у которого единственный канал — этот медиатип. Обратное направление, подтверждение события при принятии задачи, делает сценарий ПланФикса через API Zabbix.
Действие Zabbix отправляет уведомление на шаге эскалации 2, если проблему с важностью Average и выше за период эскалации не подтвердили. Скрипт отправляет POST JSON во входящий вебхук zabbix-new: название, описание, проект, Event ID, хост и важность. ПланФикс создаёт задачу или находит существующую по Event ID и возвращает её номер, а скрипт сохраняет его в тегах события.
Это тип канала оповещений Zabbix, который вместо письма или SMS выполняет JavaScript-код. Скрипт получает данные события, отправляет HTTP-запросы и может вернуть в Zabbix теги события. Так один медиатип создаёт задачи, меняет их статусы и назначает исполнителей в зависимости от типа события: проблема, восстановление, подтверждение.
Сценарий вебхука zabbix-new в ПланФиксе сначала ищет задачу по полю Event ID и создаёт новую, только если не нашёл. Повторная отправка — ретрай Zabbix или перезапуск эскалации — возвращает номер существующей задачи. Поле Event ID должно быть числовым: строку с буквами ПланФикс не запишет, и поиск перестанет находить задачу. Параметр maxsessions=1 дополнительно держит порядок: закрытие не обгонит создание.
В GET-запросе данные идут в URL, а длина строки запроса ограничена: у Jetty и nginx по умолчанию 8 КБ. Кириллическая буква в URL занимает 6 байт, поэтому текст алерта около 1 800 символов уже не проходит, и сервер отвечает 414 URI Too Long. В POST данные идут в теле запроса, в URL остаётся только адрес вебхука.
Недоставленный алерт в Zabbix не создаёт событие: ошибка остаётся только в журнале алертов, и штатного триггера на неё нет. Нужен отдельный контроль: элемент данных с числом недоставленных алертов за час и триггер, который уведомляет по другому каналу, а не через ПланФикс.
Повторяет отправку столько раз, сколько задано в maxattempts, хотя ответ сервера на тот же запрос не изменится. Поэтому скрипт пишет в текст ошибки код ответа и первые 300 символов тела, а не HTML-страницу целиком: причина сразу видна в журнале алертов.

Константин Тютюнник — ведущий инженер IT For Prof. Специализируется на системах мониторинга и автоматизации IT-процессов: внедрение Zabbix, интеграции с таск-трекерами, webhook-автоматизация инцидентов.

Настраиваем связку Zabbix с таск-трекером под процесс техподдержки: маршрутизация задач по клиентам, дедупликация, контроль доставки алертов. Если нужна интеграция Zabbix и ПланФикс, оставьте заявку на консультацию.

Поделиться: