Money Forward Developers Blog

株式会社マネーフォワード公式開発者向けブログです。技術や開発手法、イベント登壇などを発信します。サービスに関するご質問は、各サービス窓口までご連絡ください。

20230215130734

Webパフォーマンスを自動計測しDatadogで可視化するまで

こんにちはマネーフォワードPay事業本部の藤村海都です。 GitHubTwitterもどうぞ。

普段はフロントエンド周りの技術記事をZennに投稿しています。Zennはこちら。 2023年4月に新卒エンジニアとしてマネーフォワードに入社し、福岡拠点にてマネーフォワードPay for Businessのフロントエンド開発を担当しています。

今回は最近チームに導入したパフォーマンス監視について紹介します。 フロントエンドにおけるWebパフォーマンスの計測を自動化し、Datadogで可視化するまでの道のりを詳しく説明していこうと思います。 パフォーマンス監視はパフォーマンス改善の第一歩だと思っています。

初のテックブログなので温かい目で見ていただければと思います。よろしくお願いします!

作ったもの

今回導入した仕組みでは次のような流れでパフォーマンスを可視化しています。

  • Puppeteerで認証のあるページに自動ログイン
  • LighthouseでWebサービスのメトリクスごとのスコアを計測
  • Datadogへスコアを送信し可視化
  • 上記のフローをGitHub Actionsで定期自動実行

最終的なDatadogは以下のようになります! (データは短期間で集計したため直線っぽくなっている)

各指標に対して複数のページで計測しています。

パフォーマンスを監視する理由

皆さんはチームで開発したWebサービスがどの程度パフォーマンスを発揮しているのか、具体的な数値で把握していますでしょうか。 僕が所属しているチームでは、これまでパフォーマンスの監視を行なっておらず、今後実施したいことの1つとして挙げられていました。

パフォーマンス監視の仕組みがないと、具体的な改善施策を考えるのが難しく、施策を実行してもそれが本当に正しかったのかを把握する手段がありません。

チームではパフォーマンスを監視をすることで以下のようなことを達成したいよね、という認識でいました。

  • システム内のボトルネックを探す手掛かりにしたい

    パフォーマンスの監視はシステム内で時間がかかっている部分、つまり「ボトルネック」を見つける手がかりにもなります。 たとえばページロードが長い場合、その原因は何か、 また、インタラクティブになるまでに時間がかかると感じる場合、どの部分が遅延の原因となっているのか? このような問いを立て、解決するためには、具体的なパフォーマンス指標が必要です。

  • 改善を継続的に追跡したい

    パフォーマンスの指標を追跡することでサービスのパフォーマンスを一定期間にわたってモニタリングし、その変化を視覚的に理解することができるようになります。 そしてパフォーマンス改善のためのタスクが実際に効果を発揮しているのかを確認することもできます。

計測項目

ではパフォーマンスの監視のために把握すべき「具体的な数値」には一体どんなものがあるのか、Core Web Vitalsなどをもとに紹介していきます。

Core Web Vitalls

Webパフォーマンスの指標として代表的なものといえばCore Web Vitalsでしょう。 GoogleはCore Web Vitalsのスコアをランキングシステムの結果に反映すると正式に公表しています。

つまり開発者にとってCore Web Vitalsの指標を改善することはユーザー体験の改善とGoogleの検索ランキングの向上の両方につながります。

現時点(2023年7月)でのCore Web Vitalsは以下の3つです。

  • Largest Contentful Paing (LCP)

    LCPは、ビューポート内に表示される最も大きい画像またはテキストブロックのレンダリング時間です。

  • First Input Delay (FID)

    FID1は、ユーザーが最初にページを操作したとき (リンクをクリックしたり、ボタンをタップしたり、JavaScript を使用して実装されたカスタム コントロールを使用したりしたとき) から、その操作に応じてブラウザが実際にイベントハンドラーの処理を開始するまでの時間を測定します。

  • Cumulative Layout Shift (CLS)

    CLSは、ページの表示中に発生した予期しないレイアウトシフトごとにレイアウトシフトスコアの最大バーストを測定します。レイアウトシフトは、表示された要素がレンダリングされたフレームから次のフレームへと位置を変更する際に発生します。詳細についてはCLSのページをご覧ください。

