Google App Script(GAS)でGoogle Analyticsレポートからjsエラーを自動でSlack通知してみる

サーバー側のエラー検知をするケースはよく見かけますが、

SPA(Single Page Application)で構成されたWEBサイトでは、jsのエラー検知は非常に重要かと思います。

昨今ではSPAはパフォーマンス面でも大きなメリットがあり、大きく主流となってくると思われるため

無料でサクッとエラー監視したい…と思った時に頼るといいのがGoogle Analyticsです。

使用する技術

概要

基本的な構成として下記のようになります。

  1. Google App ScriptにてReporting APIを利用しAnalyticsからエラー情報を取得
  2. 取得したエラー情報をGoogle Spreadsheetに保存
  3. Google Spreadsheetより前回のエラー情報を取得
  4. 前回のアラート閾値が達していない場合(エラー通知していない場合)、エラーの閾値に達したらアラート通知

今回アラートの通知先はSlackを利用していますが、Chatworkやその他の物でも構いません。

なぜGoogle Appを利用するのか、、、というとGoogleのサービスだけあって導入の手間がほぼいらない大きなメリットがあります。

Google Analyticsでの例外エラーの検知

まずはデータ収集をしなければ意味がありませんね。

基本的な導入方法はGoogle Analyticsの初期設定がわかりやすいので割愛します。

例外が発生した際に、googleが用意している例外を投げればいいのでwindow.onerrorなどで例外が発生した際に下記のようにエラーイベントを送るだけなのでとても簡単です。

Angulerjsなどフレームワークなどによってはonerrorで取得できない場合がありますので注意してください。

window.onerror = function(message){
   gtag('event', 'exception', {
     'description': message,
     'fatal': false   // set to true if the error is fatal
   });
};

Google Spredsheet + Google App Scriptを作成

まずはGoogle Spreadsheetより新しいファイルを作成します。

メニューの「ツール」->「スクリプト エディタ」を選択しApp Scriptを作成します。

ここで注意点なのが、ここから作らないとSpreadsheetと関連したContainer Bound ScriptというApp Scriptが作れないのでご注意ください。

App Scriptの設定と実行

今回2ファイルを作成します。

左側メニューより下記ファイルをそれぞれ作成し、main.gsを書き換えてください

main.gs

function execute(){
  // 設定したいビューIDを指定
  var viewId = '***';
  // アラート閾値
  var alertRate = 0.5;
  // slack APIの設定
  var slackPostUrl = 'https://hooks.slack.com/services/T*****';
  var slackUsername = 'My First Bot';
  var slackIcon = ':+1:';
  //-----------------------------------
  // 実行
  execute_analytics_cron(viewId, alertCount, {
    slackPostUrl: slackPostUrl,
    slackUsername: slackUsername,
    slackIcon: slackIcon,
  });
}

library.gs

/**
 * Reporting APIを叩き、閾値を超えていれば通知
 *
 * @param {Array} profileId
 * @param {number} alertRate
 * @param {Array} options
 */
function execute_analytics_cron(profileId, alertRate, options) {
    var startRow = 1;
    var startCol = 1;
    var mitinue = 60;
    var postUrl = options.slackPostUrl || null;
    var username = options.slackUsername || null;
    var icon = options.slackIcon || null;
    if(!postUrl || !username || !icon){
        console.error('unset slackPostUrl or slackUsername or slackIcon.');
        return false;
    }

    // spredsheetにデータを格納し、2重複で送られないようにする
    var SpreadsheetObject = SpreadsheetApp.getActive();
    var sheet_name = profileId;
    // シート情報取得
    var sheet = SpreadsheetObject.getSheetByName(sheet_name);
    if(!sheet){
        SpreadsheetObject.insertSheet(sheet_name);
        sheet =  SpreadsheetObject.getSheetByName(sheet_name);
    }
    if(!sheet){
        throw new Error('シートが見つかりません。'+sheet_name);
    }
    var today = new Date();
    var currentDate = Utilities.formatDate(today, 'JST', 'yyyy-MM-dd');

    // 前日の場合現在のシートをクリアする
    cleanSheet(sheet, currentDate, startRow, startCol);

    // シート情報から過去のエラー一覧を取得する
    var currentRow = getLastRow(sheet, startRow, startCol);
    var currentCol = startCol;

    var reportRate = getErrorRealtimeEventReportRate(profileId, mitinue);
    console.log('total event rate: '+reportRate+' profileId: '+profileId+'.');

    var time = Utilities.formatDate(today, 'JST', 'HH:mm');
    sheet.getRange(currentRow, currentCol, 1, 1).setValue(time);
    sheet.getRange(currentRow, currentCol + 1, 1, 1).setValue(reportRate);
    console.log('save sheet '+sheet_name+' in ('+currentRow+', '+currentCol+') value ('+time+', '+reportRate+').');

    if(alertRate <= reportRate){
        // 前回の閾値が超えていれば処理しない
        if(currentRow > startRow){
            var lastSum = sheet.getRange(currentRow - 1, currentCol + 1).getValue();
            if(lastSum >= alertRate){
                console.log('skip alertChartworkSend.');
                continue;
            }
        }
        var message = '[info][title]Google App Scriptより'+currentDate+'の閾値を超えました('+String(reportRate)+'/'+String(alertRate)+' per '+mitinue+' minitue)' + "[/title]";
        message += "詳細はGoogle Analyticsを確認してください。" + "\n";
        message += "eventCategory = error" + "\n";
        message += "https://analytics.google.com/[/info]";

        if(alertSlackSend(postUrl, username, icon, message)){
            console.log('send message alertSend.');
        }
    }
}

