データドリブンな開発をめざして 〜モバイル用分析基盤を整備した話〜

モバイルアプリチームの工藤です。

ANDPADではこれまでユーザの声や思い描くビジョンを元にアプリの開発を進めてきましたが、徐々に機能変更や改善に関する仕事も増えてきています。現在のユーザの状況を意識して議論するシチュエーションも多々あるため、PdMより直々に早急にデータ分析基盤を用意せよとのお達しがありました。

まだまだ粗い部分もありますが、先日一区切りついたのでその対応内容をまとめようと思います

全体の方針を決める

今回の対応で最も意識したのは、複雑なことはしないで専任の担当者以外でも運用・維持ができるようにするという点です。弊社はスタートアップ企業であり、データ分析が根付いている他の企業に比べてまだまだエンジニアは多くありません。明確にアプリの分析担当者が居るわけではなく、職種に関わらずこのデータに触れる可能性があります。

そのため、将来的に突発的な運用作業や謎ルールに苦しめられることを避けられるよう気をつけながら各種設定を行いました。

大まかなデータの流れ

f:id:oct_k_kudo:20200605173304p:plain:w300

  1. アプリからユーザアクションに応じたログを送信
  2. ログデータはBigQueryに格納されるので、ROWデータをRedashで扱いやすいように加工する
  3. 作成した参照用テーブルからデータを取得し、集計してアウトプットする
  4. Slackでメンバーに更新通知する

現状の設計とドキュメント作成

開発対象の案件が確定した段階でPdMと分析要件について相談し、アウトプットのすり合わせをします。テキストで表現しづらいニュアンスなどは、適宜手書きでイメージしているグラフを書いたりしています。

実際に送るログはどの画面のどの操作なのか?を一意に特定できるようにRDBをイメージして探り探り設計していますが、データサイエンティストに相談しながら改善していきたいなと思っているところです。

設計した内容はGoogleスプレットシートにまとめていて、Confluenceにリンクを記載しています。設計書としてバージョン管理する事も考えたのですが、GItHubアカウントを持たない非エンジニアが分析することも踏まえてアクセスのしやすさを優先しました。今はまだ情報量が少ないおかげで成立している部分もあるので、将来的に運用が軌道に乗ってきた段階でVCSを利用することも検討したいです。1

アプリでログを送信する

アプリ用のログ収集ツールいくつか検討しましたが、以下の理由からGoogleAnalyticsを利用することにしました

  • Crashlyticsなどで既にFirebaseを利用している
  • Cloud MessagingやPredictionsなど、連携できる・してみたいサービスが提供されている
  • 検討したどのサービスも導入コストはそれほど変わらなかった

参考として、アプリの案件一覧画面でどの案件がタップされているか?を調査するために実装した内容を一部載せてみます。 少し長いので、興味のある方は展開してみてください。

f:id:oct_k_kudo:20200616155843p:plain:w200
iOSアプリのトップ画面

iOSの実装

FirebaseAnalyticsのラッパークラス

class AndpadAnalytics {
    private init() {}
    static let shared = AndpadAnalytics()
    
    func setUser(id: Int64){
        Analytics.setUserID(String(user.id))
    }

    func sendLogEvent(event: AndpadAnalyticsEvent) {
        let parameters = event.parameters?
            .compactMapValues({$0})
            .mapValues({"\($0)"})
        Analytics.logEvent(event.eventName, parameters: parameters)
    }
}

enum AndpadAnalyticsEvent {
    case selectContent(screenName: String, contentType: String, contentId: Int64? = nil)
    
    var eventName: String {
        switch self {
        case .selectContent: return FirebaseAnalytics.AnalyticsEventSelectContent
        }
    }
        
    var parameters: [String : Any?]? {
        switch self {
        case .selectContent(let screenName, let contentType, let contentId):
            return [
                AndpadAnalyticsConstants.Key.screenName: screenName,
                FirebaseAnalytics.AnalyticsParameterContentType: contentType,
                AndpadAnalyticsConstants.Key.contentId: contentId,
            ]
        }
    }
}

struct AndpadAnalyticsConstants {
    struct Key {
        static let screenName = "screen_name"
        static let viewName = "view_name"
        static let contentId = "content_id"
    }

    struct ScreenName {
        static let 案件一覧画面 = "案件一覧画面"
    }
    
    struct ContentType {
        static let orders = "orders"
    }
}

イベント発火時のコード

