Запись

Webhook Zabbix под капотом: куда пропадают теги и почему не приходят события восстановления

Почему событие из Zabbix приходит в webhook без тега команды, ручной JSON ломается и в каком случае за проблемой не придёт восстановление.

Вы пишете webhook, который передаёт проблемы из Zabbix в трекер задач, чат или сервис дежурств. Получатель распределяет события по командам с помощью тегов team и env, показывает значение метрики и закрывает инцидент после восстановления. Кажется, что теги с шаблонов и хоста придут вместе с событием, числа останутся числами, а за каждой проблемой рано или поздно придёт восстановление. Zabbix может нарушить каждое из этих ожиданий, и вы об этом не узнаете.

Разберём, что приходит на самом деле, где это увидеть в Zabbix и как настроить webhook с учётом такого поведения.

TL;DR: Если нужны только рекомендации, переходите к чек-листу. Для каждого пункта там есть пример.

Как проверяли

Мы подняли Zabbix 7.0 и 6.0 и расставили теги на триггере, элементе данных, хосте, шаблоне и вложенном шаблоне. В значения добавили кавычки, запятые, переводы строки, кириллицу и эмодзи. Webhook пересылал всё полученное на тестовый приёмник, где мы сравнивали данные с настройками Zabbix. Эскалации проверили отдельно: закрывали проблемы после последнего шага и выключали действие посреди эскалации.

Теги для маршрутизации задавайте на хосте и читайте из {EVENT.TAGSJSON}

Тег в Zabbix задаётся парой имени и значения, например team=db. По смыслу это похоже на метку в Prometheus. Теги можно задать на триггере, элементе данных, хосте и шаблоне. Шаблон содержит элементы данных и триггеры; его привязывают к хосту или другому шаблону. В последнем случае он становится вложенным. Событие собирает теги с этих уровней. Если получатель распределяет события по командам, потерянный тег team помешает маршрутизации.

Тег для маршрутизации ставьте на хост, а не на шаблон

Шаблон Base DB с тегом team=db вложен в PostgreSQL, что видно в столбце Linked to templates. Шаблон PostgreSQL привязан к хосту db-01:

Шаблон Base DB с тегом team=db вложен в шаблон PostgreSQL

Откроем триггер «Replication lag is too high» из шаблона PostgreSQL на хосте db-01. В Data collection → Hosts → Triggers на вкладке Tags выберем Inherited and trigger tags. Здесь видны теги, которые получит событие: env=prod с хоста, service=postgresql с шаблона PostgreSQL и scope=availability с самого триггера. Тега team=db среди них нет:

Теги триггера из шаблона PostgreSQL на хосте db-01: team=db среди них нет

Тег team=db будет только у события триггера «Too many connections» из Base DB. У событий триггеров из PostgreSQL его не будет: тег шаблона получают только события его собственных триггеров. Поэтому:

  • тег вложенного шаблона не получат события триггеров внешнего шаблона, как в примере выше;
  • триггер, созданный прямо на хосте, не получит ни одного тега шаблонов.

Часть событий хоста придёт получателю без team и не попадёт ни к одной команде. Тег хоста присутствует в каждом его событии, поэтому для маршрутизации лучше задать team на хосте. Если задаёте его в шаблонах ради централизованного управления, проверяйте наличие тега у каждого нужного триггера.

Теги читайте из {EVENT.TAGSJSON}, а {EVENT.TAGS} не разбирайте

{EVENT.TAGSJSON} возвращает массив объектов {"tag": ..., "value": ...}. Значения тегов в нём корректно разбираются даже с кавычками, обратной косой чертой, переводом строки, кириллицей и эмодзи. {EVENT.TAGS} возвращает одну строку: пары имя:значение разделены запятой с пробелом и отсортированы по имени. Запятые и двоеточия внутри значений не экранируются. Например:

commas:a, b: c, dup:host, dup:item, dup:tpl, dup:trig, host_tag:from_host, ...

