Incident Garden

Что такого в числе 65 535 и как оно относится к Zabbix webhook

Почему длинное оповещение из Zabbix приходит битым JSON или с половиной текста при статусе Sent.

Длинный лог в оповещении Zabbix может сломать JSON и дать Failed с SyntaxError, однако есть проблема и похуже: в Action log написано Sent, а получатель видит только часть текста. Оба кейса связаны с пределом 65 535, о котором Zabbix не предупреждает ни в интерфейсе, ни в журнале сервера.

TL;DR: Речь про ограничение размера сообщения. Если лень читать, то сразу смотрите чек-лист (семь коротких правил и готовый скрипт).

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

Предел воспроизвёлся на Zabbix 7.0 с PostgreSQL и MySQL, а также на 6.0 с PostgreSQL. Эксперимент поставили следующим образом: отправляли в элемент данных латинский и русский текст длиной около границы. Одно событие попадало в несколько типов оповещения с разными параметрами. Длину сверяли в сохранённом значении Zabbix, на входе скрипта и у тестового приёмника.

Предел общий для всех параметров

Zabbix собирает параметры типа оповещения в один JSON и передаёт скрипту строкой value. Обратите внимание, что в предел 65 535 входит вся строка вместе с именами параметров, кавычками и запятыми. Если два параметра содержат один {ITEM.VALUE}, текст занимает место дважды. Лимит расходует даже перевод строки (еще бы, это ведь по сути тоже символ), при том в двойном размере, потому что в JSON он превращается в \n.

Нюанс в том, что PostgreSQL считает символы, а MySQL байты. При этом для латиницы результат одинаковый. С кириллицей ситуация отличается: буква в UTF-8 занимает два байта и вот тут исход уже зависит от конкретной СУБД. Таблица показывает, когда оповещение получит Failed, а когда сохранит Sent при потерянном тексте.

Что в оповещенииPostgreSQLMySQL
латиница длиннее 65 500 символовFailed, JSON обрезанFailed, JSON обрезан
кириллица длиннее 32 700 символов, один параметрSent, но хвост потерянFailed, JSON обрезан
тот же текст в двух параметрахFailed, JSON обрезанFailed, JSON обрезан
кириллица до 30 000 символов, один параметрSent, текст целикомSent, текст целиком

Кириллический текст до 30 000 символов с одним параметром дошёл целиком на обеих базах. Остальные случаи проявились двумя разными симптомами.

Failed с SyntaxError означает обрезанный JSON

Обрезанная строка value вызывает исключение в JSON.parse, после чего оповещение получает Failed. Скрипт из чек-листа добавляет длину строки в ошибку. Первый пример получен на PostgreSQL, второй на MySQL с русским текстом.

JSON parse failed: len=65535 SyntaxError: invalid json (at offset 65536)
JSON parse failed: len=32772 SyntaxError: invalid json (at offset 65535)

len=65535 показывает, что строка оборвалась на границе. На MySQL с кириллицей получаем длину в символах примерно вдвое меньше, но смещение ошибки достигает тех же 65 535 в любом случае.

На MySQL русский текст проходит через две границы. Zabbix сначала укорачивает значение элемента данных до 65 535 байт, а затем имена параметров и синтаксис JSON добавляют ещё несколько байт, из-за чего JSON обрывается даже с одним параметром.

Если ваш скрипт пересылает value без разбора, получаем повреждённый JSON и он может уйти получателю и при этом получить статус Sent! Собственно по этой причине защитный скрипт начинает работу с JSON.parse(value). Ошибка видна уже в Action log.

Sent не гарантирует полный текст

На PostgreSQL целый JSON и статус Sent могут скрыть потерю части русского текста (а вы вообще отправляете текст алертов на русском?). Значения {ITEM.VALUE}, {ITEM.LASTVALUE} и {ALERT.MESSAGE} (если шаблон сообщения содержит {ITEM.VALUE}) доходят до скрипта не длиннее 65 535 байт. Латинский текст достигает общего предела и ломает JSON. У кириллического текста может исчезнуть хвост при целой JSON-строке.