button.rx.tap
    .subscribeOn(ConcurrentDispatchQueueScheduler(qos: .background))
    .observeOn(MainScheduler.instance)
    .subscribe(onNext: { [weak self] in
        AndpadAnalytics.shared.sendLogEvent(event: AndpadAnalyticsEvent.selectContent(
            screenName: AndpadAnalyticsConstants.ScreenName.案件一覧画面,
            contentType: AndpadAnalyticsConstants.ContentType.orders,
            contentId: order.id
        ))
    })
    .disposed(by: disposeBag)

Androidの実装

FirebaseAnalyticsのラッパークラス

object AndpadAnalytics {
    private lateinit var firebaseAnalytics: FirebaseAnalytics

    // Applicationクラスで初期化しています
    fun initialize(context: Context) {
        firebaseAnalytics = FirebaseAnalytics.getInstance(context)
    }

    fun setProfile(id: Int) {
        firebaseAnalytics.setUserId(id.toString())
    }

    fun sendEvent(event: AndpadAnalyticsEvent) {
        val bundleString = event.data.keySet().joinToString("\n") { "$it: ${event.data.get(it)}" }
        Timber.d("""
event: ${event.eventName}
bundle:
$bundleString
        """)
        firebaseAnalytics.logEvent(event.eventName, event.data)
    }
}

sealed class AndpadAnalyticsEvent {
    abstract val eventName: String
    abstract val data: Bundle

    data class SelectContent(
            val screenName: String,
            val contentType: String,
            val contentId: Long? = null,
    ) : AndpadAnalyticsEvent() {
        override val eventName = FirebaseAnalytics.Event.SELECT_CONTENT
        override val data
            get() = Bundle().also {
                it.putString(AndpadAnalyticsConstants.Key.screenName, screenName)
                it.putString(FirebaseAnalytics.Param.CONTENT_TYPE, contentType)
                it.putString(AndpadAnalyticsConstants.Key.contentId, contentId.toString())
            }
    }
}

object AndpadAnalyticsConstants {
    object Key {
        const val screenName = "screen_name"
        const val viewName = "view_name"
        const val contentId = "content_id"
    }

    object ScreenName {
        const val 案件一覧画面 = "案件一覧画面"
    }

    object ContentType {
        const val orders = "orders"
    }
}

イベント発火時のコード

binding.button.setOnClickListener { 
    AndpadAnalytics.sendEvent(AndpadAnalyticsEvent.SelectContent(
        AndpadAnalyticsConstants.ScreenName.案件一覧画面,
        AndpadAnalyticsConstants.ContentType.orders,
        content.id
    ))
}

ポイントは、

  1. なるべくConstantsを作成し利用する。変数名は送信値をlowerCamelに変換したもの。ただし実装上問題なければ日本語変数も可。
  2. イベントタイプごとに構造体を作っている
  3. データのValueは必ずStringにキャストしている(今はiOSのみ)

あたりでしょうか。3つとも、 実装担当者以外が分析する際にコードを確認するシチュエーションがあるかもしれない という前提で設計されたものです。

1の日本語変数に関しては、歴史的経緯から本来の意味とプログラムの命名がかけ離れているコンポーネントもあり、プログラム名とそこで使用される分析用変数と日本語で表現された役割がバラバラになるよりは、変数と役割だけでも同じものにできれば良いという考えで割り切っています。例えば案件一覧は Ordersと表現したいもののHomeActivityとして作成されており、プログラム内で突然画面名としてOrdersが出てくるよりは日本語で案件一覧と表現したほうが理解しやすい、といった具合です。

f:id:oct_k_kudo:20200616170215p:plain
プルリクのコメント。wが草なのか苦笑いなのかはまだ聞けていません

3はキャストによるオーバーヘッドが気になる人もいるかと思います。Analytics用に作成されるテーブル定義では値の型ごとにデータが格納されるカラムが変わってしまうため2、IDだからNumberだと思っていたらStringになっているのでデータが見つからない!といったトラブルを回避できます(極力一貫したデータを取得したいため、あとから実装ミスに気づいて実装を変更するケースを減らしたいいう意図も含んでいます)。また、String以外で扱いたい場合に必ず集計側で加工するという手間を強制することで、ゴミデータ起因でSQLがある日突然動かなくなるというトラブルを無くせると期待しています。
トレードオフだとは思いますが、今回は少しでも可用性が高くなる方法を優先しました。

データを集計する

BigQuery

Firebaseから連携されたデータはプロジェクト単位かつ日付単位でテーブルが作成されるため、特定アプリに関するn日分の集計ではSQLでwhereやunionが必要です。そこまで複雑なSQLでは無い一方で、テーブルのデータ全てを参照するため3対象テーブルを間違えた際の料金はデータ量を考えると怖いものがあります。

ある程度共通のテーブルを作成すればそこからのデータ取得で実現できるクエリが多そうだったため、共通のビューとそれを使ってOS/アプリ単位のカスタムテーブルを作るクエリを作成し、スケジューリング機能を使って毎朝データの前処理をしています。

必要な日数分だけテーブルを絞り込むビュー用のSQL

SELECT
  -- 他にも必要なカラムは適宜指定する
  CAST(CONCAT(
SUBSTR(event_date, 1, 4), 
'-', 
SUBSTR(event_date, 5, 2), 
'-', 
SUBSTR(event_date, 7, 2) 
) AS date) AS event_date,
  app_info.id AS app_name,
  -- バージョンを数値に変換することで、利用側で判定しやすくしている
  (IFNULL(SAFE_CAST(SPLIT(app_info.version, ".")[
      OFFSET
        (0)] AS INT64),
      0) * 1000000) + (IFNULL(SAFE_CAST(SPLIT(app_info.version, ".")[
      OFFSET
        (1)] AS INT64),
      -- デバッグビルドではX.Y.Z-debugのようなsuffixが付与されるので、それを除外するコード
      0) * 1000) + IFNULL(SAFE_CAST( SPLIT(SPLIT(app_info.version, ".")[
      OFFSET
        (2)], "-")[
    OFFSET
      (0)] AS INT64 ),
    0) AS app_version,
  event_params AS event_params,
  user_properties AS user_properties
FROM
  `********.analytics_**********.events_*` t  -- 一部伏せ字にしています
WHERE
  -- ここで必要な日数分のテーブルを指定する。一旦は7日分
  _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY))
  AND FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY));

実際のところ、上記クエリの作成中はテスト実行だけで毎回料金が発生しているという事実がそれなりにストレスだったので、何より自分のためにやってよかったと実感しています

f:id:oct_k_kudo:20200616165631p:plain:w500
iOSアプリ用のクエリの実行履歴

Redash

Redashはもともと社内で一部利用実績があったため採用しました。 特に複雑なことはしておらず、クエリをスケジュール実行してダッシュボードで一覧できるようにしています。

先のサンプルコードからどの案件がタップされているか?を集計した場合のSQLを紹介します。BigQueryは配列型がいまだに慣れません

WITH dataSet AS
  (SELECT event_date,

     (SELECT unnest_event_params.value.string_value
      FROM unnest(event_params) unnest_event_params
      WHERE unnest_event_params.key = "screen_name") AS screen_name,

     (SELECT unnest_event_params.value.string_value
      FROM unnest(event_params) unnest_event_params
      WHERE unnest_event_params.key = "content_type") AS content_type,

     (SELECT unnest_event_params.value.string_value
      FROM unnest(event_params) unnest_event_params
      WHERE unnest_event_params.key = "content_id") AS content_id,

   FROM `**********.analytics_**********.custom_table_firebase_analytics_ios_app`
   WHERE event_name = "select_content"
     AND app_version >= 1002003) -- この例だとバージョン1.2.3
SELECT event_date,
       content_id,
       count(*) AS COUNT
FROM dataSet
WHERE screen_name = "案件一覧画面"
GROUP BY event_date,
         content_id
ORDER BY event_date,
         content_id;

Slack通知

RedashのSlack APPの活用も考えましたが、ダッシュボードは利用できないそうなので一旦はリマインダーをセットしてリンクを通知するようにしています。まだ具体的なアクション起こせていませんが、毎朝ダッシュボードを確認する習慣をつけ、皆がデータを意識して案件に取り組めるようになれば良いなと考えています

f:id:oct_k_kudo:20200616172234p:plain

まとめ

エンジニアが主体となって集計できるようになったことで、最近はチーム内でもデータを元に既存機能に関する議論をすることが増えてきました。今後は今のダッシュボードを充実させて総合的に良し悪しの判断できる軸を作っていきたいです。

f:id:oct_k_kudo:20200616175839p:plain:w300

とはいえ、取得したデータはまだ完全に活かしきれていないのが現状です。これからもデータドリブンな開発をしユーザビリティの高いサービスを提供するためには、今まで以上にたくさんの仲間が必要だと考えています。

もし少しでも興味がありましたら弊社採用サイトもご覧になってください。

engineer.andpad.co.jp

最後までお読みいただきありがとうございました


  1. 他の会社さんはこの辺どうしているんですかね?

  2. keyに対して、int_valueやstring_valueとしてカラム定義されています

  3. BigQueryでは最初に参照したデータ量で金額計算が行われるため、whereやlimitは金額面で考慮されません