[Перевод] Ошибка на сайте… Что делать?

Когда код попадает в продакшн, программист выпускает во внешний мир, вместе с полезным функционалом, ещё и ошибки. Вполне возможно, что они, например, на некоем сайте, будут иногда приводить к мелким сбоям, которые спишут на самые разные причины, так и не докопавшись до сути. Знающему своё дело разработчику хорошо бы предусмотреть какой-то механизм, благодаря которому он сможет встретиться со своими ошибками, выслушать их рассказ о тех приключениях, которые им пришлось пережить, и, в результате, их исправить.

aydpmtdaqx8ueuo3kitujhva7mm.gif

Сегодня мы хотим поделиться с вами переводом статьи программиста Дэвида Гилбертсона, в которой он рассказывает о разработанной им экспериментальной системе, позволяющей отслеживать и воспроизводить ошибки в веб-проектах, написанных на React. Полагаем, подобный подход можно перенести и в другие среды, но обо всём по порядку.

Подходы к сбору сведений об ошибках


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

window.onerror = err => fetch(`/errors/${err}`);


Для того, чтобы посмотреть на отчёты по ошибкам, достаточно попросить дружественного айтишника дать вам файл со всеми записями о страницах 404, начинающимися с /errors, и вот оно — счастье.

Однако, тот «код», который вы при таком подходе получите, не поможет вам узнать, о том, где именно произошла ошибка. Вероятно, тут потребуется кое-что усовершенствовать и формировать сообщения об ошибках, в которых содержатся сведения о файле и о номере строки:

window.addEventListener('error', e => {
  fetch('/errors', {
    method: 'POST',
    body: `${e.message} (in ${e.filename} ${e.lineno}:${e.colno})`,
  });
});


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

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

9923aafe9ecb6a795c12cc2cfef788f2.png
Отчёт о деятельности пользователя

Самое интересное здесь то, что пользователь перешёл к странице с подробными сведениями о товаре (шаг 4) и щёлкнул по кнопке покупки (на пятом, последнем шаге).

Я могу сразу предположить, что тут, вероятно, что-то подозрительное творится с данными для конкретного товара, поэтому я могу перейти по той же самой ссылке и нажать на кнопку покупки, на которой написано «Buy this for $».

Сделав это, я, конечно, увижу ту же самую ошибку. Этот конкретный товар не имеет цены, поэтому вызов toLocaleString и приводит к сбою. Перед нами — типичный недосмотр не слишком опытного разработчика.

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

Мне бы в такой ситуации хотелось иметь возможность воспроизвести все действия пользователя до момента возникновения ошибки. В идеале — просто щёлкая в ходе воспроизведения по некоей кнопке, на которой написано «Следующий шаг».

Вот как, если я не лишился воображения, я себе всё это представляю:

01c9903d234b71b5867f7d8a72f3f5c9.gif
Воспроизведение действий пользователя путём наблюдения за DOM

Сведения об ошибке, выводимые на экран, и файл, открывающийся в моём редакторе — это заслуга Create React App.

Хочу отметить, что я действительно построил, в виде эксперимента, систему, которая позволяет провести нечто вроде «немодерируемого тестирование юзабилити». Я написал код, который отслеживает действия пользователя, а потом воспроизводит их, и затем спросил одного знающего человека, Джона, о том, что он обо всём этом думает. Он сказал, что это — дурацкая идея, но добавил, что мой код может быть полезен для воспроизведения ошибок.

Собственно, об этом я и хочу тут рассказать. Спасибо, Джон.

Ядро системы


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

У меня есть модуль, record.js, который содержит несколько функций для перехвата различных действий пользователя. Всё это попадает в объект journey, который можно передать на сервер при возникновении ошибки.

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

const journey = {
  meta: {},
  steps: [],
};

export const startRecording = () => {
  journey.meta.startTime = Date.now();
  journey.meta.startUrl = document.location.href;
  journey.meta.screenWidth = window.innerWidth;
  journey.meta.screenHeight = window.innerHeight;
  journey.meta.userAgent = navigator.userAgent;
};


При возникновении ошибки объект journey, например, можно отправить на сервер, для анализа. Для этого подключается соответствующий обработчик события:

window.addEventListener('error', sendErrorReport);


При этом функция sendErrorReport объявлена в том же модуле, что и объект journey:

export const sendErrorReport = (err) => {
  journey.meta.endTime = Date.now();
  journey.meta.error = `${err.message} (in ${err.filename} ${err.lineno}:${err.colno})`;
  
  fetch('/error', {
    method: 'POST',
    body: JSON.stringify(journey)
  })
  .catch(console.error);
};


Кстати, если кто-то может объяснить, почему команда JSON.stringify(err) не даёт мне тело ошибки — это будет очень здорово.

Пока всё это особой пользы не приносит. Однако, сейчас у нас есть каркас, на котором можно построить всё остальное.

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

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

Хочу признаться — я человек ленивый и эгоистичный, поэтому то, о чём буду рассказывать, будет нацелено на технологии, с которыми работаю я, а именно — на проекты, построенные на React и Redux.

Вот что именно я хочу перехватывать:

  • Все диспетчеризованные действия (в результате можно будет включить «воспроизведение» изменений хранилища состояния).
  • Изменения URL (а это значит — можно будет обновлять и URL).
  • Щелчки по странице (это даст возможность своими глазами видеть, по каким именно кнопкам и ссылкам щёлкает пользователь).
  • Скроллинг (это позволит узнать, что именно пользователь видел на странице в момент ошибки).


Перехват действий Redux


Вот код, который используется для перехвата и сохранения в объекте journey действий Redux:

export const captureActionMiddleware = () => next => action => {
  journey.steps.push({
    time: Date.now(),
    type: INTERACTION_TYPES.REDUX_ACTION,
    data: action,
  });
return next(action);
};


В начале вы можете видеть конструкцию = () => next => action => {, которую просто невозможно не понять с первого взгляда. Если вы её, всё же, не поняли, почитайте это. Я, правда, вместо того, чтобы в это вникать, лучше потрачу время на что-нибудь поважнее, например, потренируюсь изображать счастливую улыбку, которая мне пригодится, когда меня будут поздравлять с днём рождения.

Самое важное, что нужно понимать в этом коде, заключается в той роли, которую он играет в проекте. А именно, он занят тем, что помещает «действия» Redux, по мере их выполнения, в объект journey.

Затем я применил вышеописанную функцию при создании хранилища Redux, передав ссылку на неё функции этого фреймворка applyMiddleware():

const store = createStore(
  reducers,
  applyMiddleware(captureActionMiddleware),
);

ReactDOM.render(
  
    
  ,
  document.getElementById('root')
);


Запись изменений URL


Место, где выполняется перехват изменений URL зависит от того, как в приложении выполняется маршрутизация.

Роутер React не особенно хорошо помогает в деле определения изменений URL, поэтому придётся прибегнуть к такому подходу или, может быть, к такому. Хотелось бы мне, с помощью роутера React, просто задать обработчик для onRouteChange. Тут стоит отметить и то, что подобное нужно не только мне. Например, многие сталкиваются с необходимостью отправки сведений о просмотрах виртуальных страниц в Google Analytics.

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

Для перехвата изменений URL я подготовил следующую функцию, которая вызывается каждый раз, когда меняется URL:

export const captureCurrentUrl = () => {
  journey.steps.push({
    time: Date.now(),
    type: INTERACTION_TYPES.URL_CHANGE,
    data: document.location.href,
  });
};


Я вызываю её в двух местах. Там же, где выполняю команду history.push() для обновления URL, и ещё в событии popstate, которое вызывается если пользователь нажимает кнопку Назад в браузере:

window.addEventListener('popstate', () => {
  // ещё какие-то действия, необходимые для обработки события
  
  captureCurrentUrl();
});


Запись действий пользователя


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

В любом случае, задача это была интересная, поэтому тут я расскажу о её решении. При разработке на React я всегда пользуюсь компонентами и