それぞれの指標についてより詳しく知りたい方はリンクから見ていただければと思います。

その他のWeb Vitals

"Core" Web Vitalsという名の通り、その他にもWeb Vitalsは存在します。

  • Time to First Byte (TTFB)

    TTFBは、リソースのリクエストからレスポンスの最初のバイトが到着し始めるまでの時間を測定する指標です。

  • First Contentful Paint (FCP)

    FCPは、ページの読み込みが開始されてからページ内のコンテンツのいずれかの部分が画面上にレンダリングされるまでの時間を測定します。この指標における "コンテンツ" は、テキスト、画像 (背景画像を含む)、<svg> 要素、白以外の <canvas> 要素のことを指しています。

  • Time to Interactive (TTI)

    TTIは、ページの読み込みが開始されてから主なサブリソースの読み込みが完了するまでの時間です。改善することでページがユーザーの入力に対してすばやく確実に応答できるようになります。

  • Total Blocking Time (TBT)

    TBTは、長時間に渡りメイン スレッドがブロックされ、入力の応答性が妨げられることで発生するFCPとTTIの間の時間の合計を測定します。

今回使用した指標

上記のようにパフォーマンスの指標にはさまざまありますが、パフォーマンスを監視する理由であげた目標に基づき以下の指標を測定することにしました。 指標を追加しても良いのですが、一旦選定する理由が明確なものだけピックアップしました。

  • LCP
  • FID(ラボデータ2のFIDに相当するもの)
  • CLS
  • TTI
  • TBT
  • TTFB

計測ツール・ライブラリ

上記項目の指標を計測するために以下のツール・ライブラリを使用しました。

  • Lighthouse

    • パフォーマンスを計測するツール
    • 上記であげた項目を計測できる
  • Puppeteer

    • Chromeを操作できるツール
    • ヘッドレスChrome3を立ち上げ、ページ操作をプログラムにより制御できる
    • これによりログインが必要な画面もLighthouseで集計することができる
  • tsx

    • tsファイルを実行できるツール。ts-nodeの代替として利用できる
    • Puppeteerの実行、Datadogへのメトリクス送信プログラムを実行

Lighthouseを使う場合、上記であげたWeb Vitalsの指標は以下のような名称に対応します。 その他にどんな指標があるかはnpx lighthouse --list-all-auditsで確認できるので一度手元で実行してみてください。

Lighthouseでの指標名

'largest-contentful-paint' // LCP
'max-potential-fid' // FID相当
'total-blocking-time' //TBT
'interactive' // TTI相当
'cumulative-layout-shift' // CLS
'server-response-time' // TTFB相当

注意

ここでmax-potential-fidがFID相当となっている理由は、実は厳密なFIDはLighthouseでは計測できないからです。 FIDは実際のユーザー操作による測定が必要となります。その代わりに理論的な代替値を利用しています。 詳細は以下のリンクを参照していただければと思います。

メトリクスの測定

実際にヘッドレスChrome上でメトリクスを計測してDatadogに送信するという動作は以下のようなソースコードで実現できます。

処理の流れは

  1. Chromeを立ち上げる
  2. ログインする
  3. Lighthouseを実行する
  4. Datadogへ送信する

となっています。 AuditsResultの型については@KawamataRyoさんのコードを参考にしました。

lighthouse.ts(実際のコードを一部変更しています)

import { Browser, Page, launch } from 'puppeteer';
import lighthouse, { Config, Flags, generateReport } from 'lighthouse';
import { AuditsResult } from './lighthouse.d';
import { sendMetrics } from './datadog';

const BASE_URL = 'https://example.com';

const USERNAME = process.env.LIGHTHOUSE_USERNAME ?? '';
const PASSWORD = process.env.LIGHTHOUSE_PASSWORD ?? '';

const TARGET_URLS = [`${BASE_URL}/list`];

const TARGET_METRICS = [
  'largest-contentful-paint', // LCP
  'max-potential-fid', // FID相当
  'total-blocking-time', //TBT
  'interactive', // TTI
  'cumulative-layout-shift', // CLS
  'server-response-time', // TTFB
];

const flags: Flags = {
  logLevel: 'info',
  disableStorageReset: true,
};

