Мониторинг Jitter и Packet Loss в Node.js для стабильной связи

Видеозвонок постоянно "заикается"? В онлайн-игре персонаж замирает, а потом "телепортируется" через полкарты? Чаще всего мы виним в этом высокий "пинг", но на самом деле за качественную связь в реальном времени отвечают два других, более важных параметра: джиттер (jitter) и потеря пакетов (packet loss).

В этой статье мы разберемся, что это такое, почему они важнее пинга для видео, игр и VoIP, и создадим собственную систему мониторинга этих параметров с помощью Node.js и VizIoT.

Что такое Jitter и почему он важен?

Все данные в интернете передаются небольшими порциями — пакетами. Пинг (Ping, RTT) — это время, за которое один пакет доходит до сервера и возвращается обратно. Но что, если пакеты приходят с разной задержкой?

Джиттер (Jitter) — это неравномерность задержки пакетов. Проще говоря, это "дрожание" пинга.

  • Идеально (низкий джиттер): Пакеты приходят через равные промежутки времени (например, каждые 20 мс, 20 мс, 20 мс). Связь плавная.
  • Плохо (высокий джиттер): Пакеты приходят хаотично (например, 10 мс, затем 50 мс, потом 5 мс). Устройствам приходится ждать "опоздавшие" пакеты, что вызывает заикания, "лаги" и рассыпание картинки.

Аналогия: Если пинг — это среднее время в пути для автобуса по маршруту, то джиттер — это то, насколько сильно он отклоняется от расписания. Автобус, который всегда приезжает с опозданием на 5 минут, лучше, чем тот, который приезжает то на 10 минут раньше, то на 20 минут позже.

Почему потеря пакетов (Packet Loss) хуже, чем высокий пинг?

Потеря пакетов (Packet Loss) — это процент пакетов, которые так и не дошли до получателя. Они просто теряются по пути из-за перегрузок сети, плохого оборудования или помех.

Для приложений реального времени, таких как видеозвонок или игра, потерянный пакет — это катастрофа. Система не может ждать его вечно и вынуждена либо проигнорировать этот фрагмент данных (что приводит к "выпадению" звука или "замиранию" картинки), либо пытаться угадать, что в нём было.

Медленный ответ (высокий пинг) почти всегда лучше, чем отсутствие ответа (потеря пакетов).

Какой результат мы получим?

Мы создадим систему, которая будет постоянно отправлять тестовые пакеты данных и анализировать, как они возвращаются. Результаты будут отображаться на интерактивном виджете в VizIoT, который покажет вам полную картину стабильности вашего соединения.

Как это работает?

Наше приложение будет состоять из двух частей:

  1. Клиент (Client): Запускается на вашем компьютере (например, Raspberry Pi или домашнем сервере). Он с высокой частотой отправляет UDP-пакеты на сервер.
  2. Сервер (Server): Его задача — максимально быстро получать пакеты от клиента и отправлять их обратно ("эхо").

Клиент, получая пакеты обратно, измеряет время их возвращения, сравнивает задержки между пакетами (вычисляя джиттер) и считает, сколько пакетов не вернулось (потеря пакетов).

В качестве сервера вы можете использовать:

  • Наш публичный сервер (рекомендуется для простоты): app.viziot.com:54321. Пример по умолчанию настроен на него.
  • Собственный сервер: Вы можете запустить серверную часть на своём VPS или втором устройстве, чтобы измерять качество связи между двумя конкретными точками.

Шаг 1: Установка NodeJS

Наш скрипт написан на Node.js. Мы будем работать в Linux (например, Ubuntu/Debian), но вы можете адаптировать это для любой ОС.

Рекомендуется устанавливать Node.js через NVM (менеджер версий).

  1. Установите NVM:
    curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
  2. Активируйте NVM (без перезахода в систему):
    \. "$HOME/.nvm/nvm.sh"
  3. Установите последнюю версию Node.js:
    nvm install 24
  4. Проверьте установку:
    node -v
    npm -v

Шаг 2: Создание клиентского приложения

Настройка проекта

mkdir viziot_udp_jitter
cd viziot_udp_jitter/
npm init -y
npm i viziot-mqtt-client-nodejs
nano client.js

Код клиента (client.js)

Вставьте следующий код. Не забудьте указать ключ и пароль вашего устройства VizIoT. Если вы еще не знаете как работает VizIoT советуем ознакомится с этой статьей Знакомство с VizIoT.

// client.js
const { fork } = require('child_process');
const VizIoTMQTT = require('viziot-mqtt-client-nodejs');

// ---------------------------------------
// Настройки
// ---------------------------------------
// 1. Введите ключ и пароль вашего устройства VizIoT
const keyDevice = '____________';
const passDevice = '_________________';

// Данные сервера (публичный сервер VizIoT)
const HOST = 'app.viziot.com';
const PORT = 54321;

// Настройки теста
const TEST_INTERVAL = 30000; // Интервал запуска теста (30 секунд)
const COUNT_PACKETS = 500;   // Количество пакетов в одном тесте
const PACKET_INTERVAL = 10;  // Задержка между отправкой пакетов (10 мс)

// ---------------------------------------
// VizIoT MQTT
// ---------------------------------------
let isConnected = false;
const viziotMQTTClient = new VizIoTMQTT(keyDevice, passDevice);
viziotMQTTClient.connect(() => {
    isConnected = true;
    console.log("Успешно подключено к VizIoT MQTT.");
});

// ---------------------------------------
// Логика работы
// ---------------------------------------

if (COUNT_PACKETS * PACKET_INTERVAL > TEST_INTERVAL * 0.8) {
    console.error('Ошибка конфигурации: время теста превышает интервал между тестами!');
    process.exit(1);
}

function runTestCycle() {
    console.log(`\n▶ Запуск нового теста (пакетов: ${COUNT_PACKETS})`);
    worker.send({ cmd: 'startTest' });

    let seq = 0;
    const sendInterval = setInterval(() => {
        if (seq >= COUNT_PACKETS) {
            clearInterval(sendInterval);
            setTimeout(() => {
                worker.send({ cmd: 'stopTest' });
            }, 2000); // Даем 2 секунды на возврат последних пакетов
            return;
        }
        worker.send({
            cmd: 'sendPacket',
            idBuffer: Buffer.from(keyDevice, 'utf8'),
            seq: seq++,
        });
    }, PACKET_INTERVAL);
}

function showResults(stats) {
    const avgPing = stats.rtts.length ? (stats.rtts.reduce((a, b) => a + b, 0) / stats.rtts.length).toFixed(3) : 0;
    const minPing = stats.rtts.length ? Math.min(...stats.rtts).toFixed(3) : 0;
    const maxPing = stats.rtts.length ? Math.max(...stats.rtts).toFixed(3) : 0;
    const avgJitter = stats.jitters.length ? (stats.jitters.reduce((a, b) => a + b, 0) / stats.jitters.length).toFixed(3) : 0;
    const lossPercent = stats.sent > 0 ? (((stats.sent - stats.received) / stats.sent) * 100).toFixed(2) : 0;

    console.log(`
📊 ОТЧЕТ
   Отправлено: ${stats.sent}
   Получено:   ${stats.received}
   Потери:     ${lossPercent}%
   Ping (ср):  ${avgPing} мс
   Ping (мин): ${minPing} мс
   Ping (макс):${maxPing} мс
   Jitter (ср):${avgJitter} мс
    `);

    let packet = {
        date: Math.floor(Date.now() / 1000),
        sent: stats.sent,
        recv: stats.received,
        loss_perc: lossPercent,
        ping_avg: avgPing,
        ping_min: minPing,
        ping_max: maxPing,
        jitter_avg: avgJitter,
    };

    // Отправка в VizIoT
    if (isConnected) {
        viziotMQTTClient.sendDataToVizIoT(packet, (err) => {
            if (err) console.log('Ошибка отправки в VizIoT:', err);
        });
    }
}

// Работа с UDP вынесена в отдельный поток для точности измерений
const worker = fork('./udp_worker.js');

worker.on('message', (msg) => {
    switch (msg.cmd) {
        case 'ready':
            worker.send({ cmd: 'config', host: HOST, port: PORT });
            break;
        case 'configured':
            runTestCycle(); // Первый тест сразу при запуске
            setInterval(runTestCycle, TEST_INTERVAL); // Запускаем цикл тестов
            break;
        case 'report':
            showResults(msg.stats);
            break;
    }
});

Вспомогательный файл для UDP (udp_worker.js)

Чтобы отправка и приём пакетов не влияли на точность таймеров, мы вынесем всю работу с сетью в отдельный процесс.

nano udp_worker.js

Вставьте в него этот код:

// udp_worker.js
const dgram = require('dgram');
const socket = dgram.createSocket('udp4');

let SERVER_HOST = null;
let SERVER_PORT = null;
let isTesting = false;

let stats = {};

function resetStats() {
    stats = {
        sent: 0,
        received: 0,
        rtts: [],
        jitters: [],
        lastRtt: null,
    };
}

socket.on('message', (msg) => {
    if (!isTesting || msg.length < 28) return;

    const recvTime = process.hrtime.bigint();
    try {
        const msgSendTime = msg.readBigUInt64BE(20);
        const rtt = Number(recvTime - msgSendTime) / 1e6; // в миллисекундах

        stats.rtts.push(rtt);
        stats.received++;

        if (stats.lastRtt !== null) {
            stats.jitters.push(Math.abs(rtt - stats.lastRtt));
        }
        stats.lastRtt = rtt;
    } catch (e) {
        console.error('Ошибка парсинга пакета', e);
    }
});

process.on('message', (msg) => {
    switch (msg.cmd) {
        case 'config':
            SERVER_HOST = msg.host;
            SERVER_PORT = msg.port;
            process.send({ cmd: 'configured' });
            break;
        case 'startTest':
            resetStats();
            isTesting = true;
            break;
        case 'sendPacket': {
            const { idBuffer, seq } = msg;
            const buf = Buffer.allocUnsafe(28);
            idBuffer.copy(buf, 0);
            buf.writeUInt32BE(seq, 16);
            buf.writeBigUInt64BE(process.hrtime.bigint(), 20);
            socket.send(buf, SERVER_PORT, SERVER_HOST, (err) => {
                if (!err) stats.sent++;
            });
            break;
        }
        case 'stopTest':
            isTesting = false;
            process.send({ cmd: 'report', stats });
            break;
    }
});

socket.bind(() => {
    process.send({ cmd: 'ready' });
});

Шаг 3: Автозапуск через PM2

Чтобы скрипт работал 24/7, используем менеджер процессов PM2.

  1. Установите PM2 глобально: npm install pm2 -g
  2. Запустите скрипт: pm2 start client.js --name "jitter-monitor"
  3. Настройте автозапуск PM2 при старте системы: pm2 startup (и выполните команду, которую он предложит)
  4. Сохраните список процессов: pm2 save

(Необязательно) Шаг 4: Настройка собственного сервера

Если вы хотите измерять качество связи между двумя своими устройствами (например, до VPS), запустите на втором устройстве этот простой эхо-сервер.

# На сервере
sudo ufw allow 54321/udp
mkdir udp_server && cd udp_server
npm init -y
nano server.js

Код для server.js:

const dgram = require('dgram');
const server = dgram.createSocket('udp4');
const PORT = 54321;

server.on('message', (msg, rinfo) => {
    // Просто отправляем пакет обратно
    server.send(msg, rinfo.port, rinfo.address);
});

server.bind(PORT, () => {
    console.log(`UDP Эхо-сервер запущен на порту ${PORT}`);
});

Запустите его также через PM2: pm2 start server.js --name "udp-echo-server". Не забудьте поменять HOST и PORT в client.js.


Шаг 5: Визуализация и алерты в VizIoT

После первого запуска скрипта данные начнут поступать в ваше устройство VizIoT. Осталось создать красивый виджет.

Создайте виджет типа "График" и добавьте на него параметры типа линия jitter_avg, ping_min, ping_max, ping_avg. А для параметра loss_perc отлично подойдет тип бар.

Самое важное — получать уведомления о проблемах. Создайте правило в разделе "Уведомления": если loss_perc больше (>) 5 , отправить уведомление в Telegram. Так вы узнаете о проблемах со связью раньше, чем вам начнут жаловаться пользователи.

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