[Из песочницы] Телепатия на стероидах в js/node.js
Чтобы сократить время решения проблемы, нужно оперативно узнавать об ошибках, иметь как можно более точную информацию о том, что к ней привело и, желательно, собирать всё вместе.
О своём решении я и расскажу под катом.
После обсуждения было принято решение создать механизм, собирающий с клиента и сервера информацию об ошибках, и позволяющий передавать или обрабатывать данные для последующего реагирования. Механизм должен давать возможность в будущем добавлять способы работы с данными без лишнего переписывания кода и позволять из конфига менять способы работы, порядок и т.п.
Ключевые точки:
- Ловить ошибки как на 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.
Весь код можно посмотреть на гитхабе (ссылка внизу), а сейчас пройдёмся по основным задачам:
- Параллельный запуск для скорости
Для этих целей используем yield […](или Promise.all (…)) с учётом того, что каждая функция из массива не должна выбрасывать ошибку иначе, если функций с ошибками несколько, мы не сможем обработать их все - Гибкая конфигурация
Все драйвера находятся в «пакете драйверов», которые располагаются в массиве по приоритету. Ошибка рассылается сразу на весь пакет драйверов, если весь пакет не работает, система переходит к следующему и т.д. - Динамический запуск
При инициализации помечаем все драйвера как «not started».
При запуске первый пакет драйверов помечаем либо как «started», либо как «bad».
При отправке, в текущем пакете пропускаем «bad», отправляем в «started» и запускаем «not started». Драйвера, выкинувшие ошибку, помечаем как bad и идём дальше. Если все драйвера в текущем пакете помечены как bad переходим к следующему пакету. - Отправка ошибок драйверов в ещё живых драйверах
При возникновении ошибок в самих драйверах ошибок (немного тавтологии), записываем их в специальный массив. После нахождения первого живого драйвера, отправляем через него ошибки драйверов и саму ошибку (если драйвера падали при отправке ошибки) и ошибки драйверов. - Ловим ошибки с 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: я к либе из данного поста отношения не имею, я про свои решения)