DataStudioとGASでWebPagetestの計測結果をグラフ化する

mediba Advent Calendar 24日目です。
フロントエンジニアの苅部からはGoogle Data StudioとWebPagetestについて書こうと思います。
medibaシステム本部ではWebPagetestやSitespeedを使って継続したパフォーマンス計測を実施しています。

具体的にはユーザー体験(体感速度)に影響を及ぼすCritical Rendering Pathに注視して、SpeedIndexとDomContentLoaded、FirstPaintの改善を進めています。

普段WebPagetestで計測をしている中で、テスト結果を時系列にグラフ化できたらいいなと思っていたのですが、ちょうど良いタイミングでData Studioが日本でもサービス開始されたので、今回はこれらのツールとGoogle Spread Sheet(以下Spread Sheet)とGoogle Apps Scriptと組み合わせる方法を考えてみました。

Google Data Studioとは

Google Data Studio(以下Data Studio)は今年Googleからリリースされたダッシュボードツールです。
Google Analytics(以下Analytics)やBig Query,Cloud SQL,AdWords,DoubleClick,Spread Sheet,MySQL,Youtube Analyticsなど様々なデータソースと連携が可能で、表現豊かなビジュアライズができるようになります。

Analyticsのカスタムレポートはプロパティごとに権限の発行が必要でしたが、Data Studioはメールアドレス(ドメイン)単位で共有できるため、社内でのレポート共有などで役立ちそうです。
私自身も、これまでAnalyticsからCSVエクスポートしてExcelで整えていた業務フローをData Studioで代替できないか検討しています。

操作感はGoogle Documentのように軽快で、ドラッグ&ドロップでグラフを配置していく事が可能です。

こんな形で

好きな位置に配置できます。
Analyticsのマイレポートと比べるとレイアウトの制限が減り、より共有・報告に適したレポート作成ができると思います。

今回はWebPagetestのテスト結果をSpread Sheetに蓄積し、それをデータソースとして、Data Studioでグラフ化していきます。

WebPagetestとは

WebPagetestはオープンソースで提供されているパフォーマンス向上のためのプロジェクトです。
もともとはAOLが社内向けに構築したツールですが、現在ではGoogleの支援を受けてサービスを継続しています。

過去に遡って複数日時でFilmstripで比較できたり

描画の様子を動画で比較したりすることができます。

ブラウザのAPIで取得できる数値とは違い、Filmstripでは視覚的な速さが把握できるようになります。
そのため「体感的な効果があったのか」といった疑問を検証することができます。

WebPagetestはREST APIが用意されているので、定期的にAPIコール(計測リクエスト)すればパフォーマンスの定点観測ができます。

今回は以下の値をData Studio側で指標として利用できるようにします。

  • VideoSpeedIndex
  • DomInteractive
  • TTFB(TimeToFirstByte)
  • FirstPaint
  • PageLoad(onLoad)

WebPagetestの詳しい使い方は、Google Chrome Developersの動画が参考になると思います(日本語字幕付きです^^)

Google Apps Scriptとは

Google Apps Script/GAS(以下Apps Script)はGoogleのプロダクトを横断して利用できるサーバサイドプログラミング環境です。
JavaScriptを使ってDocsやSheet、Formsを操作したり、Adsense,Analytics,Calendar,Drive,Gmail,Mapsと連携させたりする事ができます。

今回はApps ScriptをSpread Sheet、WebPagetest間の連携で利用します。

APIコールとデータ連携の流れ

全体の流れとしては以下のような形になります。

1) Apps Scriptの関数から、定期的にWebPagetestのAPIをコールします。
2) WebPagetestからはレスポンスとしてtestIdが返却されます。(テストリクエストはキューイングされ、ある程度遅延した上でテストが実行されます。)
3) Apps Scriptの関数から、testIdを元に定期的にWebPagetest側のテスト結果のJSONをコールし、レスポンスの中の任意の値をSpread Sheetのセルに書き込みます。
4) Data StudioがSpead Sheetのセルを参照し、グラフを描画します。

