[Из песочницы] Телепатия на стероидах в js/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)

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

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

Комментарии (4)

  • 10 августа 2016 в 10:30

    0

    Тоже используем подобную самописную штуку, хотя у нас она даже больше похоже на отдельно стоящую библиотеку, чем у вас. Вашу внедрять не захочется, потому что слишком много махинаций нужно делать.
    И, кстати, есть сервисы готовые по сбору ошибок и логов, в основном платные, конечно.
    • 10 августа 2016 в 10:50

      0

      В нашем случае это часть ядра, что даёт больше возможностей для сбора отладочной информации.
  • 10 августа 2016 в 11:25

    0

    А что происходит с отловленными ошибками дальше? Загорается какая-то лампочка в админке, и какие-то дежурные разбираются в ситуации?
    И что будет, если в релизе выкатится фатальная ошибка какой-нибудь жутко популярной ручки, не заддосит ли ваш механизм сбора ошибок?
    • 10 августа 2016 в 11:28 (комментарий был изменён)

      0

      У нас построена таким образом, что все ошибки хранятся на отдельном сервере, который не жалко положить. А сама система сбора ошибок учитывает что сервер может лежать и просто не пишет в него (потому что если начался ддос ошибками, то значит саму ошибку уже записали). PS: я к либе из данного поста отношения не имею, я про свои решения)

© Habrahabr.ru