Как собрать статистику с веб-сайта и не набить себе шишек

в 6:54, , рубрики: badoo, fetch api, javascript, service worker, xmlhttpequest, аналитика, Блог компании Badoo, Клиентская оптимизация, Программирование, Разработка веб-сайтов, статистика

enter image description here

Привет! Меня зовут Слава Волков, и я фронтенд-разработчик в Badoo. Сегодня я хотел бы немного рассказать про сбор статистики с фронтенда.

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

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

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

Первая проблема, которая может возникнуть, – это ограничения браузеров по количеству одновременных соединений к одному домену. Например, при загрузке страницы мы выполняем четыре Ajax-запроса для получения данных (загрузки шрифтов, SVG-графики), загружаем динамично стили. В итоге у нас получается шесть запросов, которые браузер выполняет одновременно (пример номер 1) (во всех примерах я поставил задержку в две секунды, и их все лучше смотреть на своей машине во избежание сетевых задержек).

function sendAjax(url, data) {
    return new Promise(function(resolve, reject) {
        var req = new XMLHttpRequest();
        req.open('POST', url);

        req.onload = function() {
            if (req.readyState != 4) return;
            if (req.status == 200) {
                resolve(req.response);
            } else {
                reject(Error(req.statusText));
            }
        };

        req.onerror = function() {
            reject(Error("Network Error"));
        };

        req.send(data);
    });
}

function logIt(startDate, requestId, $appendContainer) {
    var endDate = new Date();
    var text = 'Request #' + requestId + '. Execution time: ' + ((endDate - startDate) / 1000) + 's';
    var $li = document.createElement('li');
    $li.textContent = text;

    $appendContainer.appendChild($li);
}

document.querySelector('.js-ajax-requests').addEventListener('click', function(e) {
    e.preventDefault();
    var $appendContainer = e.currentTarget.nextElementSibling;

    for (var i = 1; i <= 8; i++) {
        (function(i) {
            var startDate = new Date();
            sendAjax(REQUEST_URL + '?t=' + Math.random()).then(function() {
                logIt(startDate, i, $appendContainer);
            });
        })(i);
    }
});

Но что будет, если мы начнём отправлять ещё какую-то статистику о зашедшем пользователе? Получим вот такой результат (пример номер 2):

Ограничение на количество одновременных запросов

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

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

Решение проблемы ограничения на количество запросов

Способ №1

Если вы уже используете HTTP/2 или передаёте данные через WebSocket-соединение, то такая проблема вас вообще не должна коснуться. Но если ещё нет, возможно, вам поможет просто переход на HTTP/2 (и вы забудете все как страшный сон). Благо все современные браузеры это поддерживают, и в самых популярных веб-серверах уже появилась поддержка этого протокола. Единственная проблема, с которой можно столкнуться, – необходимость убрать все хаки, которые вы делали для HTTP/1.1, например, доменное шардирование (создающее лишнее TCP-соединение и мешающее приоритизации), конкатенация JS и CSS и встраиваемые изображения dataURI. Кроме того, при переходе на HTTP/2 вам придётся перевести весь сайт на HTTPS, а это может быть затратно, особенно если много данных со сторонних ресурсов у вас загружается по HTTP.

При использовании WebSocket-соединения вы также получаете постоянное соединение с сервером и никаких ограничений на количество запросов. Ничего плохого в этом решении нет, кроме того, что придётся поднять свой сокет-сервер и связать его со своей системой, – дополнительная работа для разработчиков. Но в итоге через сокет можно будет передавать не только статистику, но и обычные запросы. А главное – это позволит получать нотификации с сервера и экономить трафик.

Способ №2

Если вы ещё не готовы переходить на HTTP/2 или использовать WebSocket-соединение, самое простое решение – вынесение запросов со статистикой на отдельный домен, собственно, как и вынесение всей статики. Тогда проблема исчезнет (пример номер 3):

Отправка статистики на отдельный домен

Само собой не стоит забывать о конфигурации CORS, иначе такие запросы будут заблокированы браузером.

Способ №3

Используя возможности Fetch API, мы можем сделать шесть дополнительных запросов, не передавая cookies (пример номер 4). Но это поможет только в том случае, если cookies не будут использоваться для авторизации при запросах. По умолчанию Fetch их не передаёт. Кажется, что это выглядит как баг реализации, но такое поведение наблюдается как в Chrome, так и в Firefox. Баг, фича? Для того чтобы cookies уходили, необходимо установить дополнительный параметр:

fetch(REQUEST_URL + '?t=' + Math.random(), {
    method: 'POST',
    credentials: 'include'
}).then(function () {
    // ...
});

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

Буферизация и отправка данных

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

Кроме использования задержки debounce, есть примеры использования метода window.requestIdleCallback, но, к сожалению, он поддерживается ещё далеко не всеми браузерами. Метод requestIdleCallback ставит в очередь функцию, которая будет выполнена в момент простоя браузера. Данную возможность как раз неплохо использовать для выполнения фоновых задач, например, отправки статистики или подзагрузки каких-либо lazy load-элементов на странице. Моё мнение – она лучше подходит для агрегации синхронных вызовов. Например, посмотрите этот пример.

Кроме того, неплохо было бы определить, когда ваша система готова к использованию, и затем вызвать метод ready(), после которого статистика начнёт отправляться на сервер, не блокируя остальную работу. А до этого она может как раз попадать в буфер.