Начало строки выглядит как два тега, хотя это один тег commas со значением a, b: c. После разделения строки по запятым восстановить его уже не получится.

Одно имя тега может встретиться несколько раз. Тег dup с разными значениями на триггере, элементе данных, хосте и шаблоне дал четыре элемента массива. А одинаковая пара имени и значения с нескольких уровней слилась в один элемент. Чтобы сохранить все значения, собирайте их в список по имени:

var p = JSON.parse(value);
var list = JSON.parse(p.tagsjson);
var tags = {};
for (var i = 0; i < list.length; i++) {
  if (!tags.hasOwnProperty(list[i].tag)) {
    tags[list[i].tag] = [];
  }
  tags[list[i].tag].push(list[i].value);
}

Для этого фрагмента нужен параметр типа оповещения tagsjson={EVENT.TAGSJSON}. Если параметры обрежутся из-за предела длины, JSON.parse выбросит исключение и оповещение получит статус Failed. О пределе длины расскажем в следующей части. Код написан на ES5: скрипты выполняет движок Duktape, имя которого видно в стеке ошибок.

Не полагайтесь на порядок тегов. Zabbix выдаёт их группами: сначала теги триггера, затем элемента данных, хоста и шаблона. Внутри группы алфавитного порядка нет. Изменение важности триггера и повторная отправка тех же тегов порядок не меняли. После изменения набора тегов триггера порядок менялся, причём в 6.0 и 7.0 по-разному. Поэтому выбор первого или последнего значения одноимённого тега ненадёжен. Передавайте получателю весь список или задавайте нужный тег только на одном уровне.

{EVENT.TAGS.имя} без тега: *UNKNOWN* на 7.0 и сам макрос на 6.0

Макрос {EVENT.TAGS.env} удобен, если у события есть тег env. Если тега нет, скрипт получит не пустую строку. Zabbix 7.0 подставит *UNKNOWN*, а Zabbix 6.0 оставит текст макроса. Вот значения одного поля с двух стендов:

7.0:  "tag_missing":"*UNKNOWN*"
6.0:  "tag_missing":"{EVENT.TAGS.nonexist}"

Если получатель не учитывает это поведение, он может создать окружение с именем *UNKNOWN* или {EVENT.TAGS.env}. Проверить наличие тега можно в колонке Tags раздела Monitoring → Problems. С другими макросами происходит похожее. На 7.0 {EVENT.RECOVERY.ID} остаётся текстом вне восстановления, а {EVENT.UPDATE.TIME} вне обновления. Макрос {ESC.STEP} остаётся текстом всегда. Номер шага эскалации не передаётся в параметрах webhook; о том, как различать шаги, мы писали в первой части.

Необязательные теги удобнее брать из {EVENT.TAGSJSON}: отсутствующего тега просто не будет в массиве. Если нужен отдельный макрос, проверяйте обе формы отсутствия:

function expanded(v, macro) {
  return v !== macro && v !== '*UNKNOWN*';
}
var env = expanded(p.env, '{EVENT.TAGS.env}') ? p.env : null;