const config: Config = {
  extends: 'lighthouse:default',
  settings: {
    formFactor: 'desktop',
    // ネットワークとCPUのスロットリングの設定
    throttling: {
      rttMs: 40,
      throughputKbps: 10 * 1024,
      cpuSlowdownMultiplier: 1,
      requestLatencyMs: 0,
      downloadThroughputKbps: 0,
      uploadThroughputKbps: 0,
    },
    onlyAudits: TARGET_METRICS,
    // Chromeブラウザの画面エミュレーションの設定
    screenEmulation: {
      mobile: false,
      width: 1350,
      height: 940,
      deviceScaleFactor: 1,
      disabled: false,
    },
  },
};

const login = async (browser: Browser): Promise<Page> => {
  const page = await browser.newPage();

  // write down your login process here
  // example)
  // await page.type('[name="email"]', USERNAME);
  // await page.type('[name="password"]', PASSWORD);
  // await page.click('#loginButton');

  return page;
};

const main = async () => {
  console.info('Now login ...');
  // Chromeを立ち上げる
  const browser = await launch({
    headless: 'new',
    timeout: 150000,
    args: ['--no-sandbox', '--disable-setuid-sandbox'],
  });

  // ログインする
  const page = await login(browser);

  console.info('Running lighthouse ...');

  for (const url of TARGET_URLS) {
    // Lighthouseを実行する
    const result = await lighthouse(url, flags, config, page);

    if (result) {
      const jsonData = JSON.parse(generateReport(result.lhr, 'json')); // json出力する
      const path = url.replace(`${BASE_URL}/`, '').replace('/', '-');
      TARGET_METRICS.forEach(async (metrics) => {
        const metric = jsonData.audits[metrics] as AuditsResult;
        console.log(metric);
        // Datadogにデータを送信
        await sendMetrics({
          pagePath: path.replace('-', '.'),
          metricName: metric.id,
          metricValue: metric.numericValue,
        });
      });
    } else {
      console.log('result is null...');
    }
  }

  browser.disconnect();
  browser.close();
};

main().catch(console.error);
# lighthouse.d.ts

export type AuditsResult = {
  id: string;
  title: string;
  description: string;
  score: number;
  scoreDisplayMode: string;
  numericValue: number;
  numericUnit: string;
  displayValue: string;
  details?: Details;
};

export type Details = {
  type: string;
  items: Item[];
};

export type Item = {
  finalLayoutShiftTraceEventFound: boolean;
};

Datadogで可視化

DatadogのAPIドキュメントにメトリクス送信のサンプルコードがあったのでそれを参考に作成しました。

環境変数

Datadogのドキュメントでは実行時にコマンドラインで環境変数を渡していますが、以下のようにconfig形式で渡した方が扱いやすいと思います。 DatadogのAPIキーとAPPキー(間違えそう)についてはこちらのドキュメントに詳細が載っています。

/**
 * Submit metrics
 */

import { client, v2 } from '@datadog/datadog-api-client';

const configurationOpts = {
  authMethods: {
    apiKeyAuth: process.env.DATADOG_API_KEY ?? '',
    appKeyAuth: process.env.DATADOG_APP_KEY ?? '',
  },
};

const configuration = client.createConfiguration(configurationOpts);
const apiInstance = new v2.MetricsApi(configuration);

type Args = {
  pagePath: string;
  metricName: string;
  metricValue: number;
};

export const sendMetrics = async (args: Args) => {
  const params: v2.MetricsApiSubmitMetricsRequest = {
    body: {
      series: [
        {
          metric: `example.com.${args.metricName}.${args.pagePath}`,
          type: 0,
          points: [
            {
               timestamp: Math.round(new Date().getTime() / 1000),
               value: args.metricValue,
            },
          ],
        },
      ],
    },
  };
  const res = await apiInstance.submitMetrics(params);
  console.log(
    `Sent metric of ${args.metricName} on the page ${args.pagePath} successfully.`
  );
  console.log(JSON.stringify(res));
};

上記の処理によりDatadogへメトリクスを送信することができます!

ダッシュボード作成

