Этап поддержки продуктов отнимает много сил и нервов. Путь от «я нажимаю а оно не работает» до решения проблемы, даже у первоклассного телепата, может занимать много времени. Времени, в течение которого клиент/начальник будет зол и недоволен.Чтобы сократить время решения проблемы, нужно оперативно узнавать об ошибках, иметь как можно более точную информацию о том, что к ней привело и, желательно, собирать всё вместе.
О своём решении я и расскажу под катом.
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.
Весь код можно посмотреть на гитхабе (ссылка внизу), а сейчас пройдёмся по основным задачам:
- Параллельный запуск для скорости
Для этих целей используем 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)
Рабочий пример можно посмотреть на гитхабе. За архитектуру прошу не ругать, пример делался методом удалить-из-проекта-всё-ненужное.
Буду рад комментариям.