/**
 * 当日でなければデータをクリアする
 *
 * @param {Sheet} sheet
 * @param {string} today
 * @param {int} row
 * @param {int} col
 */
function cleanSheet(sheet, today, row, col){
    var dateCell = sheet.getRange(row, col);
    var date = dateCell.getDisplayValue();
    if(date === today){
        return false;
    }
    dateCell.setValue(today);
    var rowNum = getLastRow(sheet, row, col) - row + 1;
    if(rowNum < 1){
        return false;
    }
    sheet.getRange(row + 1, col, rowNum, 2).setValue('');
    return true;
}

/**
 * 最終列を取得する(データが存在するかチェック)
 *
 * @param {Sheet} sheet
 * @param {int} row
 * @param {int} col
 * @return {int}
 */
function getLastRow(sheet, row, col){
    var lastRow = sheet.getRange(row, col).getNextDataCell(SpreadsheetApp.Direction.DOWN).getRow();
    var lastRowCell = sheet.getRange(lastRow, col).getValue();
    var currentRow = row + 1;
    // データが存在していなければ初めの列から挿入する
    if(lastRowCell){
        currentRow = lastRow + 1;
    }
    return currentRow;
}

/**
 * Google Analyticsよりエラーレポート情報を取得
 * NOTE: Realtimeを使えばより絞った時間で取得できる(ずっとbeta版)
 * NOTE: リアルタイムレポートを利用していないので擬似的に取得(取得分)
 *
 * @param {string} profileId
 * @param {int} mitinue
 * @return {boolean}
 */
function getErrorRealtimeEventReportRate(profileId, mitinue) {
    var timezone = 'JST';
    var today = new Date();
    var date = Utilities.formatDate(today, timezone, 'yyyy-MM-dd');
    var tableId = 'ga:' + profileId;

    console.log('Reporting API request.');

    // 合計PV数を取得
    var totalReport = Analytics.Data.Ga.get(tableId, date, date, 'ga:pageviews', {
        'dimensions': 'ga:dateHourMinute',
        'max-results': mitinue,
        'sort': '-ga:dateHourMinute'
    });
    if(totalReport.rows === undefined){
        return 0;
    }
    var totalPageviewDatas = convertReportToArray(totalReport);
    var totalPageviews = 0;
    var minTime = -1;
    var maxTime = 0;
    for(var k in totalPageviewDatas){
        var item = totalPageviewDatas[k];
        totalPageviews += item['ga:pageviews'];
        if(minTime < 0 || minTime > item['ga:dateHourMinute']){
            minTime = item['ga:dateHourMinute'];
        }
        if(maxTime < 0 || maxTime < item['ga:dateHourMinute']){
            maxTime = item['ga:dateHourMinute'];
        }
    }
    console.log('total Pageviews: ' + totalPageviews + ' ' + minTime + '~' + maxTime);


    var report = Analytics.Data.Ga.get(tableId, date, date, 'ga:totalEvents', {
        'filters': 'ga:eventCategory=~exception',
        'dimensions': 'ga:dateHourMinute',
        'max-results': mitinue,
        'sort': '-ga:dateHourMinute'
    });
    //var report = Analytics.Data.Realtime.get(tableId, metric, options);
    if(report.rows === undefined){
        return 0;
    }
    var totalEventDatas = convertReportToArray(report);
    var totalEvents = 0;
    for(var k in totalEventDatas){
        var item = totalEventDatas[k];
        var t = item['ga:dateHourMinute'];
        if(minTime <= t && t <= maxTime){
            totalEvents += item['ga:totalEvents'];
        }
    }
    console.log('total Events: ' + totalEvents);

    return (totalEvents > 0 && totalPageviews > 0) ? (totalEvents / totalPageviews) : 0;
}