На стенде Zabbix сохранил в истории элемента данных почти 65 000 русских символов целиком, но через каждый из трёх макросов скрипт получил примерно половину (что как бы ожидаемо ввиду размера символа кириллицы, мы упоминали это выше). Журнал сервера с уровнем по умолчанию не сообщил об обрезке. На снимке два оповещения одного события из Action log. У верхнего текст оборван на полуслове, хотя статус Sent и поле ошибки пустое. У нижнего с двумя параметрами обрезался JSON, и скрипт поставил Failed с len=65535.

Action log: сообщение оборвано на полуслове при статусе Sent, а соседнее оповещение Failed с len=65535

Совет: ограничьте длинный текст до 30 000 символов ещё до Zabbix.

Чек-лист и скрипт с защитой от обрезки

  1. Передавайте большой текст одним параметром. Если шаблон сообщения содержит {ITEM.VALUE}, используйте message={ALERT.MESSAGE}. Дополнительный value={ITEM.VALUE} потратит запас повторно.
  2. Считайте запас с экранированием. Каждый перевод строки стоит два символа, поэтому 30 000 строк по одному символу в 65 535 уже не влезают.
  3. Ограничьте текст до отправки в Zabbix 30 000 символами, если в нём бывает кириллица. Например на Python это можно сделать через value = value[:30000].
  4. Разбирайте value первой строкой и кладите длину в текст исключения, чтобы Failed показал причину падения:
    var p; try { p = JSON.parse(value); } catch (e) { throw 'JSON parse failed: len=' + value.length + ' ' + e; }
  5. Помечайте текст, упёршийся в 65 535 байт, вместо исключения, например message += '\n[message cut by Zabbix]'; при utf8Bytes(message) >= 65532.
  6. Проверяйте JSON у получателя, если скрипт пересылает value как есть. На невалидное тело получатель отвечает 400, а скрипт выбрасывает исключение на любой ответ не 2xx, как в первой части серии.
  7. Пишите подозрение на обрезку в журнал сами, сервер о ней молчит: Zabbix.log(3, 'webhook: message may be cut by Zabbix, bytes=' + bytes);.

Скрипт выполняет пункты 4, 5 и 7, а также рекомендации из чек-листа первой части. Он сначала разбирает value, затем считает байты в message перед отправкой. Используйте те же параметры типа оповещения: url=<адрес получателя>, event_id={EVENT.ID}, event_value={EVENT.VALUE}, alert_date={DATE}, alert_time={TIME}, subject={ALERT.SUBJECT}, message={ALERT.MESSAGE}.

function utf8Bytes(s) {
  var n = 0;
  for (var i = 0; i < s.length; i++) {
    var c = s.charCodeAt(i);
    if (c < 0x80) { n += 1; }
    else if (c < 0x800) { n += 2; }
    else if (c > 0xFFFF) { n += 4; }
    else { n += 3; }
  }
  return n;
}
 
var p;
try {
  p = JSON.parse(value);
} catch (e) {
  throw 'JSON parse failed: len=' + value.length + ' ' + e;
}
 
var message = p.message;
var bytes = utf8Bytes(message);
if (bytes >= 65532) {
  Zabbix.log(3, 'webhook: message may be cut by Zabbix, bytes=' + bytes);
  message += '\n[message cut by Zabbix]';
}
 
var req = new HttpRequest();
req.addHeader('Content-Type: application/json');
var dedupKey = p.event_id + ':' + p.alert_date + ' ' + p.alert_time;
var resp = req.post(p.url, JSON.stringify({
  dedup_key: dedupKey,
  event_id: p.event_id,
  event_value: p.event_value,
  subject: p.subject,
  message: message
}));
var status = req.getStatus();
if (status < 200 || status >= 300) {
  throw 'Receiver answered HTTP ' + status + ': ' + resp;
}
return 'OK';

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

Что оставить в оповещении

Короткий алерт о загрузке процессора почти никогда не достигает предела 65 535. Полный лог в оповещении может его превысить. Дежурному обычно достаточно первых строк ошибки, имени хоста и ссылки на полный лог вместо текста на 60K+ символов. Такое сообщение быстро читается и помещается в предел.

Помните, что агрегировать сообщения от любых систем мониторинга, в том числе от Zabbix, можно в Incident Garden.