次にDatadog側でダッシュボードを作成し、メトリクスを集計すればパフォーマンススコアが視覚的に確認できるようになります。

  1. Add WidgetからTimeseriesを選択
  2. 送信したメトリクス(example.com.${args.metricName}.${args.pagePath}の部分)を選択
  3. Add Queryで同じグラフ内に表示したいメトリクスを追加

Goodスコアを表示

Googleやweb.devから各メトリクスの良いスコアの基準が発表されています。以下の表はそれらの基準を「Goodスコア」としてまとめたものです。 一般的にはそれぞれの指標においてGoodスコアを上回る(数値的には下回る)と非常に良いと言えます。

指標 Goodスコア
CLS 0.1
LCP 2.5 s
INP 200 ms
FID(max-potential-fid) 130 ms
TTI 5 s
TBT 300 ms
TTFB 800 ms

今回はそれらを緑色のマーカーとして追加し、現状のメトリクススコアがどういう位置にいるのかを視認できるようにしました。

任意のマーカーはMarkers→Add Markerで追加できます。画像はLCPの例です。

GitHub Actionsで自動化

最後にこれらのフローをGitHub Actionsで定期自動実行できるようにしました。

npm scriptsに以下を追加します。実行ファイルへのパスは適宜書き換えてください。

# package.json

"lighthouse": "tsx path/to/lighthouse.ts"
# lighthouse.yml

name: Lighthouse Performance CI

on:
  schedule:
    - cron: '0 0 * * 1-5'

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 18
      - name: Install dependencies
        run: npm install
      - name: Lighthouse score
        run: npm run lighthouse
        timeout-minutes: 15
        env:
          LIGHTHOUSE_USERNAME: ${{ secrets.LIGHTHOUSE_USERNAME }}
          LIGHTHOUSE_PASSWORD: ${{ secrets.LIGHTHOUSE_PASSWORD }}
          DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
          DATADOG_APP_KEY: ${{ secrets.DATADOG_APP_KEY }}

平日の朝9時に毎日実行されるように設定しています。 またログインに失敗し、CIが回り続けるのを防ぐためにタイムアウトを入れています。

学び・今後の展望

学び

  • 改めてアクセシビリティの重要性に気づく

    ログインを自動化するときにセレクタを使って要素を指定するのですが、このときに適切な属性がないと要素を取得しにくいなと感じました。

  • Datadogについて知れた

    Datadogでヘルスチェックを行うということは今まであったのですが、自分で1からダッシュボードを作成、メトリクスを見やすいように工夫することはとても勉強になりました!今後はたとえば一定の値を超えるとアラートを出すようにするなどしてみたいと思いました。

  • tsxというライブラリを知った

    最初Lighthouseのプログラムを実行するときにts-nodeを使っていたのですが、チームの方にtsxを教えていただきこれを使うことにしました。特に設定など必要なく簡単にTypeScriptを実行できるのでとても便利でした!

  • Puppeteerでの自動ログインフロー

    今回初めてPuppeteerを使ってみてとても便利だと感じました。ログインなど基本的なブラウザの動作を自動化できるので今後も何かに使っていきたいなと思いました。

今後の展望

  • モニタリングして活用

    今後は可視化したデータを継続的にチームでモニタリングしパフォーマンス改善に役立てていきたいです。 僕のいるチームの場合、まずLCPに改善の余地がありそうなのでこれから改善計画をしていきたいと思います。

  • バンドルサイズなどを定期的に計測し、そちらも監視していきたい

    今回はLighthouseでのパフォーマンス計測のみ行いましたが、今後はバンドルサイズなども数値で継続的に記録していき改善に役立てたいと思いました。

参考

さいごに

マネーフォワード福岡拠点では、エンジニアを募集しています! プロダクトを一緒に盛り上げてくれる人を待っています!

以下のイベントもありますので興味のある方はぜひご参加ください!

福岡開発拠点のサイトもあるのでぜひみてください!


  1. First Input Delay (FID)は2024年3月にInteraction to Next Paint (INP)に変更されます
  2. 実際のユーザー環境ではなく、特定の条件下で収集されたデータです。詳細はラボ環境での測定をご覧ください。
  3. ヘッドレスChromeとは、GUIなしでChromeを操作できるモードのことです。詳細についてはGetting Started with Headless Chromeをご覧ください。