Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

LUCRAの分析を支えるモバイルアプリのログ設計と実装

はじめに

こんにちは。LUCRA事業部の岡田です。好きなスポーツはボルダリングです。クリスマスに特段欲しいものはありませんが、彼女が欲しいです。

この記事は Gunosy Advent Calendar 2018、17日目の記事です。昨日の記事はくらさわさんのElm ファーストインプレッションでした。

Gunosyには 『数字は神より正しい』 という指針があり、その言葉通り毎日数値と向き合い、分析を通して仮説の立証、ロジックへの活用を行なっています。その数値の元になっているものはアプリから収集するログです。今回はGunosyの強みでもある分析を下支えしているモバイルアプリ(クライアント)側でのログの設計や実装について紹介したいと思います。なお、LUCRAにおけるログ収集・分析基盤は以下のようになっておりこちらを前提とした内容になっていますが、どの基盤を使っていてもそう大きく変わらないと思います。

クライアントで追加するログの種類

クライアントで追加すべきログは大きく分けて3種類あると思います。

  • クラッシュログ

    • 想定外の事象や継続不能な状態に陥った際にクラッシュした or させた時のログです。このログの目的は安定性の検知とクラッシュ原因の調査です
  • エラーログ

    • クラッシュとは行かないまでもユーザーの体験を損なう事象の発生の検知や調査のために追加します
  • アクションログ

    • ユーザーの操作に応じて送付するログです。「アプリを開いた」、「記事を閲覧した」、「Push通知を開いた」などKPI計測、施策効果やユーザー行動の分析を行うために追加します

この中で今回触れるログはアクションログについてです。アクションログはサービスの健康状態監視だけでなく記事配信ロジック等にも利用しているためGunosyにとって重要なものです。また、アプリのエンハンスで追加する機能は必ずA/Bテストを通しており、ユーザーにとって価値のある機能であるのかを判断する定量的な評価指標の元になっています。

アクションログの設計方針

上記で書いたログの利用用途を満たすためにアクションログに含むべき基本要素は4W1Hだと思います。

例として記事をクリックというアクションで考えると、

  • When(いつ)
    • ex) 記事をクリックした時間
  • Where(どこで)
    • ex) ファッションタブの10段目
  • Who(だれが)
    • ex) iOS12.0のiPhoneXSでLUCRAのv2.3.3をWifi環境で接続しているユーザー
  • What(なにを)
    • ex) 記事をクリックした
  • How(どのように)
    • ex) A/Bテストの〇〇テストのAパターン、xxテストのBパターン、△△テストのAパターンを割り当て中

といった感じです。 ただし、これらの情報以外にアクションの種類によっては追加で入れたくなる情報があります。

ログに必要な要素とそうでない要素切り分け

例に出した 記事をクリック というアクションログに対して追加したくなる要素をいくつかあげると

  • 記事のカテゴリは?
  • だれが書いたものか?
  • いつ公開されたものか? etc..

これらは分析したい項目によって大きく変化するので全てをログに入れようとする場合ログが肥大化するだけでなく、他の分析したい要素がでるたびクライアントへの修正を入れる必要がでてくるため辛いです。

  1. RDB等にある最新のデータで把握可能な情報である
  2. 適切にリレーションが組まれている
  3. ETLなどを用いてRDB等に格納されたサービス用のデータを分析用のデータソースに投入している

という前提条件のもとログに含めるべき要素は、

  1. その時々で状態が変わるもの
  2. RDBの各テーブルと紐付けられる必要最低限のRDB上の主キー

となります。

データウェアハウス上でのテーブル定義

以上を踏まえ、LUCRAではActionLogを格納するテーブルは以下のようにしています。(一部省略)

カラム名 action data ab user_id os app_version created_at
article:click { "location": hoge, article_id: hoge, ... } { "hoge": "huga", ...} xxx xxx xxx 2018-12-17T12:00:00
4W1H What Where(+ Whatの補足) How Who When

クライアント実装の勘所(Kotlin)

ログ定義のクラス図
(厳密に記載はしてないので、全体像を把握するための参考程度にmm)

