💭

AutoWebPerf で CoreWebVitals を計測する

2021/01/14に公開

はじめに

AutoWebPerf をいろいろ触ってみたので紹介していこうと思います

CoreWebVitals

https://web.dev/vitals/

Core Web Vitals とは優れたユーザーエクスペリエンスを提供するための3つの指標のことです

  • Largest Contentful Paint (LCP)
  • First Input Delay (FID)
  • Cumulative Layout Shift (CLS)

AutoWebPerf

https://web.dev/autowebperf/

AutoWebPerf とは Google が作った複数のソースからパフォーマンスのデータを自動的に収集できるツールです

https://github.com/GoogleChromeLabs/AutoWebPerf

Chrome UX Report、PageSpeed Insights、WebPageTest など収集元のツールが増えてきて、それぞれ個別で設定をし計測を行うのは負荷が高まってきたので、1つの統合的なツールを利用して複数のソースからパフォーマンス監視をできるようにしたのが、作成した経緯なのかなと感じます

アーキテクチャ

connector からテストのリストを取得し、gatherer を介してパフォーマンスを取得し、コネクタに結果を出力するようなアーキテクチャになっています

  • the engine
  • connector modules
    • Spreadsheet
    • JSON
    • CSV
  • gatherer modules
    • CrUX API
    • CrUX BigQuery
    • PageSpeed Insights API
    • WebPageTest API
    • BigQuery(現在開発中とのこと)

クイックスタート

clone して npm i すれば ./awp run でテストを行うことができます

git clone https://github.com/GoogleChromeLabs/AutoWebPerf.git
cd AutoWebPerf
npm i
./awp run examples/tests.json output/results.json

下記テストケースを元にテストを行い、結果を出力します

examples/tests.json
{
  "tests": [
    {
      "label": "web.dev",
      "url": "https://web.dev",
      "gatherer": "psi"
    }
  ]
}

結果は下記

output/results.json
{
  "results": [
    {
      "id": "1610180070705-https://web.dev",
      "type": "Single",
      "gatherer": "psi",
      "status": "Retrieved",
      "label": "web.dev",
      "createdTimestamp": 1610180070705,
      "modifiedTimestamp": 1610180070705,
      "errors": [],
      "url": "https://web.dev",
      "psi": {
        "status": "Retrieved",
        "statusText": "Success",
        "settings": {},
        "metadata": {
          "testId": "https://web.dev/",
          "requestedUrl": "https://web.dev/",
          "finalUrl": "https://web.dev/",
          "lighthouseVersion": "6.3.0",
          "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/85.0.4183.140 Safari/537.36",
          "fetchTime": "2021-01-09T08:14:31.866Z"
        },
        "metrics": {
          "RenderBlockingResources": 0,
          "crux": {
            "LargestContentfulPaint": {
              "category": "FAST",
              "percentile": 2475,
              "good": 0.761798544259217,
              "ni": 0.15437896219769925,
              "poor": 0.08382249354308509
            },
            "FirstInputDelay": {
              "category": "FAST",
              "percentile": 17,
              "good": 0.9399224806201529,
              "ni": 0.04069767441860455,
              "poor": 0.01937984496124033
            },
            "FirstContentfulPaint": {
              "category": "AVERAGE",
              "percentile": 2521,
              "good": 0.18828643331045844,
              "ni": 0.6437886067261603,
              "poor": 0.16792495996339965
            },
            "CumulativeLayoutShift": {
              "category": "FAST",
              "percentile": 3,
              "good": 0.7931119920713579,
              "ni": 0.07210109018830525,
              "poor": 0.13478691774033683
            }
          },
          "lighthouse": {
            "FirstContentfulPaint": 1411,
            "FirstMeaningfulPaint": 1682,
            "LargestContentfulPaint": 3318.111530962102,
            "SpeedIndex": 2162,
            "TimeToInteractive": 5448,
            "FirstCPUIdle": 4085,
            "FirstInputDelay": 13,
            "TotalBlockingTime": 109,
            "CumulativeLayoutShift": 0,
            "TotalSize": 392,
            "HTML": 7,
            "Javascript": 140,
            "CSS": 25,
            "Fonts": 74,
            "Images": 135,
            "Medias": 0,
            "ThirdParty": 202,
            "UnusedCSS": 21,
            "WebPImages": 0,
            "OptimizedImages": 0,
            "ResponsiveImages": 0,
            "OffscreenImages": 0,
            "DOMElements": 307,
            "Performance": 0.88,
            "ProgressiveWebApp": 1,
            "Manifest": 1,
            "ServiceWorker": 1,
            "Offline": 1,
            "Accessibility": 1,
            "SEO": 0.99,
            "BestPractices": 1
          }
        },
        "errors": []
      }
    }
  ]
}

