Используем JS Self-Profiling API для профилирования фронтенда на клиентах

—

от автора

Всем привет! Меня зовут Илья Алонов. Я фронтенд-инженер и уже второй год занимаюсь производительностью фронта в Авито в платформенной команде Perfomance. 

Хочу рассказать про профилирование фронтденда в продакшене с использованием JS Self-Profiling API. Этот инструмент я откопал, когда пытался ускорить исполнение клиентского JS в Авито. Если интересуетесь перформансом веб-приложений — эта статья для вас.

С этим материалом я выступал на митапе MoscowJS. Запись моего выступления с докладом «Профилирование фронтенда в проде» можно посмотреть на YouTube-канале AvitoTech.

Что такое профилирование

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

Профилирование бывает двух видов: инструментальное и сэмплирующее.

Инструментальный профайлер программно дополняет таймингами каждый вызов на стеке. Эти тайминги вычисляют время исполнения того или иного кода. 
Сэмплирующий профайлер раз в N времени берёт снэпшот (сэмпл) стека вызовов и вычисляет из них время исполнения кода.

Под профилированием фронтенда в основном подразумевают использование инструментов браузера, таких как профайлеры в Chrome или Firefox. 

Зачем нужно профилирование

Есть метрика Total Blocking Time (TBT). Она отражает, как долго исполнение JS блокирует главный поток браузера — то есть время, в течение которого браузер не отвечает на взаимодействия пользователя с сайтом. У нас оно выглядело вот так:

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

Подробнее о Total Blocking Time →

Спустя множество ручных оптимизаций и предшествовавших им рисерчей я решил, что с этим надо что-то делать. Моих рук и головы (да и в целом всей нашей команды) просто не хватит, чтобы постоянно следить за метрикой на всех страницах, реагировать на просадки и пилить оптимизации. Хочется иметь инструмент, который поможет командам самим следить за перформансом своего кода в рантайме.

С такими мыслями я начал искать способ декомпозировать TBT и набрёл на JS Self-Profiling API.

Что такое JS Self-Profiling API

JS Self-Profiling API — это браузерный API, который позволяет запускать сэмплирующий профайлер. Не стоит путать с профайлерами, доступными в devtools — это разные вещи.

Профайлеры сэмплирующего класса собирают сэмплы стека вызовов, из которых строят трейс — то, что профайлер выплёвывает как результат:

Трейс исполнения кода с 3 задачами. Профайлер снимает снэпшоты раз в 5 единиц времени. А вызывает B, B вызывает C

Трейс исполнения кода с 3 задачами. Профайлер снимает снэпшоты раз в 5 единиц времени. А вызывает B, B вызывает C

JS Self-Profiling API позволяет запускать и останавливать профилирование в любой момент жизненного цикла страницы.

Как использовать JS Self-Profiling API

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

Важно: на момент выхода статьи JS Self-Profiling API доступен в Chrome 94 и выше. Проверьте, совместим ли инструмент с вашим браузером.

1. Инициализируем профайлер:

if (windows.Profiler) { const profilier = new Profiler({ sampleRate: 10, maxBufferSize: Number.MAX_SAFE_INTEGER });  // … когда придет время …  const trace = await profiler.stop(); doStuff(trace); }

Для инициализации нужно 2 параметра:  

  • sampleRate — частота сэмплирования в миллисекундах. Любой number. На деле для *nix-систем минимальное значение — 10 мс, для Windows — 16 мс. Если поставить значения меньше минимальных, браузер под капотом всё равно выставит нужные. 

  • maxBufferSize — предельное количество сэмплов, которое мы хотим снять. 

2. После профилирования получаем объект Trace с 4 полями: 

  • resources — список url-ов исполненных JS-файлов;

  • frames — список фреймов с информацией об исполняемом коде: имена функций и их позиции в загруженном файле;

  • stacks — список стеков вызовов. Элементы списка состоят из двух полей: frameId — ссылка на фрейм из списка frames, parentId — ссылка на вызывающий стек. Фактически в этом списке хранятся деревья вызовов;

  • samples — список всех собранных сэмплов: состоит из timestamp и ссылки на стек. 

Вот как это выглядит:

{   "resources": [     "https://domain.com/static/abc.js", "https://domain.com/static/123.js"   ],   "frames": [     {       "name": "a", "resourceId": 6,       "line": 23, "column": 169 }, {       "name": "b", "resourceId": 1,       "line": 313, "column": 1325     },   ],   "stacks": [ { "frameId": 0 }, { "frameId": 1, "parentId": 0 }, { "frameId": 2, "parentId": 1 }   ],   "samples": [     { "timestamp": 1551.73499998637, "stackId": 2 }, { "timestamp": 1576.83999999426, "stackId": 1 },   ] }

Трейс загрузки главной странцы Авито весит ~90Kb. Эта информация понадобится нам чуть позже.

Что там с оверхедом

Главное стоп-слово для разработчиков, желающих включить профилирование (необязательно в браузере) — оверхед. Давайте смотреть, что с ним у нашей технологии.

Во-первых, есть исследование от ребят из Facebook* об оверхеде от JS Self-Profiling API. Они пишут, что в их случае оверхед составляет 1% в скорости загрузки. 
Во-вторых, я сам провёл A/B-тест в Авито и тоже обнаружил низкий оверхед. На мобильной версии сайта он в среднем составил 1–2% от скорости загрузки и Total Blocking Time при частоте сэмплирования 10 мс. 

Итого оверхед у JS Self-Profiling API незначительный, следовательно, можно развлекаться дальше.

Слева — оверхед загрузки, справа — оверхед Total Blocking Time

Слева — оверхед загрузки, справа — оверхед Total Blocking Time

Дизайн инструмента

С оверхедом разобрались, можно приступать к дизайну. Что можно сделать с полученным трейсом? Первое, что приходит в голову — слить его на сервер и там как-то обрабатывать. План выглядит надёжно, давайте посчитаем математику:

  • предположим, что у нас 10K RPM,

  • трейс загрузки в проде весит 90 Кб.

Несложными вычислениями получаем 1 Тб в день. И это при условии, что все данные нормализованные:

90Kb / 1024^2 * 10000 * 60 * 24 = 1 Tb/day

Это, конечно, можно процессить на сервере и даже хранить, но для этого нужна инфраструктура. А зачем делать инфраструктуру, если можно её не делать?

Упрощаем себе задачу

Я решил уменьшить масштаб задачи и обрабатывать данные только о лонгтасках, из которых складывается Total Blocking Time.

Чтобы это получилось, нужно вытащить лонгтаски с помощью PerformanceObserver и смержить их с трейсом профилирования:

const trace = await profiler.stop();  const longtasks = getLongtasks(); // via PerformanceObserver  function merge(trace, longtasks) { … }  const richLongtasks = merge(trace, longtasks);

Этот алгоритм хорошо описал Nic Jansma в своей статье.

В результате получаются обогащённые лонгтаски — со временем начала, конца и списком причин. Вот как это выглядит:

{   start: 1,   end: 11,   reasons: [     {       duration: 6,       frames: [         {           url: 'https://domain.com/static/abc.js',           line: 23, column: 169         },         {           url: 'https://domain.com/static/123.js',           line: 313, column: 1325         },       ]     },     …   ] } 

Превращаем обфусцированный код в исходники

Мы получили обогащённые таски со списком причин. Проблема в том, что файлы, на которые ссылаются фреймы, — это обфусцированный исходный код. 

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

Давайте подумаем о том, как обычно выглядит фронтендерский проект. Он состоит из:

  • NPM-пакетов: проприетарных или open source;

  • модулей: компонентов, утилит;

  • стороннего кода: рекламы, трекеров, счётчиков и прочего подобного.

Зафиксируем эту мысль в виде конфига:

// profiling.config.json { "internal": [ "@avito/packageA", "@avito/packageB" "src/components/x", … ], "external": [ "yastatic", "me.yandex.ru", "googletagmanager", … ] }

Так мы заранее знаем, профилирование каких модулей нам интересно. Чтобы всё это заработало, на этапе сборки мы вытащим из Source Map информацию о том, куда были собраны нужные нам модули. Тогда для каждого чанка получится сгенерировать некий json-файл, который мы назовём profiling map. Каждая такая мапа будет содержать информацию об интересующих нас модулях, попавших в соответствующий чанк.

Генерация Profiling Map

Генерация Profiling Map

Profiling map — это JSON с двумя полями: 

  • modules — список профилируемых модулей;

  • intervals — отсортированный список интервалов. Каждый интервал состоит из 3 значений: начало, конец, индекс модуля из списка modules.

Вот как это выглядит:

// some-chunk.hash.profiling-map.json { "modules": [ "@avito/packageA", "@avito/packageB" "src/components/x" ] "intervals": [ [154, 201, 1], // [start, end, moduleIdx] [45886, 46813, 0], [55574, 57004, 0] [55574, 57004, 2], ], } 

Весит Profiling Map немного, в том числе за счёт нормализации данных. Для главной мобильной страницы Авито — всего 500 байт. 

Собираем воедино

В прошлых двух разделах мы научились получать обогащённые лонгтаски и переходить от собранного кода к исходникам. Осталось всё это соединить, чтобы технология работала. Делать это будем в два этапа.

В начале загрузки страницы:

  1. Запустить PerfomanceObserver. 

  2. Запустить Profiler.

Запускаем PerfomanceObserver и Profiler на клиенте

Запускаем PerfomanceObserver и Profiler на клиенте

В конце загрузки страницы: 

  1. Остановить PerfomanceObserver и получить лонгтаски.

  2. Остановить Profiler и получить трейс. 

  3. Слить лонгтаски и трейс, получить обогащённые лонгтаски.

  4. Параллельно с мержем лонгтасок и трейса запросить profiling map оттуда, где они лежат. Я положил рядом со статикой.

  5. Достать из обогащённых лонгтасок то, что указано в profiling map’ах.

Слева — получаем обогащенные лонгтаски, справа — получаем Profiling Map

Слева — получаем обогащенные лонгтаски, справа — получаем Profiling Map
Сливаем обогащенные лонгтаски и Profiling Map

Сливаем обогащенные лонгтаски и Profiling Map

Ограничения JS Self-Profiling API и как их обойти

  1. Так как профайлер сэмплирующий, разовое профилирование может не зацепить исполнение какого-то кода, и некоторые таски мы не увидим. Решить это можно банально: профилировать чаще. Чем чаще профилируем, тем больше данных имеем, тем лучше наши метрики.

При сэмплирующем профилировании можно пропустить какие-то таски

При сэмплирующем профилировании можно пропустить какие-то таски
  1. CORS. Для всех ресурсов с доменов, отличных от текущего, должны быть настроены cross-origin политики. В противном случае JS Self-Profiling втихую удалит информацию о них из профилирования. 

  2. Инструмент воспринимает только исполнение JavaScript. Информацию о сборке мусора, парсинге HTML, вычислениях стилей и лейаута он не даст. Сейчас этого нет, но на GitHub есть предложение о добавлении такой информации в JS Self-Profiling API в виде маркеров на стеке. 

Вот как это будет выглядеть:

   "stacks" : [            {"marker" : "idle"}, // id: 0         {"marker" : "script"}, // id: 1         {"marker" : "parse"}, // id: 2         {"marker" : "gc"}, // id: 3         {"marker" : "paint"}, // id: 4         {"marker" : "other"}, // id: 5         {"frameId" : 1, "marker": "script"}, // id: 6         {"frameId" : 2, "parentId" : 6, "marker": "script"}, // id: 7         {"frameId" : 2, "parentId" : 6, "marker": "gc"}, // id: 8         {"frameId" : 3, "parentId" : 8, "marker": "script"} // id: 9     ]

Что в итоге

Мы создали инструмент, который: 

  • умеет собирать метрики интересующих нас модулей,

  • почти не влияет на производительность,

  • прост в использовании.

Вот как выглядит график метрик пакета avito/core:

Данные для графика собраны моим инструментом, который я создал с помощью JS Self-Profiling API

Сейчас я развиваю инструмент дальше. Уже появилась поддержка микрофронтов и сильно улучшился DX — теперь включать профилирование пакетов можно из внутреннего дашборда, не запариваясь изменениями конфига.

Также у нас есть планы:

  • автоматизировать мониторинг метрик,

  • использовать технологию в NFR.

Полезные материалы 

  1. Запись моего выступления «Профилирование фронтенда в проде» на YouTube

  2. Статья JS Self-Profiling API In Pratice, на которую я опирался

  3. Драфт спецификации JS Self Profiling API

  4. Репозиторий JS Self Profiling API на GitHub

* Компания Meta, которой принадлежит Facebook, признана экстремистской и запрещена в России

Предыдущая статья: «Всё происходит само собой, когда тебе не всё равно»: как из игрока в покер стать руководителем тимлидов


ссылка на оригинал статьи https://habr.com/ru/articles/759072/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *