Help us understand the problem. What is going on with this article?

CloudFunctionsとBigQueryで行動ログデータ分析基盤を作る

WEB前提で書いてますが、アプリの分析基盤にBigQueryを使う場合はFirebase Analytics経由でBigQueryにInsertして、必要があればDataFlowとかでゴニョゴニョ加工したりするのが一般的っぽいです。

まず分析したいデータを洗い出す

サンプルで以下の2つのイベントを取るようにしてみたいと思います。

  1. ページビュー
  2. サインアップボタンのクリック

DBにデータが挿入されるまでの流れ

  1. アプリ上でイベント発火のトリガーが発生(eg. ボタンクリック、ページ遷移)
  2. CloudFunctionsエンドポイントに行動データをPOSTする
  3. CloudFunctionsからBigQueryにデータをPOSTする

次にテーブル定義

ページビュー

カラム名 詳細
id int ユニークID
createdAt timestamp レコード作成日
currentUrl string 現在のページのURL
sessionId string セッションID
city string 市区町村
os string 使ってるOS
refferUrl string リファラURLが入る(nullable)
initialRefferUrl string リファラのリファラ。(nullable)

サインアップボタンクリック

カラム名 詳細
id int ユニークID
createdAt timestamp レコード作成日
currentUrl string 現在のページのURL
sessionId string セッションID
city string 市区町村
os string 使ってるOS
refferUrl string リファラURLが入る(nullable)
initialRefferUrl string リファラのリファラ。(nullable)
elemId string ボタンが複数あって、識別したい場合のID

BigQueryの下準備

BigQueryのプロジェクトを作成し、pageViewsignupClickデータセットを作成しておきます。
データセットとイベントが1:1になっているという想定で、さらにテーブルは1ヶ月ごとに
名前に_yyyymmの形式でサフィックスが追加されて、新規追加されていく。という運用を想定して作ります。

例えば、pageViewという名前のデータセット配下のテーブルは
pageView_201812pageView_201901という感じで毎月作成されていく感じです。

下準備最後に、利用するBigQueryが所属しているGCPのプロジェクトのIDを控えておきます。

CloudFunctionsを作成

参考 https://cloud.google.com/nodejs/docs/reference/bigquery/1.3.x/

パラメータで
「テーブル名」「insertするデータ(jsonでエンコードしておく)」の2つを渡せば
対応するBigQueryデータセットに自動でデータをinsertするスクリプトを作りました。

function.js
/*
 * Parameters
 * type: テーブル名が入る
 * data: テーブルにinsertするデータ。json形式で送る。
 */

exports.sendEvent2BQ = (req, res) => {
    res.header('Access-Control-Allow-Origin', '*')
    res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
    res.header('Access-Control-Allow-Methods', 'POST,PUT,PATH,OPTIONS')

    let eventType = req.body.type || ""
    if(eventType.length == 0) {
        res.status = 400
        res.end("データタイプが設定されていません。")
    }

    const bq = require('gcloud')({ projectId: process.env.BQ_PROJECT_ID }).bigquery()

    let ds = null
    const datasetNames = ['page_view', 'signup_click']
    datasetNames.map(name => {
        if(eventType == name) {
            ds = bq.dataset(name)
        }
    })

    if(!ds) {
        res.status = 400
        res.end(`データタイプ'${eventType}'は不正です。`)
    }

    const targetTable = ds.table(TableUtil.getCurrentMonthTableName(eventType))

    targetTable.exists((err, exists) => {
        const insertData = JSON.parse(req.body.data)
        const insert = () => {
            TableOperator.insert2bq(targetTable, insertData, res)
        }
        if(!exists) {
            TableOperator.createTable(ds, res, eventType, insert)
        } else {
            insert()
        }
    })
}

class TableOperator {
    static schemes() {
        const commonParams = [
            {
                "type": "string",
                "mode": "required",
                "name": "id",
            },
            {
                "type": "timestamp",
                "mode": "required",
                "name": "createdAt",
            },
            {
                "type": "string",
                "name": "os",
            },
            {
                "type": "string",
                "name": "city",
            },
            {
                "type": "string",
                "name": "referrer",
            },
            {
                "type": "string",
                "name": "initialReferrer",
            },
            {
                "type": "string",
                "name": "sessionId",
            },
            {
                "type": "string",
                "name": "currentUrl",
            },
        ]
        return {
            pageView: {
                schema: {
                    "fields": [commonParams],
                },
            },
            signupClick: {
                schema: {
                    "fields": [
                        {
                            "type": "string",
                            "name": "elemId",
                        },
                    ].concat(commonParams),
                },
            },
        }
    }

    static createTable(ds, res, eventType, cb) {
        const tableScheme = this.getScheme(eventType)
        const tableName = TableUtil.getCurrentMonthTableName(eventType)
        if(!tableScheme) {
            res.status = 500
            res.end(`Table scheme was not found by name: ${eventType}`)
            return
        }
        ds.createTable(tableName, tableScheme, (err, table, apiResponse) => {
            if ( err ) {
                console.log('err: ', err)
                console.log('apiResponse: ', apiResponse)
                res.status = 500
                res.end(`TABLE CREATION FAILED: ${JSON.stringify(err)}`)
                return
            } else {
                console.log("table created")
                cb()
            }
        })
    }

    static getScheme(name) {
        const scm = this.schemes()
        // 本当は変数をそのまま使ってkeyを指定するのは良くない
        if(scm[name]) {
            return scm[name]
        }
        return null
    }

    static insert2bq(table, insertData, res) {
        const row = {
            insertId: (new Date()).getTime(),
            json: insertData
        }
        const options = {
            raw: true,
            skipInvalidRows: true,
        }
        table.insert(row, options, (err, insertErrors, apiResponse) => {
            if (err) {
                console.log('err: ', err)
                console.log('insertErr: ', insertErrors)
                console.log('apiResponse: ', JSON.stringify(apiResponse))
                res.status = 500
                res.end("FAILED:" + JSON.stringify(err) + JSON.stringify(insertErrors))
            } else {
                res.status = 200
                res.end(JSON.stringify(apiResponse))
            }
        })
    }
}

class TableUtil {
    static getSuffix() {
        let m = (new Date()).getMonth() + 1
        m = (m < 10) ? "0" + m : m
        return `${d.getFullYear()}${m}`
    }

    static getCurrentMonthTableName(name) {
        return `${name}_${this.getSuffix()}`
    }
}

次にCloudFunctoinsの作成ページに行って、言語はnodeを選択して新しく関数を作成しましょう。
そしてCloudFunctoins環境変数で
BQ_PROJECT_IDを追加して、値にBigQueryの下準備セクションで控えたGCPのIDを入力して保存しましょう。

最後に、関数のエンドポイントを取得します。
以下の手順でエンドポイントを取得し、控えておいてください。
関数を選択 > トリガー > URLをコピー。

Cloudfunctionsにリクエストを投げてみる

front-client.ts
import axios, { AxiosError, AxiosInstance } from "axios"
import * as rx from 'rxjs'
import * as uuid from 'uuid'

class CloudFunctionClient {
    private endpoint: string
    private axios: AxiosInstance
    private errorSbj = new rx.Subject<any>()
    private os: string | null = null
    private sessionId: string

    constructor(endpoint: string) {
        this.axios = axios.create()
        this.axios.interceptors.request.use(req => {
            if (this.authToken) {
                const header = {
                    // 本当は認証をつけたほうが良いですが、サンプルなのでつけていません
                    // Authorization: `Bearer: authToken`,
                    'Content-Type': 'application/x-www-form-urlencoded',
                }
                req.headers = Object.assign({}, req.headers, header)
            }
            return req
        })
        this.axios.interceptors.response.use(res => {
            return res
        }, (error) => {
            this.onError(error)
        })

        this.authToken = authToken
        this.endpoint = endpoint
        this.setFixedParameters()
    }

    private async sendBqEvent(eventName: string, data: Object): Promise<any> {
        let params = new URLSearchParams()
        params.append('type', eventName)
        params.append('data', JSON.stringify(data))

        const res = await this.axios.post<any>(this.endpoint, params)
        return res.data
    }

    private onError(error: AxiosError | Error): void {
        const e = error as AxiosError
        if (!e.response) {
            throw e
        }

        switch (e.response.status) {
            case 401:
                this.errorSbj.next()
                console.error('Unauthorized.')
                break
            default:
                console.error('An error occured', e)
        }
        throw e
    }

    /*------ トラッキング ------*/

    async trackView(sessionId: string) {
        const params = Object.assign({}, this.commonParameters, {
            sessionId: sessionId,
        })
        return await this.sendBqEvent('pageView', params)
    }

    async trackSignupClick(elemId: string) {
        const params = Object.assign({}, this.commonParameters, {
            elemId: elemId
        })
        return await this.sendBqEvent('signupClick', params)
    }

    /*------ プライベートメソッド ------*/
    private get commonParameters() {
        return {
            city: null,
            id: uuid.v4(),
            createdAt: this.formatTimestamp(),
            currentUrl: document.URL,
            initialReferrer: null,
            os: this.os,
            referrer: document.referrer,
        }
    }

    private get os() {
        const ua = navigator.useragent
        let os = ""
        // 正規表現でUserAgentを判別してOSを特定する処理
        return os
    }

    private formatTimestamp() {
        const d = new Date()
        const y = d.getFullYear()
        const mon = this.numPadding(d.getMonth() + 1)
        const date = this.numPadding(d.getDate())
        const h = this.numPadding(d.getHours())
        const min = this.numPadding(d.getMinutes())
        const s = this.numPadding(d.getSeconds())
        return `${y}-${mon}-${date} ${h}:${min}:${s}`
    }

    // jsデフォルトの@addingが使えないブラウザがあるので自前定義
    private numPadding(n: any) {
        return (n < 10) ? `0${n}` : n
    }
}

// 初期化
const endpoint = "「CloudFunctionsを作成」でメモったURL"
const cloudFunctionsClient = new CloudFunctionsClient(endpoint)

const sessionId = "セッションID"
const btnElemId = "ボタンのID"

// トラッキング処理
cloudFunctionsCliend.trackView(sessionId)
cloudFunctionsCliend.trackSignupClick(btnElemId)

以上です。
今回は、データセットを手動で作成していますがここら辺もAPIで自動化したりできそうです。
なんとなく全体のフローがわかってもらえたら嬉しいです。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away