Проверка первого символа на { даст ложный результат для параметра, в котором вы намеренно передаёте JSON. Отдельный макрос также не подходит для одноимённых тегов.

Все параметры webhook приходят строками, даже {EVENT.ID} и {ITEM.VALUE}

Параметры типа оповещения задаются в таблице Parameters на вкладке Media type в разделе Alerts → Media types → ваш тип. Значение каждого параметра приходит в скрипт строкой, даже если в ячейке стоит число 500. После JSON.parse(value) тип string будет и у {EVENT.ID}, и у {ITEM.VALUE} числового элемента данных, и у параметра с JSON внутри. При вычислениях эта разница сразу заметна:

Выражение в скриптеЧто ушло получателю
p.item_value"500"
p.item_value + 1"5001"
Number(p.item_value)500
Number(p.item_value) + 1501
JSON.parse(p.jobj) при jobj={"n":500,"s":"500"}{"n":500,"s":"500"}

Получатель увидит число, только если скрипт преобразует значение через Number или разберёт вложенный JSON через JSON.parse. Если переслать параметры как есть, получатель со строгой схемой может отвергнуть поле. А получатель, который складывает значения без преобразования в числа, сложит строки вместо чисел.

Преобразуйте в число только те поля, где оно ожидается. Для текста, например *UNKNOWN* или значения текстового элемента данных, Number вернёт NaN. При сериализации через JSON.stringify он превратится в null. Если вместо этого выбросить исключение, оповещение получит статус Failed, а повторные попытки столкнутся с тем же значением. Передайте получателю и результат преобразования, и исходную строку:

var n = Number(p.item_value);
body.value = isNaN(n) ? null : n;
body.value_raw = p.item_value;

Если получатель сам преобразует строки в числа, менять скрипт не нужно.

JSON, собранный из макросов вручную, ломается на кавычке и переводе строки

Шаблон {"title":"{EVENT.NAME}"} можно указать в параметре или в тексте сообщения на вкладке Message templates. Пока в имени триггера нет двойной кавычки или перевода строки, он выглядит рабочим. Zabbix подставляет значение без экранирования, поэтому JSON с такими символами ломается. Кириллица и эмодзи не мешают разбору. Например, для триггера q"uote back\slash Привет 😀 тело, собранное вручную, выглядело так:

{"title":"q"uote back\slash Привет 😀"}

А после JSON.stringify в скрипте так:

{"title":"q\"uote back\\slash Привет 😀"}

Python json.loads не смог разобрать вручную собранный JSON: в примере выше из-за кавычки, в отдельной проверке из-за перевода строки. Сообщения об ошибках:

Expecting ',' delimiter: line 1 column 13 (char 12)
Invalid control character at: line 1 column 16 (char 15)

Zabbix отправит такое оповещение как обычно: он только подставляет текст и не проверяет готовый JSON. Ошибку обнаружит получатель при разборе. Передавайте каждый макрос отдельным параметром, например name={EVENT.NAME} и weird={EVENT.TAGS.weird}, а тело собирайте в скрипте через JSON.stringify. На обеих версиях оно оставалось корректным при всех проверенных именах. Значение тега с кавычкой ломало ручную сборку точно так же.

Макросы раскрываются при создании оповещения: {ITEM.LASTVALUE} на шаге 2 эскалации уже новое

Zabbix раскрывает макросы при создании оповещения. На 7.0 все повторы одного оповещения содержали одинаковые значения: третья попытка через 10 секунд после первой сохранила исходные {TIME} и возраст события. Каждый шаг эскалации создаёт новое оповещение, поэтому макросы раскрываются заново. В Reports → Action log шаг виден отдельной записью. Разницу хорошо показывают значения элемента данных. Когда он выдал 500, открылась проблема. Через 6 секунд значение выросло до 900:

Оповещение{ITEM.VALUE}{ITEM.LASTVALUE}
шаг 1500500
шаг 2, через минуту500900

На обеих версиях {ITEM.VALUE} сохраняет значение, при котором сработал триггер. {ITEM.LASTVALUE} берёт текущее значение на момент шага. Передавайте оба, если на следующем шаге нужно видеть, изменилась ли ситуация. Время получения и возраст события на момент доставки лучше вычислять у получателя: при повторах значения в теле остаются прежними.

Это же свойство помогает составить ключ для отсева дублей, о котором мы писали в первой части.

Recovery приходит и после завершённой эскалации, но не после отменённой

Если у действия настроена операция восстановления, recovery придёт и после завершения всех шагов эскалации. Мы закрыли проблему через 2 минуты 38 секунд после первого оповещения. Единственный шаг длился минуту, но recovery ушёл на обеих версиях. Так работали и уведомление всех участников, и отправка конкретному пользователю.

Если действие выключить посреди эскалации, recovery не придёт. Даже после повторного включения действия и закрытия проблемы инцидент у получателя останется открытым. Вместо recovery придёт лишнее оповещение с темой из шаблона проблемы и значением {EVENT.VALUE} равным 1. Оно отправляется не сразу после выключения, а в момент следующего шага. В наших проверках это происходило через минуту при шаге в минуту и через пять минут при шаге в пять минут. В Reports → Action log оно выглядит как ещё одна отправка по той же проблеме. Отличить его можно по началу сообщения:

NOTE: Escalation canceled: action 'exp04-action-rec' disabled.
Last message sent:
exp04-trig-r1

Чтобы не создать у получателя новую проблему, передайте в скрипт параметр message={ALERT.MESSAGE} и проверяйте префикс NOTE: Escalation canceled. Он сохранялся на 6.0 и 7.0 как с собственным шаблоном типа оповещения, так и с сообщением, заданным в операции действия.

if (p.message.indexOf('NOTE: Escalation canceled') === 0) {
  body.kind = 'escalation_canceled';
}

Пометьте такую проблему у получателя. После сверки с Zabbix её должен закрыть человек, поскольку recovery уже не придёт. Автоматическое закрытие может скрыть проблему, которая всё ещё существует.

Чек-лист: что проверить в скрипте webhook

  1. Читайте теги из {EVENT.TAGSJSON} через JSON.parse. Строку {EVENT.TAGS} не разбирайте: запятые и двоеточия в значениях не экранируются. Параметр: tagsjson={EVENT.TAGSJSON}.
  2. Собирайте значения одноимённых тегов в список. На их порядок не опирайтесь: tags[list[i].tag].push(list[i].value).
  3. Теги для маршрутизации ставьте на хост. Тег шаблона получают только события его триггеров. Триггеры внешнего шаблона и созданные на хосте его не получат. Проверяйте теги триггера в Tags → Inherited and trigger tags.
  4. Берите необязательные теги из {EVENT.TAGSJSON}. Если используете {EVENT.TAGS.<имя>}, проверяйте обе формы отсутствия: v !== '{EVENT.TAGS.env}' && v !== '*UNKNOWN*'.
  5. Преобразуйте числа явно и сохраняйте исходное значение: body.value = isNaN(n) ? null : n; body.value_raw = p.item_value;.
  6. Передавайте каждый макрос отдельным параметром и собирайте тело через JSON.stringify: name={EVENT.NAME} вместо {"title":"{EVENT.NAME}"} в одном параметре.
  7. Передавайте значение срабатывания и текущее значение: trigger_value={ITEM.VALUE}, current_value={ITEM.LASTVALUE}.
  8. Время доставки определяйте у получателя. Например, он сам заполнит поле received_at. Значения {TIME} и возраста события фиксируются при создании оповещения и при повторах не обновляются.
  9. Распознавайте отмену эскалации по префиксу в {ALERT.MESSAGE} и помечайте такие проблемы. Recovery не придёт, поэтому после сверки с Zabbix их закрывает человек. Параметр: message={ALERT.MESSAGE}. Проверка: p.message.indexOf('NOTE: Escalation canceled') === 0.

Что дальше

Скрипт, собранный по этому чек-листу, отдаёт получателю теги, числа и тексты ровно такими, какими их видит Zabbix, и не принимает отмену эскалации за новую проблему. Но о доставленном событии ещё нужно сообщить человеку. Если вам нужны эскалации и графики дежурств поверх Zabbix, Incident Garden принимает его события напрямую. Скрипт из инструкции по подключению Zabbix уже читает теги из {EVENT.TAGSJSON}. Если ночное сообщение в чате можно пропустить, настройте голосовые звонки: робот позвонит дежурному на телефон.

Перед использованием webhook проверьте в своём окружении сценарии с тегами, значениями и восстановлением. Если понадобится помощь, обратитесь в Telegram-чат DevOps, SRE, Infra.