スポーツ解説アプリ SpoLive における分析基盤の構築

本記事は、[NTTコミュニケーションズ Advent Calendar 2019](https://qiita.com/advent-calendar/2019/nttcom) 8日目の記事です。
昨日は、 @y-i さんの [社内ISUCONで優勝した時にやったこと](https://qiita.com/y-i/items/ab7d7ec0bed1d788e0be) でした。

## これは何

- スポーツ解説アプリ [SpoLive](https://spo.live/) における分析基盤構築についてのノウハウ共有
- サーバーレスで、要件に応じて柔軟に分析できる基盤を構築した話

## SpoLive について

[SpoLive](https://spo.live/) は、「スポーツファンとアスリートの距離を、デジタルの力で縮める」ことをビジョンに掲げた新サービスです。例えば試合中に、「なんで今のプレーが許されてるんだっけ？」「この選手どんな人だっけ？」といった気になることをすぐに解決できるのはもちろんのこと、より手軽に、より深く豊かな選手の情報に触れることができるようなアプリを目指して日々アップデートを重ねています。

本記事では、SpoLiveにおける分析基盤を構築したノウハウを述べます。構築の前提条件として下記がありました。

- データをビジネスサイドのメンバー(SQL を書いて分析できるメンバーがいる)が柔軟に分析できる基盤を用意したい
- 開発・運用コストはできるだけ抑えたい

### 分析基盤

SpoLive は、`Firebase` と `expo` で開発をしています。
基盤構築の方針として下記の方針が考えられますが、後述の理由で`2` を採用しました。

- 1. Firebase Analytics + BigQuery
- 2. Google Analytics + BigQuery
- 3. 別の分析ツール(Amplitude・Mix Panel 等)

理由: Firebase を利用していたら、 `Firebase Analytics` を利用することが主流だと思いますが、 `expo` において対応できるライブラリは（自分の知る限り）なく、別の分析ツールではなくまずは世の中の知見も多くシンプルな構成から入ろうと考えたためです。

Bigqueryは、データ分析にあたってはよく用いられているサービスで、SQLで複数データを統合して集計することも容易ですし、例えば、[Googleデータポータルを利用して可視化すること](https://cloud.google.com/bigquery/docs/visualize-data-studio?hl=ja) などができ、柔軟に分析ができます。



## 技術要素

前述の方針に基づき、分析基盤を構築するにあたり、下記の要素に分割できます。

```
アプリ =(1)=> Google Analytics =(2)=|
　　　　　　　　　　　　　 　　　　　　　|
　　　　　　　　　　　　Firebase  =(3)==> BigQyery
```


上記のうち、(2)(3)のGoogle Analytics/FirebaseからBigqueryへ転送するタスクは、Node.jsで実装し、CloudFunctions の 定期ジョブ としてデプロイします。

理由として、BigQuery のNode.js SDKが世の中に知見が溜まっていそうな点、expo(ReactNative)と技術要素をjsで揃えることによる、メンバーのメンテしやすさの点がありました。また、後述する通り、CloudFunctionsの定期ジョブは、コードだけでスケジューリング可能で、コンソールで手動で設定・・といった必要がなく、変更管理のしやすさを感じています。


### (1)アプリ から Google Analytics

ライブラリ `expo-analytics` を利用して、アプリから GoogleNAalytics へアプリの利用状況に関するデータを収集します。


```js 
// analitics.js

import { Analytics as GoogleAnalytics, ScreenHit, Event } from "expo-analytics";

class Analytics {
  constructor(code = null) {
    this.ga = null;
    this.code = code;
  }

  init = () => {
      this.ga = new GoogleAnalytics(this.code);
  };

  EventHit = (category = null, action = null, label = null, value = 0) => {
    if (category && action) {
      const params = [category, action];
      if (label) {
        params[2] = label;

        if (value >= 0) {
          params[3] = value;
        }
      }
      this.ga.event(new Event(...params));
    }
  };
}

export default new Analytics(gaId);
```


```js 
// someview.js

import Analytics from "app/src/libs/analytics";

 Analytics.EventHit(
      "categoryName",
      "actionName",
      "labelName",
    );

```

### 転送タスクのデプロイ・自動起動

前述のとおり、(2)(3)のGoogle Analytics/FirebaseからBigqueryへ転送するタスクは、Node.jsで実装し、CloudFunctions の 定期ジョブ としてデプロイします。

```
# ディレクトリ構成
functions/src/index.ts
           |    
           |--- modules/
                 |    
                 |--- transferBigquery.ts
```


```Node.js


import * as functions from "firebase-functions";
import { transferBigquery } from "./modules/transferBigquery";


// bigqueryへの定期エクスポート
module.exports.transferBigquery = functions
  .region("asia-northeast1")
  .runWith({
    timeoutSeconds: 9 * 60, // max: 9min
    memory: "1GB"
  })
  .pubsub.schedule("0 4 * * *")
  .timeZone("Asia/Tokyo")
  .onRun(transferBigquery);

```


### (2)Google Analytics から BigQuery

GAからのデータ取得には、GoogleAPIs の [Node.js向け SDK](https://googleapis.dev/nodejs/googleapis/latest/analyticsreporting/index.html)を利用します。
(3)とあわせて、一つのFunctionsとして実装します。

API利用時の認証情報は、[Functionsの環境変数](https://firebase.google.com/docs/functions/config-env?hl=ja)として保存しますが、ローカルでの開発時も functionsディレクトリで、 `firebase functions:config:get > .runtimeconfig.json` としておくと、ローカルでも環境変数をjsonファイルの値から利用できます。

Functionsのメイン処理は下記のイメージです

```Node.js

// transferBigquery.ts

import firebase from "./firebase-admin";
import moment from "moment";

import { Analytics } from "../libs/googleAnalytics";
import { BigQuery } from "@google-cloud/bigquery";
import { Storage } from "@google-cloud/storage";

// main関数で、class初期化・メソッド実行
export const transferBigquery = async () => {
  const projectId = process.env.GCLOUD_PROJECT;
  if (!projectId) {
    console.error("projectId is invalid", projectId);
    return;
  }

  // firestoreの環境変数としてGAの認証情報を保存
  const { client_email, private_key, view_id } = functions.config().googleanalytics
  const ga = new Analytics(client_email, private_key, view_id);
  const tb = new TransferBigquery(projectId) // 後述

  // GAのデータは過去1日分を入れ替え
  const start: Moment = moment().add(-1, "days")
  const end: Moment = moment().add(-1, "days")
  await tb._delete_eventdata_from_bigquery(start,end)
  await tb._export_eventdata_to_storage(start, end) // ここが本節で説明したい部分
  await tb._save_eventdata_to_bigquery()

  //firebaseの試合情報は、全件更新
  await tb._export_gamedata_to_storage(); // 後述
  await tb._save_gamedata_to_bigquery();  // 後述
};


export class TransferBigquery {

  private projectId: string
  private suffix: string

  constructor(projectId: string) {
    this.projectId = projectId
  }

  async _export_eventdata_to_storage(start: Moment, end: Moment) {
    const eventData = await this.analytics._eventToJson(start, end) // GAからイベントデータを取得
    return await this._exportJSON(eventData.join("\n"), `events.json`); //CloudStorageへ保存(後述)
  }
  // ...省略...

}



```

GAのイベントデータを取得する処理は下記のとおり

```Node.js

// libs/googleAnalytics.ts

import { google } from "googleapis";
import { Moment } from "moment";

export const Analytics = class {

  private jwtClient: any;
  private analytics: any;
  private viewId: string;

  constructor(clientEmail: string, privateKey: string, viewId: string) {
    this.jwtClient = new google.auth.JWT(
      clientEmail,
      undefined,
      privateKey,
      ["https://www.googleapis.com/auth/analytics.readonly"],
      undefined,
    );
    this.analytics = google.analytics("v3");
    this.viewId = viewId;
  }

  // event以外の情報を取得したい場合は、別のパラメータを指定する
  // - Dimensions & Metrics Explorer: https://ga-dev-tools.appspot.com/dimensions-metrics-explorer/
  _event_param(date: Moment) {
    const params: any = {
      "start-date": date.format("YYYY-MM-DD"),
      "end-date": date.format("YYYY-MM-DD"),
      metrics: "ga:uniqueEvents",
      dimensions:
        "ga:eventCategory,ga:eventAction,ga:eventLabel",
      sort: "ga:eventCategory"
    };
    return params
  }

  // 指定期間のデータを取得しJSONを生成
  async _eventToJson(start: Moment, end: Moment) {
    const eventJson = []
    while (start.diff(end) <= 0) {
      const eventData = await this._fetch_report_data(this._event_param(start));
      if (eventData == undefined) {
        start.add(1, "days");
        continue
      }
      console.log(`add gaEvent: ${start.format("YYYY-MM-DD")}`)
      for (const e of eventData) {
        eventJson.push(JSON.stringify({
          date: start.format("YYYY-MM-DD"),
          eventCategory: e[0],
          eventAction: e[1],
          eventLabel: e[2],
          uniqueEvents: e[3],
        }))
      }
      start.add(1, "days");
    }
    return eventJson
  }

  // GAからデータ取得
  async _fetch_report_data(params: any): Promise<any[]> {
    params.auth = this.jwtClient;
    params.ids = `ga:${this.viewId}`;
    return new Promise((resolve, reject) => {
      this.jwtClient.authorize((err: any, tokens: any) => {
        if (err) {
          reject(err);
        }
        this.analytics.data.ga.get(params, (err: Error, resp: any) => {
          if (err) {
            reject(err);
          }
          resolve(resp.data.rows);
        })
      });
    });
  }
};

```


### (3)Firebase から BigQuery

Bigqurey の Node.js向け SDK `google-cloud/bigquery` を利用します。
Bigqureyへのデータインポートには、CloudStorageにあるファイルから一括インポートする方法と、 [一度に 1 レコードずつ BigQuery にデータをストリーミング処理でインポートする方法](https://cloud.google.com/bigquery/streaming-data-into-bigquery?hl=ja)がありますが、公式ドキュメントにもありますがコスト的には [ストリーミング処理は必要がなければ避けるべき](https://cloud.google.com/bigquery/docs/best-practices-costs?hl=ja#use_streaming_inserts_with_caution)です。

上記の理由から、データを改行区切り JSON にしてCloudStorageへ出力し、Bigqueryへインポートさせます。



```Node.js

// transferBigquery.ts

  // // 前述のメイン処理抜粋
  // // firebaseの試合情報は、全件更新
  // await tb._export_gamedata_to_storage();
  // await tb._save_gamedata_to_bigquery(); 

export class TransferBigquery {

  private projectId: string
  private analytics: any
  private location: string

  constructor(projectId: string, analytics: any) {
    this.projectId = projectId
    this.analytics = analytics
    this.location = 'US' //Bigquery>datasetのlocationを変更した場合はここも変更する
  }

  async _export_gamedata_to_storage(){
    const gameData = await firebase
     .firestore()
     .collection(`games`)
     .get()
     .then(querySnapshot => {
       const returnData: string[] = [];
       querySnapshot.forEach(doc => {
         const {
           team_homeName = null,
           team_awayName = null
         } = doc.data();

         // BigQueryがサポートしている改行区切りJSONを出力するために、ここで整形
         returnData.push(
           JSON.stringify({
             gameId: doc.id,
             team_homeName,
             team_awayName
           })
         );
       });
       return returnData;
    });
    return await this._exportJSON(gameData.join("\n"), "games.json");
  };

  // Cloud Storageへの保存
  async _exportJSON(jsonText: string, filename: string) {
    const storage = new Storage();
    const bucket = storage.bucket(`${this.projectId}.some_storage.com`);
    bucket.file(`somedir/${filename}`).save(jsonText, err => {
      if (!err) {
        bucket.file(`somedir/${filename}`).setMetadata(
          {
            metadata: {
              contentType: "application/json"
            }
          },
          (err: any, apiResponse: any) => {
            if (err) {
              console.log("err", err);
            } else {
              console.log(`finish saving ${JSON.stringify(apiResponse)}`);
            }
          }
        );
      } else {
        console.log("fail saving", err);
      }
    });
  };

  // Clound Storage からBigqueryへインポート
  async _save_gamedata_to_bigquery() {
    const schema: any = {
      fields: [
        { name: "gameId", type: "STRING", mode: "NULLABLE" },
        { name: "team_homeName", type: "STRING", mode: "NULLABLE" },
        { name: "team_awayName", type: "STRING", mode: "NULLABLE" }
      ]
    };
    return await this._saveBigquery({
      bqSchema: schema,
      tabeleName: "games",
      filename: `games.json`,
      isAppendMode: false,
    });
  };

  _loadStorageFile(filename: string) {
    const storage = new Storage();
    const bucket = storage.bucket(`${this.projectId}.some_storage.com`);
    return bucket.file(`somedir/${filename}`);
  };

  async  _saveBigquery(params: BigqueryParam) {
    const { bqSchema, tabeleName, filename, isAppendMode } = params
    const datasetId = "some_dataset";
    if (!this.projectId) {
      console.error("projectId is invalid", this.projectId);
      return;
    }
    const bigquery = new BigQuery({ projectId: this.projectId });
    const table = bigquery.dataset(datasetId).table(tabeleName);

    // https://cloud.google.com/bigquery/docs/reference/auditlogs/rest/Shared.Types/WriteDisposition
    const importMode: string = isAppendMode ? "WRITE_APPEND" : "WRITE_TRUNCATE";
    const metadata: any = {
      sourceFormat: "NEWLINE_DELIMITED_JSON",
      schema: bqSchema,
      // Set the write disposition to overwrite existing table data.
      writeDisposition: importMode,
    };

    table.load(this._loadStorageFile(filename), metadata, (err, apiResponse) => {
      if (err) {
        console.log("err", err);
      } else {
        console.log(`finish saving: ${JSON.stringify(apiResponse)}`);
      }
      return;
    });
  };

}
```
## 最後に

本記事では、Firebase / Cloud Functins / BigQuery などのクラウド基盤上に、サーバーレスでユーザー分析基盤を構築するノウハウについて述べました。

スポーツ解説アプリ[SpoLive](https://spo.live/)では、これからも利用者の方により楽しくスポーツを楽しんでいただけるアプリを目指して日々改善中です。今現在、ラグビーやサッカーの試合がお楽しみいただける他、今後も対応スポーツを拡大していく予定です。ぜひご利用ください。

明日は、 @tetrapod117 さんの記事です。