/**
 * レポートデータの合計数を取得
 * @param {string} key
 * @param {number} report
 */
function getReportTotalCount(key, report){
    var total = 0;
    var items = convertReportToArray(report);
    for(var k in items){
        var item = items[k];
        total += item[key];
    }
    return total;
}

/**
 * レポートデータを整形
 * @param {object} report
 */
function convertReportToArray(report){
    if(report.rows === undefined){
        return [];
    }
    var items = [];
    var header = report.columnHeaders;
    for(var k in report.rows){
        var data = report.rows[k];
        var item = {};
        for(var i=0;i<header.length;i++){
            var v = data[i];
            if(header[i].dataType === 'INTEGER'){
                v = parseInt(v);
            }
            item[header[i].name] = v;
        }
        items.push(item);
    }
    return items;
}

/**
 * Chatworkへ通知する
 * @param {string} roomId
 * @param {string} apikey
 * @param {string} message
 * @return {boolean}
 */
function alertChartworkSend(roomId, apikey, message) {
    const WebhookUrl = 'https://api.chatwork.com/v2/rooms/'+roomId+'/messages';

    var payload = 'body='+message;
    var response = UrlFetchApp.fetch(WebhookUrl, {
        method: "POST",
        headers: {
            'X-ChatWorkToken': apikey
        },
        payload: payload
    });
    var responseCode = response.getResponseCode();

    if (responseCode === 200) {
        console.log('success chartwork api request.');
        return true;
    }
    console.error('error chatwork api request status code: '+responseCode);
    return false;
}

/**
 * Slackへ通知する
 *
 * @param {string} postUrl
 * @param {string} username
 * @param {string} icon
 * @param {string} message
 * @return {boolean}
 */
function alertSlackSend(postUrl, username, icon, message) {
    var payload = JSON.stringify({
        "username" : username,
        "icon_emoji": icon,
        "text" : message
    });
    var response = UrlFetchApp.fetch(postUrl, {
        "method" : "post",
        "contentType" : "application/json",
        "payload" : payload
    });
    var responseCode = response.getResponseCode();

    if (responseCode === 200) {
        console.log('success Slack api request.');
        return true;
    }
    console.error('error Slack api request status code: '+responseCode);
    return false;
}

設定は下記を参考に設定します。

viewIds: 検知したいアナリティクスのビューID(管理->ビューの設定)

main.gsの設定を行い実行するとログがStackdriverに保存されます。

ちなみにconsole.log()の代わりにLogger.log()をつかうとStackdriverを利用しなくなりますが、

実行タスクのデバッグログには出てこなくなります。

トリガーの設定

GASのメニューよりトリガー設定画面へ移動し設定してください。

下記を参考に時間で定期的に実行設定するだけ。

イベントのソース: 時間主導型
時間ベースのトリガーのタイプ: お任せですが、10分程度がいいかと思います。

改良点

Googleでのエラー取得はReporting APIよりもRealtime APIを利用した方がよりリアルタイムに情報収集ができます。

Beta版のため、こちらより申請すれば使えるらしいです。

ちなみにclaspを利用すればクライアントから直接ソースコードをアップロードすることができます。

注意点

ここまで紹介したものについては無料枠の範囲が決められているため、各サービスの利用上限数をよく確認の上ご利用ください。

下記一部ですが、参考値となります.(2019年現在のものです)

Google App Script

https://developers.google.com/apps-script/guides/services/quotas

URL Fetch calls 20,000 / day
Script runtime 6 min / execution
Triggers total runtime 90 min / day

Google reporting API

https://developers.google.com/analytics/devguides/reporting/core/v3/limits-quotas?hl=ja

プロジェクトごとの 1 日あたりのリクエスト数は 50,000 件
IP アドレスごとの 1 秒あたりのクエリ数(QPS)は 10 件。

Google Analytics

https://developers.google.com/analytics/devguides/collection/analyticsjs/limits-quotas?hl=ja

プロパティあたり 1 か月 1,000 万ヒット

Stackdriver

https://cloud.google.com/free/?hl=ja

50 GB のログを 30 日間保存

また、Analyticsのイベントの各パラメータ文字列長さは下記のようになっております。

eventCategory: 150 バイト
eventAction: 500 バイト
eventLabel: 500 バイト

参考: https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters

あと個人情報の取り扱いには十分注意しましょうね。

https://support.google.com/analytics/answer/7686480

コメントを残す

メールアドレスが公開されることはありません。