Как сделать производительность видимой с помощью GitLab CI и артефактов GitLab

Опубликовано: 2022-03-10
Краткий обзор ↬ Недостаточно оптимизировать приложение. Вам необходимо предотвратить снижение производительности, и первый шаг к этому — сделать видимыми изменения производительности. В этой статье Антон Немцев показывает пару способов показать их в мерж-реквестах GitLab.

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

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

Примечание . Если у вас есть базовое представление о Node.js, смутное представление о том, как работает ваша CI/CD, и вам небезразлична производительность приложения или бизнес-преимуществ, которые оно может принести, тогда мы готовы к работе.

Как создать бюджет производительности для проекта

Первые вопросы, которые мы должны себе задать:

«Что такое перформанс-проект?»

«Какие показатели мне следует использовать?»

«Какие значения этих показателей приемлемы?»

Выбор метрик выходит за рамки этой статьи и сильно зависит от контекста проекта, но я рекомендую вам начать с чтения «Метрики производительности, ориентированные на пользователя» Филипа Уолтона.

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

Для сайта я бы рассматривал время до первого байта (TTFB) как метрику. Эта метрика показывает, сколько времени требуется серверу, чтобы что-то ответить. Эта метрика важна, но довольно расплывчата, потому что может включать в себя что угодно — начиная от времени рендеринга сервера и заканчивая проблемами с задержкой. Так что приятно использовать его в сочетании с Server Timing или OpenTracing, чтобы узнать, из чего именно он состоит.

Вы также должны учитывать такие показатели, как время до взаимодействия (TTI) и первая значимая отрисовка (последняя скоро будет заменена самой большой отрисовкой по содержанию (LCP)). Я думаю, что оба они наиболее важны — с точки зрения воспринимаемой производительности.

Но имейте в виду: показатели всегда зависят от контекста , поэтому, пожалуйста, не принимайте это как должное. Подумайте, что важно в вашем конкретном случае.

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

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

Используйте конкурентов в своих интересах

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

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

  1. Измеряйте значения выбранных вами метрик на каждом типе страниц для проектов ваших конкурентов;
  2. Измеряйте те же показатели в своем проекте;
  3. Найдите ближайшее лучшее, чем ваше значение, значение каждой метрики в проектах конкурентов. Добавляя к ним 20% и устанавливая в качестве своих следующих целей.

Почему 20%? Это магическое число, которое предположительно означает, что разница будет заметна невооруженным глазом. Подробнее об этом числе можно прочитать в статье Дениса Мишунова «Почему важна воспринимаемая производительность, часть 1: восприятие времени».

Борьба с тенью

У вас есть уникальный проект? У вас нет конкурентов? Или вы уже лучше любого из них во всех возможных смыслах? Это не проблема. Вы всегда можете посоревноваться с единственным достойным соперником, то есть с самим собой. Измерьте каждую метрику производительности вашего проекта на каждом типе страниц, а затем улучшите их на те же 20%.

Еще после прыжка! Продолжить чтение ниже ↓

Синтетические тесты

Существует два способа измерения производительности:

  • Синтетический (в контролируемой среде)
  • RUM (реальные пользовательские измерения)
    Данные собираются от реальных пользователей в производстве.

В этой статье мы будем использовать синтетические тесты и предположим, что наш проект использует GitLab со встроенным CI для развертывания проекта.

Библиотека и ее размер как показатель

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

Пакет для измерения размера библиотеки

Чтобы размер библиотеки был как можно меньше, нам нужно внимательно следить за тем, как она меняется во время разработки. Но как это сделать? Ну, мы могли бы использовать пакет Size Limit, созданный Андреем Ситником из Evil Martians.

Давайте установим его.

 npm i -D size-limit @size-limit/preset-small-lib

Затем добавьте его в package.json .

 "scripts": { + "size": "size-limit", "test": "jest && eslint ." }, + "size-limit": [ + { + "path": "index.js" + } + ],

Блок "size-limit":[{},{},…] содержит список размеров файлов, которые мы хотим проверить. В нашем случае это всего один файл: index.js .

size сценария NPM просто запускает пакет size-limit , который считывает size-limit блока конфигурации, упомянутое ранее, и проверяет размер перечисленных там файлов. Давайте запустим его и посмотрим, что произойдет:

 npm run size 
Результат выполнения команды показывает размер index.js
Результат выполнения команды показывает размер index.js. (Большой превью)

Мы можем видеть размер файла, но на самом деле этот размер не контролируется. Давайте исправим это, добавив limit в package.json :

 "size-limit": [ { + "limit": "2 KB", "path": "index.js" } ],

Теперь, если мы запустим скрипт, он будет проверен на соответствие установленному нами лимиту.

скриншот терминала; размер файла меньше ограничения и отображается зеленым цветом
скриншот терминала; размер файла меньше ограничения и отображается зеленым цветом. (Большой превью)

В случае, если новая разработка изменит размер файла до точки превышения установленного предела, сценарий завершится с ненулевым кодом. Это, помимо прочего, означает, что он остановит конвейер в GitLab CI.

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

Теперь мы можем использовать git hook для проверки размера файла на соответствие лимиту перед каждым коммитом. Мы можем даже использовать пакет хаски, чтобы сделать это красиво и просто.

Давайте установим его.

 npm i -D husky

Затем измените наш package.json .

 "size-limit": [ { "limit": "2 KB", "path": "index.js" } ], + "husky": { + "hooks": { + "pre-commit": "npm run size" + } + },

И теперь перед каждым коммитом автоматически будет выполняться команда npm run size и если она будет заканчиваться ненулевым кодом, то коммит никогда не произойдет.

Скриншот терминала, где фиксация прервана из-за превышения размера файла.
Скриншот терминала, где фиксация прервана из-за превышения размера файла. (Большой превью)

Но есть много способов пропустить хуки (преднамеренно или даже случайно), так что не стоит слишком полагаться на них.

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

так что нам делать? Давайте покажем изменение размера файла прямо в мерж-реквесте! Но вы не стремитесь к мастерству напрямую; ты ведешь себя как взрослый разработчик, верно?

Запускаем нашу проверку на GitLab CI

Добавим артефакт GitLab типа metrics. Артефакт — это файл, который будет «жить» после завершения работы конвейера. Этот специфический тип артефакта позволяет нам отображать дополнительный виджет в мерж-реквесте, показывающий любое изменение значения метрики между артефактом в мастере и функциональной ветке. Формат артефакта metrics — текстовый формат Prometheus. Для значений GitLab внутри артефакта это просто текст. GitLab не понимает, что именно изменилось в значении — он просто знает, что значение другое. Итак, что именно мы должны делать?

  1. Определите артефакты в конвейере.
  2. Измените сценарий, чтобы он создавал артефакт в конвейере.

Для создания артефакта нам нужно изменить .gitlab-ci.yml следующим образом:

 image: node:latest stages: - performance sizecheck: stage: performance before_script: - npm ci script: - npm run size + artifacts: + expire_in: 7 days + paths: + - metric.txt + reports: + metrics: metric.txt
  1. expire_in: 7 days — артефакт будет существовать 7 дней.
  2.  paths: metric.txt

    Он будет сохранен в корневом каталоге. Если вы пропустите эту опцию, то ее будет невозможно загрузить.
  3.  reports: metrics: metric.txt

    Артефакт будет иметь тип reports:metrics

Теперь заставим Size Limit генерировать отчет. Для этого нам нужно изменить package.json :

 "scripts": { - "size": "size-limit", + "size": "size-limit --json > size-limit.json", "test": "jest && eslint ." },

size-limit с ключом --json выведет данные в формате json:

Команда size-limit --json выводит JSON на консоль. JSON содержит массив объектов, который содержит имя и размер файла, а также сообщает нам, превышает ли он ограничение по размеру.
Команда size-limit --json выводит JSON на консоль. JSON содержит массив объектов, который содержит имя и размер файла, а также сообщает нам, превышает ли он ограничение по размеру. (Большой превью)

А перенаправление > size-limit.json сохранит JSON в файл size-limit.json .

Теперь нам нужно создать из этого артефакт. Формат сводится к [metrics name][space][metrics value] . Создадим скрипт generate-metric.js :

 const report = require('./size-limit.json'); process.stdout.write(`size ${(report[0].size/1024).toFixed(1)}Kb`); process.exit(0);

И добавьте его в package.json :

 "scripts": { "size": "size-limit --json > size-limit.json", + "postsize": "node generate-metric.js > metric.txt", "test": "jest && eslint ." },

Поскольку мы использовали префикс post , команда npm run size size запустит сценарий размера, а затем автоматически выполнит сценарий postsize , что приведет к созданию файла metric.txt , нашего артефакта.

В итоге, когда мы сольем эту ветку в master, что-то изменим и создадим новый мерж-реквест, мы увидим следующее:

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

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

Теперь мы действительно можем увидеть, как изменить размер пакета, и принять разумное решение, следует ли нам объединять его или нет.

  • Вы можете увидеть весь этот код в этом репозитории.

Резюме

OK! Итак, мы выяснили, как поступить в тривиальном случае. Если у вас несколько файлов, просто разделите метрики разрывами строк. В качестве альтернативы пределу размера вы можете рассмотреть пакетный размер. Если вы используете WebPack, вы можете получить все необходимые размеры, создав с флагами --profile и --json :

 webpack --profile --json > stats.json

Если вы используете next.js, вы можете использовать плагин @next/bundle-analyzer. Тебе решать!

Использование маяка

Lighthouse — стандарт де-факто в проектной аналитике. Давайте напишем скрипт, который позволит нам измерить производительность, a11y, лучшие практики и предоставить нам оценку SEO.

Скрипт для измерения всех вещей

Для начала нам нужно установить пакет маяка, который будет производить измерения. Нам также нужно установить puppeteer, который мы будем использовать в качестве безголового браузера.

 npm i -D lighthouse puppeteer

Далее создадим скрипт lighthouse.js и запустим наш браузер:

 const puppeteer = require('puppeteer'); (async () => { const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox', '--headless'], }); })();

Теперь давайте напишем функцию, которая поможет нам проанализировать заданный URL:

 const lighthouse = require('lighthouse'); const DOMAIN = process.env.DOMAIN; const buildReport = browser => async url => { const data = await lighthouse( `${DOMAIN}${url}`, { port: new URL(browser.wsEndpoint()).port, output: 'json', }, { extends: 'lighthouse:full', } ); const { report: reportJSON } = data; const report = JSON.parse(reportJSON); // … }

Здорово! Теперь у нас есть функция, которая примет объект браузера в качестве аргумента и вернет функцию, которая примет URL -адрес в качестве аргумента и сгенерирует отчет после передачи этого URL -адреса lighthouse .

Мы передаем lighthouse следующие аргументы:

  1. Адрес, который мы хотим проанализировать;
  2. параметры lighthouse , в частности port браузера и output (выходной формат отчета);
  3. конфигурация report и lighthouse:full (все, что мы можем измерить). Для более точной настройки обратитесь к документации.

Замечательный! Теперь у нас есть отчет. Но что мы можем с этим сделать? Что ж, мы можем сверить метрики с ограничениями и выйти из скрипта с ненулевым кодом, который остановит конвейер:

 if (report.categories.performance.score < 0.8) process.exit(1);

Но мы просто хотим сделать производительность видимой и неблокирующей? Затем давайте примем другой тип артефакта: артефакт производительности GitLab.

Артефакт производительности GitLab

Чтобы понять этот формат артефактов, нам нужно прочитать код плагина sitespeed.io. (Почему GitLab не может описать формат своих артефактов в собственной документации? Загадка. )

 [ { "subject":"/", "metrics":[ { "name":"Transfer Size (KB)", "value":"19.5", "desiredSize":"smaller" }, { "name":"Total Score", "value":92, "desiredSize":"larger" }, {…} ] }, {…} ]

Артефакт — это файл JSON , содержащий массив объектов. Каждый из них представляет отчет об одном URL .

 [{page 1}, {page 2}, …]

Каждая страница представлена ​​объектом со следующими атрибутами:

  1. subject
    Идентификатор страницы (очень удобно использовать такой путь);
  2. metrics
    Массив объектов (каждый из них представляет собой одно измерение, выполненное на странице).
 { "subject":"/login/", "metrics":[{measurement 1}, {measurement 2}, {measurement 3}, …] }

measurement — это объект, который содержит следующие атрибуты:

  1. name
    Имя измерения, например, это может быть Time to first byte или Time to interactive .
  2. value
    Числовой результат измерения.
  3. desiredSize
    Если целевое значение должно быть как можно меньше, например, для метрики « Time to interactive », то значение должно быть smaller . Если он должен быть как можно больше, например, для показателя Performance score маяка, используйте larger .
 { "name":"Time to first byte (ms)", "value":240, "desiredSize":"smaller" }

Давайте изменим нашу функцию buildReport таким образом, чтобы она возвращала отчет для одной страницы со стандартными метриками маяка.

Скриншот с отчетом о маяке. Есть оценка производительности, оценка a11y, оценка лучших практик, оценка SEO.
Скриншот с отчетом о маяке. Есть оценка производительности, оценка a11y, оценка лучших практик, оценка SEO. (Большой превью)
 const buildReport = browser => async url => { // … const metrics = [ { name: report.categories.performance.title, value: report.categories.performance.score, desiredSize: 'larger', }, { name: report.categories.accessibility.title, value: report.categories.accessibility.score, desiredSize: 'larger', }, { name: report.categories['best-practices'].title, value: report.categories['best-practices'].score, desiredSize: 'larger', }, { name: report.categories.seo.title, value: report.categories.seo.score, desiredSize: 'larger', }, { name: report.categories.pwa.title, value: report.categories.pwa.score, desiredSize: 'larger', }, ]; return { subject: url, metrics: metrics, }; }

Теперь, когда у нас есть функция, формирующая отчет. Применим его к каждому типу страниц проекта. Во-первых, мне нужно указать, что process.env.DOMAIN должен содержать промежуточный домен (на который вам нужно заранее развернуть свой проект из функциональной ветки).

 + const fs = require('fs'); const lighthouse = require('lighthouse'); const puppeteer = require('puppeteer'); const DOMAIN = process.env.DOMAIN; const buildReport = browser => async url => {/* … */}; + const urls = [ + '/inloggen', + '/wachtwoord-herstellen-otp', + '/lp/service', + '/send-request-to/ww-tammer', + '/post-service-request/binnenschilderwerk', + ]; (async () => { const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox', '--headless'], }); + const builder = buildReport(browser); + const report = []; + for (let url of urls) { + const metrics = await builder(url); + report.push(metrics); + } + fs.writeFileSync(`./performance.json`, JSON.stringify(report)); + await browser.close(); })();
  • Вы можете найти полный исходный код в этой сути и рабочий пример в этом репозитории.

Примечание . В этот момент вы, возможно, захотите прервать меня и напрасно кричать: «Почему вы отнимаете у меня время — вы даже не можете правильно использовать Promise.all!» В свое оправдание осмелюсь сказать, что не рекомендуется запускать более одного экземпляра маяка одновременно, так как это негативно влияет на точность результатов измерений. Также, если вы не проявите должной смекалки, это приведет к исключению.

Использование нескольких процессов

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

Если вы хотите пойти на этот риск, вам необходимо:

  1. Разбить массив URL на куски по количеству ядер;
  2. Создать форк процесса по количеству ядер;
  3. Перенесите части массива в вилки, а затем получите сгенерированные отчеты.

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

 /** * Returns urls array splited to chunks accordin to cors number * * @param urls {String[]} — URLs array * @param cors {Number} — count of available cors * @return {Array } — URLs array splited to chunks */ function chunkArray(urls, cors) { const chunks = [...Array(cors)].map(() => []); let index = 0; urls.forEach((url) => { if (index > (chunks.length - 1)) { index = 0; } chunks[index].push(url); index += 1; }); return chunks; } /** * Returns urls array splited to chunks accordin to cors number * * @param urls {String[]} — URLs array * @param cors {Number} — count of available cors * @return {Array } — URLs array splited to chunks */ function chunkArray(urls, cors) { const chunks = [...Array(cors)].map(() => []); let index = 0; urls.forEach((url) => { if (index > (chunks.length - 1)) { index = 0; } chunks[index].push(url); index += 1; }); return chunks; }

Сделайте вилки в соответствии с количеством ядер:

 // Adding packages that allow us to use cluster const cluster = require('cluster'); // And find out how many cors are available. Both packages are build-in for node.js. const numCPUs = require('os').cpus().length; (async () => { if (cluster.isMaster) { // Parent process const chunks = chunkArray(urls, urls.length/numCPUs); chunks.map(chunk => { // Creating child processes const worker = cluster.fork(); }); } else { // Child process } })();

Передадим массив чанков дочерним процессам и получим отчеты обратно:

 (async () => { if (cluster.isMaster) { // Parent process const chunks = chunkArray(urls, urls.length/numCPUs); chunks.map(chunk => { const worker = cluster.fork(); + // Send message with URL's array to child process + worker.send(chunk); }); } else { // Child process + // Recieveing message from parent proccess + process.on('message', async (urls) => { + const browser = await puppeteer.launch({ + args: ['--no-sandbox', '--disable-setuid-sandbox', '--headless'], + }); + const builder = buildReport(browser); + const report = []; + for (let url of urls) { + // Generating report for each URL + const metrics = await builder(url); + report.push(metrics); + } + // Send array of reports back to the parent proccess + cluster.worker.send(report); + await browser.close(); + }); } })();

И, наконец, собрать отчеты в один массив и сгенерировать артефакт.

  • Ознакомьтесь с полным кодом и репозиторием с примером, показывающим, как использовать маяк с несколькими процессами.

Точность измерений

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

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

 // Count of measurements we want to make const MEASURES_COUNT = 3; /* * Reducer which will calculate an avarage value of all page measurements * @param pages {Object} — accumulator * @param page {Object} — page * @return {Object} — page with avarage metrics values */ const mergeMetrics = (pages, page) => { if (!pages) return page; return { subject: pages.subject, metrics: pages.metrics.map((measure, index) => { let value = (measure.value + page.metrics[index].value)/2; value = +value.toFixed(2); return { ...measure, value, } }), } }

Затем измените наш код, чтобы использовать их:

 process.on('message', async (urls) => { const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox', '--headless'], }); const builder = buildReport(browser); const report = []; for (let url of urls) { + // Let's measure MEASURES_COUNT times and calculate the avarage + let measures = []; + let index = MEASURES_COUNT; + while(index--){ const metric = await builder(url); + measures.push(metric); + } + const measure = measures.reduce(mergeMetrics); report.push(measure); } cluster.worker.send(report); await browser.close(); }); }
  • Ознакомьтесь с сутью с полным кодом и репозиторием с примером.

И теперь мы можем добавить lighthouse в пайплайн.

Добавление его в конвейер

Сначала создайте файл конфигурации с именем .gitlab-ci.yml .

 image: node:latest stages: # You need to deploy a project to staging and put the staging domain name # into the environment variable DOMAIN. But this is beyond the scope of this article, # primarily because it is very dependent on your specific project. # - deploy # - performance lighthouse: stage: performance before_script: - apt-get update - apt-get -y install gconf-service libasound2 libatk1.0-0 libatk-bridge2.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget - npm ci script: - node lighthouse.js artifacts: expire_in: 7 days paths: - performance.json reports: performance: performance.json

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

Скриншот страницы мерж-реквеста. Есть виджет, который показывает, какие метрики маяка изменились и как именно
Скриншот страницы мерж-реквеста. Есть виджет, который показывает, какие метрики маяка изменились и как именно. (Большой превью)

Хороший?

Резюме

Наконец-то мы закончили с более сложным случаем. Очевидно, что помимо маяка существует множество подобных инструментов. Например, sitespeed.io. В документации GitLab даже есть статья, в которой объясняется, как использовать скорость сайта в пайплайне sitespeed . Также есть плагин для GitLab, который позволяет нам генерировать артефакт. Но кто предпочтет продукты с открытым исходным кодом, управляемые сообществом, продуктам, принадлежащим корпоративному монстру?

Нет покоя нечестивым

Может показаться, что мы, наконец, там, но нет, еще нет. Если вы используете платную версию GitLab, то артефакты с metrics типов отчетов и performance присутствуют в планах, начиная с premium и silver , которые стоят 19 долларов в месяц для каждого пользователя. Кроме того, вы не можете просто купить конкретную функцию, которая вам нужна — вы можете только изменить план. Извиняюсь. Итак, что мы можем сделать? В отличие от GitHub с его Checks API и Status API, GitLab не позволит вам самостоятельно создать фактический виджет в мерж-реквесте. И нет никакой надежды получить их в ближайшее время.

Скриншот твита Ильи Климова (сотрудник GitLab) написал о вероятности появления аналогов для Github Checks and Status API: «Крайне маловероятно. Проверки уже доступны через API статусов коммитов, а что касается статусов, мы стремимся быть закрытой экосистемой».
Скриншот твита Ильи Климова (сотрудник GitLab), который написал о вероятности появления аналогов для Github Checks and Status API. (Большой превью)

Один из способов проверить, действительно ли вы поддерживаете эти функции: вы можете найти переменную среды GITLAB_FEATURES в конвейере. Если в списке отсутствуют merge_request_performance_metrics и metrics_reports , то эти функции не поддерживаются.

 GITLAB_FEATURES=audit_events,burndown_charts,code_owners,contribution_analytics, elastic_search, export_issues,group_bulk_edit,group_burndown_charts,group_webhooks, issuable_default_templates,issue_board_focus_mode,issue_weights,jenkins_integration, ldap_group_sync,member_lock,merge_request_approvers,multiple_issue_assignees, multiple_ldap_servers,multiple_merge_request_assignees,protected_refs_for_users, push_rules,related_issues,repository_mirrors,repository_size_limit,scoped_issue_board, usage_quotas,visual_review_app,wip_limits

Если поддержки нет, надо что-то придумывать. Например, мы можем добавить комментарий к мерж-реквесту, комментарий с таблицей, содержащей все необходимые нам данные. Мы можем оставить наш код нетронутым — артефакты будут создаваться, но виджеты всегда будут показывать сообщение «metrics are unchanged» .

Очень странное и неочевидное поведение; Пришлось хорошенько подумать, чтобы понять, что происходит.

Итак, каков план?

  1. Нам нужно прочитать артефакт из ветки master ;
  2. Создать комментарий в формате markdown ;
  3. Получить идентификатор мерж-реквеста от текущей фиче-ветки к мастеру;
  4. Добавьте комментарий.

Как прочитать артефакт из основной ветки

Если мы хотим показать, как меняются показатели производительности между master и функциональной ветвями, нам нужно прочитать артефакт из master ветки. И для этого нам нужно будет использовать fetch .

 npm i -S isomorphic-fetch
 // You can use predefined CI environment variables // @see https://gitlab.com/help/ci/variables/predefined_variables.md // We need fetch polyfill for node.js const fetch = require('isomorphic-fetch'); // GitLab domain const GITLAB_DOMAIN = process.env.CI_SERVER_HOST || process.env.GITLAB_DOMAIN || 'gitlab.com'; // User or organization name const NAME_SPACE = process.env.CI_PROJECT_NAMESPACE || process.env.PROJECT_NAMESPACE || 'silentimp'; // Repo name const PROJECT = process.env.CI_PROJECT_NAME || process.env.PROJECT_NAME || 'lighthouse-comments'; // Name of the job, which create an artifact const JOB_NAME = process.env.CI_JOB_NAME || process.env.JOB_NAME || 'lighthouse'; /* * Returns an artifact * * @param name {String} - artifact file name * @return {Object} - object with performance artifact * @throw {Error} - thhrow an error, if artifact contain string, that can't be parsed as a JSON. Or in case of fetch errors. */ const getArtifact = async name => { const response = await fetch(`https://${GITLAB_DOMAIN}/${NAME_SPACE}/${PROJECT}/-/jobs/artifacts/master/raw/${name}?job=${JOB_NAME}`); if (!response.ok) throw new Error('Artifact not found'); const data = await response.json(); return data; };

Создание текста комментария

Нам нужно построить текст комментария в формате markdown . Давайте создадим несколько сервисных функций, которые нам помогут:

 /** * Return part of report for specific page * * @param report {Object} — report * @param subject {String} — subject, that allow find specific page * @return {Object} — page report */ const getPage = (report, subject) => report.find(item => (item.subject === subject)); /** * Return specific metric for the page * * @param page {Object} — page * @param name {String} — metrics name * @return {Object} — metric */ const getMetric = (page, name) => page.metrics.find(item => item.name === name); /** * Return table cell for desired metric * * @param branch {Object} - report from feature branch * @param master {Object} - report from master branch * @param name {String} - metrics name */ const buildCell = (branch, master, name) => { const branchMetric = getMetric(branch, name); const masterMetric = getMetric(master, name); const branchValue = branchMetric.value; const masterValue = masterMetric.value; const desiredLarger = branchMetric.desiredSize === 'larger'; const isChanged = branchValue !== masterValue; const larger = branchValue > masterValue; if (!isChanged) return `${branchValue}`; if (larger) return `${branchValue} ${desiredLarger ? '' : '' } **+${Math.abs(branchValue - masterValue).toFixed(2)}**`; return `${branchValue} ${!desiredLarger ? '' : '' } **-${Math.abs(branchValue - masterValue).toFixed(2)}**`; }; /** * Returns text of the comment with table inside * This table contain changes in all metrics * * @param branch {Object} report from feature branch * @param master {Object} report from master branch * @return {String} comment markdown */ const buildCommentText = (branch, master) =>{ const md = branch.map( page => { const pageAtMaster = getPage(master, page.subject); if (!pageAtMaster) return ''; const md = `|${page.subject}|${buildCell(page, pageAtMaster, 'Performance')}|${buildCell(page, pageAtMaster, 'Accessibility')}|${buildCell(page, pageAtMaster, 'Best Practices')}|${buildCell(page, pageAtMaster, 'SEO')}| `; return md; }).join(''); return ` |Path|Performance|Accessibility|Best Practices|SEO| |--- |--- |--- |--- |--- | ${md} `; };

Скрипт, который создаст комментарий

Вам понадобится токен для работы с GitLab API. Чтобы сгенерировать его, вам нужно открыть GitLab, войти в систему, открыть пункт меню «Настройки», а затем открыть «Токены доступа», которые находятся в левой части меню навигации. После этого вы сможете увидеть форму, позволяющую сгенерировать токен.

Скриншот, на котором показана форма генерации токена и пункты меню, о которых я упоминал выше.
Скриншот, на котором показана форма генерации токена и пункты меню, о которых я упоминал выше. (Большой превью)

Также вам понадобится ID проекта. Найти его можно в репозитории «Настройки» (в подменю «Общие»):

Скриншот показывает страницу настроек, где вы можете найти ID проекта
На скриншоте показана страница настроек, где вы можете найти ID проекта. (Большой превью)

Чтобы добавить комментарий к мерж-реквесту, нам нужно знать его ID. Функция, позволяющая получить идентификатор мерж-реквеста, выглядит так:

 // You can set environment variables via CI/CD UI. // @see https://gitlab.com/help/ci/variables/README#variables // I have set GITLAB_TOKEN this way // ID of the project const GITLAB_PROJECT_ID = process.env.CI_PROJECT_ID || '18090019'; // Token const TOKEN = process.env.GITLAB_TOKEN; /** * Returns iid of the merge request from feature branch to master * @param from {String} — name of the feature branch * @param to {String} — name of the master branch * @return {Number} — iid of the merge request */ const getMRID = async (from, to) => { const response = await fetch(`https://${GITLAB_DOMAIN}/api/v4/projects/${GITLAB_PROJECT_ID}/merge_requests?target_branch=${to}&source_branch=${from}`, { method: 'GET', headers: { 'PRIVATE-TOKEN': TOKEN, } }); if (!response.ok) throw new Error('Merge request not found'); const [{iid}] = await response.json(); return iid; };

We need to get a feature branch name. You may use the environment variable CI_COMMIT_REF_SLUG inside the pipeline. Outside of the pipeline, you can use the current-git-branch package. Also, you will need to form a message body.

Let's install the packages we need for this matter:

 npm i -S current-git-branch form-data

And now, finally, function to add a comment:

 const FormData = require('form-data'); const branchName = require('current-git-branch'); // Branch from which we are making merge request // In the pipeline we have environment variable `CI_COMMIT_REF_NAME`, // which contains name of this banch. Function `branchName` // will return something like «HEAD detached» message in the pipeline. // And name of the branch outside of pipeline const CURRENT_BRANCH = process.env.CI_COMMIT_REF_NAME || branchName(); // Merge request target branch, usually it's master const DEFAULT_BRANCH = process.env.CI_DEFAULT_BRANCH || 'master'; /** * Adding comment to merege request * @param md {String} — markdown text of the comment */ const addComment = async md => { const iid = await getMRID(CURRENT_BRANCH, DEFAULT_BRANCH); const commentPath = `https://${GITLAB_DOMAIN}/api/v4/projects/${GITLAB_PROJECT_ID}/merge_requests/${iid}/notes`; const body = new FormData(); body.append('body', md); await fetch(commentPath, { method: 'POST', headers: { 'PRIVATE-TOKEN': TOKEN, }, body, }); };

And now we can generate and add a comment:

 cluster.on('message', (worker, msg) => { report = [...report, ...msg]; worker.disconnect(); reportsCount++; if (reportsCount === chunks.length) { fs.writeFileSync(`./performance.json`, JSON.stringify(report)); + if (CURRENT_BRANCH === DEFAULT_BRANCH) process.exit(0); + try { + const masterReport = await getArtifact('performance.json'); + const md = buildCommentText(report, masterReport) + await addComment(md); + } catch (error) { + console.log(error); + } process.exit(0); } });
  • Check the gist and demo repository.

Now create a merge request and you will get:

A screenshot of the merge request which shows comment with a table that contains a table with lighthouse metrics change
A screenshot of the merge request which shows comment with a table that contains a table with lighthouse metrics change. (Большой превью)

Резюме

Comments are much less visible than widgets but it's still much better than nothing. This way we can visualize the performance even without artifacts.

Authentication

OK, but what about authentication? The performance of the pages that require authentication is also important. It's easy: we will simply log in. puppeteer is essentially a fully-fledged browser and we can write scripts that mimic user actions:

 const LOGIN_URL = '/login'; const USER_EMAIL = process.env.USER_EMAIL; const USER_PASSWORD = process.env.USER_PASSWORD; /** * Authentication sctipt * @param browser {Object} — browser instance */ const login = async browser => { const page = await browser.newPage(); page.setCacheEnabled(false); await page.goto(`${DOMAIN}${LOGIN_URL}`, { waitUntil: 'networkidle2' }); await page.click('input[name=email]'); await page.keyboard.type(USER_EMAIL); await page.click('input[name=password]'); await page.keyboard.type(USER_PASSWORD); await page.click('button[data-test]', { waitUntil: 'domcontentloaded' }); };

Before checking a page that requires authentication, we may just run this script. Сделанный.

Резюме

In this way, I built the performance monitoring system at Werkspot — a company I currently work for. It's great when you have the opportunity to experiment with the bleeding edge technology.

Now you also know how to visualize performance change, and it's sure to help you better track performance degradation. But what comes next? You can save the data and visualize it for a time period in order to better understand the big picture, and you can collect performance data directly from the users.

You may also check out a great talk on this subject: “Measuring Real User Performance In The Browser.” When you build the system that will collect performance data and visualize them, it will help to find your performance bottlenecks and resolve them. Good luck with that!