К сожалению, при использовании буферизации может возникнуть такая ситуация: пользователь закрыл вкладку – и статистика, которую вы собирали, не отправляется и теряется. Этого можно избежать. Первое, что приходит на ум, – это создание метода force() у вашего объекта отправки статистики, который будет выполняться при beforeunload. Но если вы используете для отправки статистики XHR-запросы, то при закрытии вкладки или браузера запрос также не будет выполнен:

window.addEventListener('beforeunload', sendData, false);
function sendData() {
  var client = new XMLHttpRequest();
  client.open("POST", "/server.php", false);
  client.send(data);
}

Исправить это можно отправкой синхронного запроса, как в примере выше (но это заблокирует для пользователя действия с браузером), или же использованием специального метода sendBeacon, который позволяет асинхронно отправлять небольшие объёмы данных на сервер и гарантирует их доставку даже после закрытия страницы. Данный метод работает во всех современных браузерах, кроме Safari и Internet Explorer (в Edge есть поддержка), поэтому для них придётся оставить старый синхронный XHR. Но главное – метод выглядит достаточно компактно и просто:

window.addEventListener('beforeunload', sendData, false);
function sendData() {
  var navigator = window.navigator;
  var url = "/server.php";

  if (!navigator.sendBeacon || !navigator.sendBeacon(url, data)) {
      var t = new XMLHttpRequest();
      t.open('POST', url, false);
      t.setRequestHeader('Content-Type', 'text/plain');
      t.send(data);
  }
}

Для того чтобы убедиться, что ваши запросы уходят, достаточно открыть в Chrome DevTools вкладку Network и отфильтровать по запросам типа Other. Тут будут находиться все ваши sendBeacon-запросы.

К сожалению, у sendBeacon есть недостатки, из-за которых на него нельзя перевести все отправки запросов. Во-первых, метод попадает под ограничение количества соединений на один домен (пример номер 5), так что теоретически может возникнуть ситуация, когда запрос отправки статистики заблокирует какой-то важный запрос за получением данных (но есть исключение: если вы используете вместо XHR-запросов новый Fetch API без передачи cookies, то sendBeacon уже не попадает под ограничение коннектов (пример номер 9)). Во-вторых, sendBeacon может иметь ограничение на размер запроса. Например, раньше для Firefox и Edge максимальный размер запроса составлял 64 Кб, сейчас же для Firefox уже нет ограничения на размер данных (пример 8). Когда я пытался найти максимальный размер данных для Chrome (на текущий момент актуальна 57 версия), то нашёл очень интересный баг, из-за которого использование sendBeacon становится проблематичным и который вызвал у нас падение в отправке статистики. Попробуйте выполнить пример 7, перезагрузите страницу и посмотрите результат примера 8:

Баг с sendBeacon в Google Chrome

В Chrome до тех пор, пока буфер не достигнет 64 Кб, остальные запросы просто нельзя отправить. Сейчас баг уже исправлен, и я надеюсь, что его исправление попадёт в ближайшую версию. После этого ограничение на один запрос будет также составлять 64 Кб данных.

Так что, если через этот метод у вас уходит много статистики от разных компонентов, то, скорее всего, вы столкнётесь с лимитом. Если вы перешагнёте этот лимит, то метод navigator.sendBeacon() вернёт false, и в таком случае лучше воспользоваться обычным XHR-запросом, а navigator.sendBeacon() оставить только для тех случаев, когда пользователь покидает страницу. Также этот метод не гарантирует получение данных сервером, если пропало интернет-соединение, поэтому при отправке данных лучше воспользоваться свойством navigator.onLine, которое возвращает сетевой статус браузера, прежде чем отправлять запросы.

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

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

Service Worker и фоновая синхронизация

Фоновая синхронизация в Service Worker представлена Background Sync API или с ещё одной реализацией как периодическая синхронизация. Возьмём уже рассмотренный пример и попробуем переписать его с использованием возможностей сервис-воркера.

Готовый тестовый пример можно посмотреть по этой ссылке. А тут – исходный код.

Statistic.prototype._sendMessageToServiceWorker = function(message) {
    return new Promise(function(resolve, reject) {
        var messageChannel = new MessageChannel();
        messageChannel.port1.onmessage = function(event) {
            if (event.data.error) {
                reject(event.data.error);
            } else {
                resolve(event.data);
            }
        };

     navigator.serviceWorker.controller.postMessage(message, [messageChannel.port2]);
    });
};

Statistic.prototype._syncData = function() {
    return navigator.serviceWorker.ready.then(function(registration) {
        return registration.sync.register('oneTimeStatisticSync');
    });
};

Service Worker:

self.addEventListener('sync', function(event) {
    console.info('Sync event executed');

    if (event.tag == "oneTimeStatisticSync") {
        event.waitUntil(sendStatistic());
    }
});

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

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

navigator.serviceWorker.register('service-worker.js')
    .then(function() {
        return navigator.serviceWorker.ready;
    })
    .then(function(registration) {
        this.ready();
        return registration;
    }.bind(this))
    .then(function(registration) {
        if (registration.periodicSync) {
            registration.periodicSync.register({
               tag: 'periodicStatisticSync',
               minPeriod: 1000 * 30, // 30sec
               powerState: 'auto',
               networkState: 'online'
            });
        }
    });

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

К недостаткам использования Service Worker можно отнести, наверное, то, что этот способ поддерживается ещё не всеми браузерами. Кроме того, его реализация требует использования только HTTPS-протокола: Service Worker должен быть подключён по HTTPS и все fetch-запросы внутри него тоже должны быть с использованием этого протокола (исключение составляет localhost).

Заключение

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

Желаю всем успехов в сборе данных!

Автор: Badoo

Источник

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


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