送付までのざっくりとした流れは

  1. イベントハンドラ等でLogData(アクション詳細の定義クラス)型のオブジェクト生成する
  2. 生成したオブジェクトをSendLog(ログを送るロジックを記載したUsecase)に渡す
  3. (SendLogで)受け取ったLogDataとその他もろもろの情報を元に、LogPayload(データウェアハウス上のテーブルとマッピングするエンティティ)を生成する
  4. KinesisStreamにいい感じに流す

といった感じです。 これらすべてについて書くと彼女を作る時間がなくなるので、今回は1.に絞りAndroidでの実装ポイントいくつか紹介します。

送付ロジックがログ種別の影響を受けないようにする

各アクションによってアクションログに含める要素は増減します。LUCRAでは1つのアクションにつき一つのクラスを作成するようにしています。 そのため、抽象クラスとしてLogData(アクション詳細の定義クラス)を切り出すようにしています。

abstract class LogData(
    @Transient 
    val action: LogAction
)

このLogDataを継承させ各アクション詳細定義クラスを宣言します。 記事をクリックを例にすると、

data class ArticleClick(
        val articleId: Long,
        val location: Location,
        val placement: Int,
        ...
) : LogData(LogAction.ArticleClick)

となります。 こうすることで、SendLog(ログを送るロジックを記載したUsecase)はアクション詳細定義クラスに関知することなくLogData型として処理することができます。

再利用性を高める

LogDataの属性値にあるLogActionのvalueは先に記載したテーブル定義のactionカラムにマッピングされる要素です。

sealed class LogAction(val value: String) {
    object ArticleClick : LogAction("article:click")
    object ArticleImpression : LogAction("article:impression")
    ...
}

SealedClassを用いて、なんらかのaction名を保持する特徴を持ったLogActionを定義し、このLogActionを継承し指定のaction名を入れたクラスを宣言します。

これにより、LogActionクラス内に全てのaction名の一覧が集まり見通しが良くなるだけでなく、クエリを書いている最中や新規ログの追加時に「article:clickってどの画面で飛ばしているだっけ?」や「このactionってどんな属性値持っているんだっけ?」となった時も、LogActionの定義ファイルからIDEのコードジャンプ機能がフルに使え調査が楽になります。

また、action名は極力シンプルにWhatを表せるように命名します。LUCRAでは原則、article:clickのように名詞:動詞のフォーマットに準じるようにしています。 こうすることでaction定義の肥大化が防げ、実装上だけでなく、分析用クエリの再利用性も向上します。

付帯情報への制約

action名に情報量を持たせ過ぎず集約させた分の、 Where等にあたる不足情報はLogDataを継承した各アクション詳細定義クラスのプロパティとして定義します。 最終的にはテーブル定義のdataカラムにマッピングされる要素です。先ほどのArticleClickクラスにある

    val articleId: Long,
    val location: Location,
    val placement: Int,

などです。 これらの付帯情報にはログフォーマットを保証するために、適切にNullableかNonNullにすることはもちろんの事、Whereを示す情報はフォーマット等の制約を持たせるべきだと思います。

例として、X画面において

  1. 記事をクリックした(article:click)
  2. 記事がインプレッションした(article:impression)

という2つのアクションが起きた時に ログ上でのWhere情報をString型で定義してしまうと無駄な定数宣言が増えたり

  1. XScreen
  2. x_screen

といった表記揺れに繋がったりしまうからです。

これはかなりあからさまな例ですが、1つのFragment or Activitiy上のアクションであってもViewが階層化されると、分析上に必要なWhere情報は複雑になります。 そのため、Location型としてSealedClassを用いて定義し実装上制約を持たせています。

sealed class Location(val value: String) {
    class Article(
            articleId: Long
    ) : Location("article:$articleId")

    class Tab(
            tabId: Long
    ) : Location("tab:$tabId")
    ...
}

おわりに

今回はアクションログの設計とアプリ実装について触れました。実装についてはSwift版も書こうと思っていたのですが思いの外クリスマスまで時間がなく焦りから書けませんでした。ニーズがあれば今後どこかで書きます。ログ周りは運用・分析してみて気づくことが多いので、色々ブラッシュアップしていきたいと思います。

明日は @mathetakeさんの「Peer to PeerはGoogleの夢を見るか: How to build decentralized Google」(予定)です!お楽しみに!