ついでにAnalyticsで収集している速度指標をData Studioに送り、RUMのデータとして確認できるようにしています。

1. WebPagetestを利用する準備

今回はパブリックインスタンスを使うので、手順に沿ってAPI Keyを取得します。

・WebPagetest - Get API Key

APIのリクエスト制限は200Pageload/Dayとなっています。
1つのURLでfirst view/repeat viewの2つを計測した場合は、"2Pageload"としてカウントされます。
さらに、そのテストをrunsオプションで10回実行したら”20 Pageload”となります。
あくまでAPIコール数ではなく、[テストの実行回数]制限になりますのでご注意ください。

今回は以下のようなパラメータを指定して計測をしています。

変数役割
url${URL}計測対象のURLを指定します。
k${API_KEY}API KEYを指定します。
video1録画を有効にします。
fJSONレスポンスのフォーマットをJSONとします。
mobile1Chromeによるmobileエミュレートになります。(UA文字列と解像度とviewportをエミュレート)
runs1テストの試行回数を指定します。複数回実行することで計測結果の数値を丸める事ができます。
fvonly11と指定することで、firstViewのみのテストになります。
locationec2-ap-northeast-1.3GFast計測地点をEC2の東京リージョン(ap-northeast-1)とし、回線速度のエミュレートを"3GFast"とします。
mobileDeviceiPhone5cmobile_devices.iniの中から任意で指定できます。
(対応するUA文字列やviewportがセットされます)

・RESTful APIs - WebPagetest Documentation
・mobile_devices.ini

2. Spread Sheetの準備

必要な指標をヘッダー行に入れ、計測対象ごとにシートを分ける形にしました。
標準偏差はSTDEV関数、平均値はAVERAGE関数、中央値はMEDIAN関数、相関係数(の二乗)はRSQ関数で算出する事ができます。
またTTEST関数を使うことでT検定でp値を算出することができますので、2組の集団(月次データなど)の平均の有意差を判断することもできます。

3. Apps Scriptの準備

関数/変数を以下の4つのgsファイルに分ける形で作りました。

  • sendTestRequest.gs
  • getDataByTestId.gs
  • util.gs
  • variable.gs

個々の変数,関数はスコープの中に入れなければグローバルになるため、他のgsファイルからそのまま利用する事ができます。
※突貫で作ったコードなので、Apps Script APIへの負荷の高い処理があるかもしません。。

・sendTestRequest.gs

WebPagetestにテスト実施のリクエストを投げ、レスポンスで返ってくるtestIdをセルに書き込みます。
Apps Scriptでは、UrlFetchApp.fetch()を使う事で簡単にHTTPリクエストが投げられます!
ペイロード付きのPOSTリクエストもできるので、いろいろと応用が効きそうです。

・Class UrlFetchApp | Apps Script | Google Developers

function sendTestRequest() {
  var sheetName,sheet,url,res,jsondata,testId,testIdLastRow;
  var activeSheet = SpreadsheetApp.getActiveSpreadsheet();  
  for(var i=0; i < targetObj.length; i++){
    sheetName = targetObj[i].sheetName;
    sheet = activeSheet.getSheetByName(sheetName);
    url = createTargetUrl(targetObj[i]);
    res = UrlFetchApp.fetch(url);  
    jsondata = JSON.parse(res.getContentText());
    testId = jsondata.data.testId
    testIdLastRow = sheet.getRange(testIdRow + (getLastRowNumberByColumn(sheet,1) + 1));
    testIdLastRow.setValue(testId);
  }
}

・getDataByTestId.gs

testIdを元に、WebPagetestのテスト結果のJSONを取得し、セルに書き込みます。