CLI で定期的なテスト

AutoWebPerf では CLI で定期的なテストを行えます

準備

まず ./awp recurring で準備をします

./awp recurring examples/tests-recurring.json output/results.json
examples/tests-recurring.json
{
  "tests": [
    {
      "label": "web.dev",
      "url": "https://web.dev",
      "recurring": {
        "frequency": "Daily",
        "nextTriggerTimestamp": 1606955174906
      },
      "gatherer": "psi",
      "errors": []
    }
  ]
}

実行すると tests.recurring.nextTriggerTimestamp が現在時刻 +1 日に更新されます

これは tests.recurring.frequencyDaily に設定されているため、次の実行日時をテスト実行時の +1 日に更新したのだと思います

↓実行後

examples/tests-recurring.json
{
  "tests": [
    {
      "label": "web.dev",
      "url": "https://web.dev",
      "recurring": {
        "frequency": "Daily",
        "nextTriggerTimestamp": 1610267063035
      },
      "gatherer": "psi",
      "errors": []
    }
  ]
}

更新後もう一度 ./awp recurring を実行しても、テストは実行されません

これは現在時刻が tests.recurring.nextTriggerTimestamp より前の時刻だからだと思われます

準備はつまるところ、tests.recurring.nextTriggerTimestamp を更新しているので手動で書き換えても良いです!

実行

./awp continue を実行すると CLI は待機状態になります

./awp continue examples/tests-recurring.json output/results.json

動作確認で tests.recurring.nextTriggerTimestamp を1分後などにしていたのですが、最初実行されず戸惑いました

これは、どうやら 10 分毎にテストを実行するかチェックをしているっぽいので、動作テストをする場合は10分待つ必要がありました

https://github.com/GoogleChromeLabs/AutoWebPerf/blob/stable/src/awp-core.js#L461-L491

src/awp-core.js
async continue(options) {
  options = options || {};
  options.recurring = true;
  let self = this;
  let isRunning = false;

  // Set timer interval as every 10 mins by default.
  let timerInterval = options.timerInterval ? 
      parseInt(options.timerInterval) : 60 * 10;

  if (options.verbose) {
    this.log(`Timer interval sets as ${timerInterval} seconds.`);
  }

  await self.recurring(options);

  // Run contiuously.
  return await new Promise(resolve => {
    const interval = setInterval(async () => {
      if (isRunning) return;

      await self.recurring(options);
      await self.retrieve(options);
      isRunning = false;

      if (options.verbose) {
        self.log('Waiting for next timer triggered...');
      }
    }, timerInterval * 1000);
  });
}

無事10分待って定期的な実行を確認 👍

Spreadsheet への書き込み

GitHub にやり方が書いてあったので、これを見ながらやっていきます

https://github.com/GoogleChromeLabs/AutoWebPerf/blob/stable/docs/sheets-connector.md

手順1 サービスアカウントの作成

すでにサービスアカウントがある場合はスキップ

https://cloud.google.com/iam/docs/creating-managing-service-accounts

手順2 スプレッドシートの作成

まず Google Sheets API を有効化しておきます

公式から Spreadsheet のサンプルが提供されているのでこれを参考に Spreadsheet を作ります
https://docs.google.com/spreadsheets/d/1c3k9eEVg12Atoa72tglVVBg--o0CEMkOF3JCaLlxzeo/edit#gid=0

Tests タブと、Results タブを作成し、A 列は公式の Spreadsheet シートのラベルをコピーします

Tests にはせっかくなので自分が運営しているサイトと、比較のために web.dev を指定しています

シートを先程作ったサービスアカウントに共有します


手順3 service-account.json の作成

サービスアカウントの詳細画面の「鍵を追加」から作ります

AutoWebPerf では tmp.gitignore に追加されているので、tmp ディレクトリ配下に置いておきます

手順4 Chrome UX Report API キーの作成

Chrome UX Report API を有効化します

キーはデフォルトで作られるので利用します

手順5 実行

https://docs.google.com/spreadsheets/d/1c3k9eEVg12Atoa72tglVVBg--o0CEMkOF3JCaLlxzeo/edit

