Телепатия на стероидах в js-node.js

в 6:02, , рубрики: javascript, node.js, сопровождение проектов

image
Этап поддержки продуктов отнимает много сил и нервов. Путь от «я нажимаю а оно не работает» до решения проблемы, даже у первоклассного телепата, может занимать много времени. Времени, в течение которого клиент/начальник будет зол и недоволен.

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

О своём решении я и расскажу под катом.

1. Задачи

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

Ключевые точки:

  • Ловить ошибки как на frontend так и на backend
  • Возможность добавить несколько обработчиков ошибок в т.ч. в будущем
  • Большой объем отладочной информации
  • Гибкая настройка для каждого проекта
  • Высокая надёжность

2. Решение

Было решено при запуске сервера производить загрузку специальных обработчиков ошибок — драйверов, порядок и приоритет которых будет загружен из конфига. Ошибки на frontend будут посылаться на сервер, где будут обрабатываться вместе с остальными.

Основная идея в том, что при возникновении ошибки, и по мере отмотки стека до глобальной области, в класс ошибки будет добавляться отладочная информация при помощи расставленных сборщиков. При выпадении в глобальную область, ошибка будет перехватываться и обрабатываться при помощи драйвера ошибки.

2.1 Класс ошибки

Был написан свой класс ошибки, наследуемый от стандартного. С конструктором, принимающим ошибку, возможностью указать «уровень тревоги» и добавлением отладочных данных. Класс расположен в едином для front- и backend файле инструментов.

Здесь и далее, в коде использованы библиотеки co, socket.io и sugar.js

Полный код класса

app.Error = function Error(error,lastFn){
    if(error && error.name && error.message && error.stack){в случае, если в конструктор передана другая ошибка
        this.name=error.name;
        this.message=error.message;
        this.stack=error.stack;
        this.clueData=error.clueData||[];
        this._alarmLvl=error._alarmLvl||'trivial';
        this._side=error._side || (module ? "backend" : "frontend");//определение стороны
        return;
    }
    if(!app.isString(error)) error='unknown error';
    this.name='Error';
    this.message=error;
    this._alarmLvl='trivial';
    this._side=module ? "backend" : "frontend";
    this.clueData=[];

    if (Error.captureStackTrace) {
        Error.captureStackTrace(this, app.isFunction(lastFn)? lastFn : this.constructor);
    } else {
        this.stack = (new Error()).stack.split('n').removeAt(1).join();//удаление из стека вызова конструктора класса ошибки
    }

};
app.Error.prototype = Object.create(Error.prototype);
app.Error.prototype.constructor = app.Error;
app.Error.prototype.setFatal = function () {//getter/setters для уровня тревоги
    this._alarmLvl='fatal';
    return this;
};
app.Error.prototype.setTrivial = function () {
    this._alarmLvl='trivial';
    return this;
};
app.Error.prototype.setWarning = function () {
    this._alarmLvl='warning';
    return this;
};
app.Error.prototype.getAlarmLevel = function () {
    return this._alarmLvl;
};
app.Error.prototype.addClueData = function(name,data){//добавление отладочной информации
    var dataObj={};
    dataObj[name]=data;
    this.clueData.push(dataObj);
    return this;
};

И сразу пример использования для promise:

socket.on(fullName, function (values) {
    <...>
    method(values)//Выполняем функцию api
        .then(<...>)
        .catch(function (error) {//Ловим ошибку
               throw new app.Error(error)//Оборачиваем в наш класс и пробрасываем дальше по стеку
                   .setFatal()//Указываем "уровень тревоги"
                   .addClueData('api', {//Добавляем отладочные данные
                       fullName,
                       values,
                       handshake: socket.handshake
                   })
           });
});

Для try-catch поступаем аналогичным образом.

2.2 Frontend

Для frontend загвоздка в том, что ошибка может произойти ещё до того, как загрузится библиотека транспорта (socket.io в данном случае).

Обходим эту проблему, собирая ошибки во временную переменную. Для перехвата ошибок из глобальной области используем window.onerror:

app.errorForSending=[];
app.sendError = function (error) {//Функция отправки ошибки на сервер
    app.io.emit('server error send', new app.Error(error));
};