function getDataByTestId(){
  var sheetName,sheet,last_row,dateLastRow;
  var activeSheet = SpreadsheetApp.getActiveSpreadsheet();
  for(var i = 0; i < targetObj.length; i++){
    sheetName = targetObj[i].sheetName;
    sheet = activeSheet.getSheetByName(sheetName);
    lastRow = sheet.getLastRow();
    dateLastRow = getLastRowNumberByColumn(sheet,2) + 1;
    for(var j = dateLastRow; j <= lastRow ; j++){
      testId = sheet.getRange(testIdRow + j).getValue();
      if(testId != '' && testId != '0'){
        setTestValue(sheet, getValueByTestId(testId), j);
      }
    }
  }
  function getValueByTestId(testId) {
    var url = WPT_RESULT_URL + '?test=' + testId;
    var res = UrlFetchApp.fetch(url);  
    res = JSON.parse(res.getContentText());
    res = res.data;
    return res
  }
  function setTestValue(sheet,data,row){
    var targetRange,val;
    for (var prop in metricsObj) {
      targetRange = sheet.getRange(metricsObj[prop].header + row);
      val = getDescendantProp(data,metricsObj[prop].resValue);
      if(val){
        if (prop == 'date') {
          val = dateExchange(val);
        } else {
          val = Math.round(val);
        }
        targetRange.setValue(val);
      }
    }
  }
}

testIdを取得済みのシートに対してこの関数を実行すると、以下のような形で動作します。

・util.gs

その他の関数を宣言します。

function getDescendantProp(obj, desc) {
  var arr = desc.split('.');
  while(arr.length && (obj = obj[arr.shift()]));
  return obj;
}
function dateExchange(unixTimeStamp) {
  return Utilities.formatDate(new Date(unixTimeStamp * 1000), 'GMT+9', 'YYYYMMddHH')
}
function createRequestUrl(opt) {
  return WPT_TEST_URL +
    '?url=' + encodeURIComponent(opt.url) +
    '&k=' + WPT_REQUEST_PARAM.k +
    '&video=' + WPT_REQUEST_PARAM.video +
    '&f=' + WPT_REQUEST_PARAM.f +
    '&mobile=' + WPT_REQUEST_PARAM.mobile +
    '&runs=' + WPT_REQUEST_PARAM.runs +
    '&fvonly=' + WPT_REQUEST_PARAM.fvonly +
    '&location=' + WPT_REQUEST_PARAM.location +
    '&mobileDevice=' + WPT_REQUEST_PARAM.mobileDevice;
}
function getLastRowNumberByColumn(sheet, column){
  var last_row = sheet.getLastRow();
  for(var i = last_row; i >= 1; i--){
    if(sheet.getRange(i, column).getValue() != ''){
      return i;
      break;
    }
  }
}

・variable.gs

計測対象や計測指標などの変数を入れておきます。

var WPT_URL = 'https://www.webpagetest.org/';
var WPT_API_KEY = ${API_KEY};
var WPT_TEST_URL = WPT_URL + 'runtest.php';
var WPT_RESULT_URL = WPT_URL + 'jsonResult.php';
var WPT_REQUEST_PARAM = {
    k: WPT_API_KEY,
    video: 1,
    f: 'json',
    mobile: 1,
    runs: 1,
    fvonly: 1,
    location: 'ec2-ap-northeast-1.3GFast',
    mobileDevice: 'iPhone5c'
};
var testIdRow = 'A';
var targetObj = [
    {
        sheetName: 'mediba_top',
        url: 'http://www.mediba.jp/'
    },{
        sheetName: 'mediba_blog_top',
        url: 'http://ceblog.mediba.jp/'
    }
];

var metricsObj = {
  date:{
    header: 'B',
    resValue: 'completed'
  },
  firstViewSpeedindex:{
    header: 'C',
    resValue: 'average.firstView.SpeedIndex'
  },
  TTFB:{
    header: 'D',
    resValue: 'average.firstView.TTFB'
  },
  domInteractive:{
    header: 'E',
    resValue: 'average.firstView.domInteractive'
  },
  firstPaint:{
    header: 'F',
    resValue: 'average.firstView.firstPaint'
  },
  domContentLoadedEventStart:{
    header: 'G',
    resValue: 'average.firstView.domContentLoadedEventStart'
  }
}

トリガーの設定

以下の2つの関数は定期的に実行したいので、それぞれを時間主導型のトリガーで設定します。

  • テストリクエストを投げる関数(sendTestRequest)
  • テスト結果を取得する関数(getDataByTestId)