MY GOOGLE SHEET ID は↑の URL であれば 1c3k9eEVg12Atoa72tglVVBg--o0CEMkOF3JCaLlxzeo になります

SERVICE_ACCOUNT_CREDENTIALS=./tmp/service-account.json CRUX_APIKEY=MY_API_KEY ./awp run sheets:[MY GOOGLE SHEET ID]/Tests sheets:[MY GOOGLE SHEET ID]/Results

成功したが、自分のサイトはデータがなかった(悲しい)

DataStudio での可視化

Spreadsheet への書き込みのドキュメントに DataStudio での可視化が載っているのでやっていきます(このドキュメントに書かれている Spreadsheet の構成が元になっています)

https://github.com/GoogleChromeLabs/AutoWebPerf/blob/stable/docs/sheets-connector.md

DataStudio のテンプレートが用意されているのでこちらを元に作っていきます

https://datastudio.google.com/reporting/5f7b6c8c-cae2-4cf2-97a4-06af250a0039/page/X6nFB/preview

「テンプレートを使用」をクリックし、「新しいデータソース」→「新しいデータソースの作成」

先程作ったスプレッドシートの Results を選択します

「レポートに追加」

「レポートをコピー」で作成します

良い感じにグラフが表示されました!

Firebase Functions で定期的なテスト

https://firebase.google.com/docs/functions/schedule-functions?hl=ja

課金は必須ですが、Cloud Scheduler の各ジョブのコストは月額 $0.10(USD)であり、Google アカウントごとに 3 つのジョブを無料で使用できるため、全体的なコストは管理可能であることが期待できます

3つまでは無料とのことなので Firebase Functions で定期的にテストを実行させます

実装

https://github.com/GoogleChromeLabs/AutoWebPerf#usage-of-autowebperf-core

コード上で利用する場合 README とは引数が少し違いました

functions/src/awp.ts
import * as admin from "firebase-admin";
const AutoWebPerf = require('awp/src/awp-core');

admin.initializeApp();
const bucket = admin.storage().bucket();

const SHEET_ID = 'XXX';
const SERVICE_ACCOUNT_CREDENTIALS_FILE_NAME = 'service-account.json';
const LOCAL_SERVICE_ACCOUNT_CREDENTIALS_PATH = `/tmp/${SERVICE_ACCOUNT_CREDENTIALS_FILE_NAME}`;
const SERVICE_ACCOUNT_CREDENTIALS_PATH = `..${LOCAL_SERVICE_ACCOUNT_CREDENTIALS_PATH}`;
const CRUX_APIKEY = 'XXX';

export async function run() {
  await bucket.file(SERVICE_ACCOUNT_CREDENTIALS_FILE_NAME).download({
    destination: LOCAL_SERVICE_ACCOUNT_CREDENTIALS_PATH
  });

  const awp = new AutoWebPerf({
    "tests": {
      "connector": "sheets",
      "path": `${SHEET_ID}/Tests`
    },
    "results": {
      "connector": "sheets",
      "path": `${SHEET_ID}/Results`
    },
    "envVars": {
      "SERVICE_ACCOUNT_CREDENTIALS": SERVICE_ACCOUNT_CREDENTIALS_PATH,
      "CRUX_APIKEY": CRUX_APIKEY
    }
  });
  awp.run();
}

tests, results はコマンドラインからの実行と同じように connectorpath を定義し、環境変数は envVars 経由で渡します

Firebase Functions でのローカル JSON ファイルの取り扱いがわからなかったので、service-account.json を Firebase Stroage にアップロードしておいて、毎回ダウンロードして利用しています

functions/src/index.ts
import * as functions from 'firebase-functions';
import { run } from './awp';

export const scheduledFunction = functions.pubsub.schedule('every day 03:00').timeZone('Asia/Tokyo').onRun(async () => {
  await run();
  return null;
});

デプロイ

最初ロケーションを設定していなかったのでビルド時にエラーになりましたが、それ以外は特に詰まったりしなかったです 👍

Error: Cloud resource location is not set for this project but scheduled functions requires it. Please see this documentation for more details: https://firebase.google.com/docs/projects/locations.

https://firebase.google.com/docs/functions/schedule-functions?hl=ja#before_you_begin

終わりに

Firebase Functions でのローカル JSON ファイルの取り扱いに困ったくらいで他はスムーズに構築できました

まだツールをいれてないのであれば、導入を検討してもいいかもしれません!

Discussion