window.onerror = function (message, source, lineno, colno, error) {//Перехватываем ошибку из глобальной области
    app.errorForSending.push(//Записываем в массив для ошибок. 
        new app.Error(error)
            .setFatal());//Сразу присваиваем высокий уровень тревоги, ведь ошибка произошла во время загрузки
};
app.events.on('socket.io ready', ()=> {//После готовности транспортной библиотеки
    window.onerror = function (message, source, lineno, colno, error) {//Перезаписываем коллбек
        app.sendError(new app.Error(error).setFatal());
    };

    app.errorForSending.forEach((error)=> {//Отправляем все ошибки, собранные ранее
        app.sendError(error);
    });
    delete app.errorForSending;
});
app.events.on('client ready', ()=> {//после загрузки записываем окончательную версию обработчика
    window.onerror = function (message, source, lineno, colno, error) {
        app.sendError(error);
    };
});

Остаётся проблема в том, что некоторые библиотеки любят не выбрасывать ошибки, а просто гади выводить в консоль. Перезапишем функции консоли для перехвата данных.

function wrapConsole(name, action) {
    console['$' + name] = console[name];//сохраняем исходный метод
    console[name] = function () {
        console['$' + name](...arguments);//вызываем исходный метод
        app.sendError(
            new app.Error(`From console.${name}: ` + [].join.call(arguments, '' ),//запишем в сообщение ошибки консольный вывод
                              console[name])//Сократим стек до вызова этой функции(будет работать только в движке v8)
                .addClueData('console', {//добавим данные о имени консоли и исходных аргументах
                    consoleMethod: name,
                    arg          : Array.create(arguments)
                })[action]());//вызовем соответствующий уровню сеттер
    };
}
wrapConsole('error', 'setTrivial');
wrapConsole('warn', 'setWarning');
wrapConsole('info', 'setWarning');

2.3 Server

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

  • Всё должно работать как можно быстрее, даже если каждому драйверу в процессе инициализации/обработки ошибки, нужно «поговорить по душам» с другим сервером или вычислить ответ на главный вопрос вселенной жизни и всего такого;
  • Гибкая система запасных и дублирующих драйверов;
  • Динамически запускать запасные драйвера, в случае отказа предыдущих;
  • Исключения, возникшие во время работы драйверов, отправлять по работающим драйверам;
  • Ловить и обрабатывать ошибки с frontend, а также выпадающие в глобальную область node.js.

Весь код можно посмотреть на гитхабе (ссылка внизу), а сейчас пройдёмся по основным задачам:

  1. Параллельный запуск для скорости
    Для этих целей используем yield [...](или Promise.all(...)) с учётом того, что каждая функция из массива не должна выбрасывать ошибку иначе, если функций с ошибками несколько, мы не сможем обработать их все
  2. Гибкая конфигурация
    Все драйвера находятся в «пакете драйверов», которые располагаются в массиве по приоритету. Ошибка рассылается сразу на весь пакет драйверов, если весь пакет не работает, система переходит к следующему и т.д.
  3. Динамический запуск
    При инициализации помечаем все драйвера как «not started».
    При запуске первый пакет драйверов помечаем либо как «started», либо как «bad».
    При отправке, в текущем пакете пропускаем «bad», отправляем в «started» и запускаем «not started». Драйвера, выкинувшие ошибку, помечаем как bad и идём дальше. Если все драйвера в текущем пакете помечены как bad переходим к следующему пакету.
  4. Отправка ошибок драйверов в ещё живых драйверах
    При возникновении ошибок в самих драйверах ошибок(немного тавтологии), записываем их в специальный массив. После нахождения первого живого драйвера, отправляем через него ошибки драйверов и саму ошибку(если драйвера падали при отправке ошибки) и ошибки драйверов.
  5. Ловим ошибки с front/backend
    Создаем специальный api для frontend и ловим исключения node.js через process.on('uncaughtException',fn) и process.on('unhandledRejection',fn)

3. Заключение

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

Если задуматься о развитии, то в будущем можно добавить несколько полезных фич:

  • Изменение политики отключения неработающих драйверов
    Например, добавить возможность повторной проверки драйвера на работоспособность через некоторое время.
  • Возможность вставки кода драйверов на frontend
    Можно использовать для сбора дополнительной информации.
  • Пресет логгирования
    DRY для повторяющихся функций сбора общей информации(последние загруженные страницы, последние использованные api)

Рабочий пример можно посмотреть на гитхабе. За архитектуру прошу не ругать, пример делался методом удалить-из-проекта-всё-ненужное.

Буду рад комментариям.

Автор: Kot_DaVinchi

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js