ここではテストリクエストを1時間に1回、テスト結果の取得を4時間に1回としています。

4. Data Studioの準備

一通り準備ができたので、あとはData StudioでSpread Sheetに蓄積されたデータを読み込むだけです。

データソースから[Google スプレッドシート]を選択し、任意のスプレッドシート、ワークシートを選択します。

Spread Sheetから取得する日付の値は、Data Studioでは[時間ディメンション]として扱いたいのでタイプを[日付 時]とします。
平均値の値が必要な場合は、既存のフィールドを複製した上で[集計方法]を[平均値]にします。

※ データソースを[Googleアナリティクス]で選択することでAnalyticsからのデータインポートも可能になります。

完成したレポート

1時間に1回のスパンでテスト実行した結果をレポートにしてみました。

Spread Sheet

・シート その1

ファーストビュー内でA/Bテストが稼働し、さらにネットワーク広告も存在するサービスのデータです。
SpeedIndexのヒストグラムが多峰性になり標準偏差も大きい事がわかります。

・シート その2

広告など、サードパーティースクリプトの影響が少ないサービスのデータです。
SpeedIndexの標準偏差が少なく、SpeedIndexとDomInteractive,FirstPaintとの相関係数は0.9を超えています。

・シート その3

サービスごとの数値を横断して見れるようにしています。

複数のサービスでSpeedIndexとFirstPaint,DomInteractiveの相関係数を確認したところ、そのほとんどが0.7を超えていました。(データの分布は単峰性)
クリティカルレンダリングパスと描画の関連性を考えると因果関係に近いと思いますが、データとしても強い正の相関があるという事が分かりました。

Spread Sheetには統計で利用できる関数や、図を展開する機能があらかじめ用意されているので、誰でも簡単に統計的な視点でデータを眺める事ができそうです。

Data Studio

Grafana風の色合いでレポートを作ってみました。
自由にデザイン変更が可能で、レイアウトのグリッドも綺麗に整います。

こんな形でWebPagetestのデータ(Synthetic)とGoogle Analyticsで収集しているデータ(RUM)が一覧表示ができるようになりました。
期間ツールを入れてるので、Analytics同様に任意の期間で絞り込みできるようになります。

残念ながらData Studioは、1つのグラフに複数のデータソースの指標を入れることができないみたいです。(2016年時点)
今回のようにWebPagetestとAnalyticsのデータソースがあった場合に、それぞれの指標を重ねる事ができません。

実現するためには、それぞれのデータを1つのSpread Sheetの1つのシートにまとめた上で、それをデータソースとして読み込むという形になります。
異なるデータソースで指標を計算する場合も同様です。
(例: [PV]を[特定のクリックイベント]で割り算した[CTR]の変化を折れ線グラフで追う、など)

そのため、Data Studioを利用する上ではSpread Sheetを活用する事が必要になるのかなと思いました。

おわりに

最初はData Studio寄りの記事を書こうと思っていたのですが、それ以外のプロダクトが面白くてついつい遊んでしまいました。。

Apps ScriptはSpread Sheet専用ではないので、Gmailなどの他アプリケーションと連携してみると面白いかもしれません。
たとえばA/Bテストを実施している場合に[t検定をApps Script側で実施し、有意差が出た場合に自動的にGmailで送信する]といった事もできるかもです。
また集計データが増えてくると辛くなりそうなのでデータはCloudSQL側で保持してもよさそうです。

今回作った仕組みで、ある程度参考になる集計が可能になりそうなので、今後は同業種のWEBサイトのベンチマークを標準化してパフォーマンス比較してみたいと思います。

・備考
API制限があるため試行回数を減らしていますが、信頼性の高い数値として扱うには回数が不足しており、またパフォーマンス計測としてのWebPageTestの信頼性も考慮する必要があるかと思います。
ただ、パフォーマンス計測に十分なコストをかけられないような状況も多いと思うので、まずは厳密さに執着せずできる事をやり、課題を抽出する事が大切なのかなと思っています。

参考URL