ドキュメントベースの型安全なモバイルアプリ行動ログ基盤の構築

こんにちは。PlayStation 5が一向に買えない@giginetです。普段はモバイル基盤部というところでiOSの基盤開発をしています。

皆さん、行動していますか?我々は日々Webサービス上で様々な行動をしています。サービス開発において、改善に活かすための効率的な行動ログの収集方法はしばしば課題になります。

今回は、サービス開発者がモバイルアプリ上で簡単にログを定義し、分析を行えるログ基盤を導入した事例について紹介します。

行動ログとは何か

モバイルアプリの行動ログとは、ユーザーのアプリ上の操作や利用状況を取得、集積するためのものです。 例えば、特定の画面を表示したり、特定のボタンをタップしたり、といったユーザー操作を起点として送信されています。

集められたログは、サービス開発のための分析や実態把握に役立てられます。

最近はFirebase Analyticsなど、PaaSの形態で提供されるログ基盤も増えてきました。 一方で、クックパッドのようなサービス規模になると、流量やコスト、ニーズへの適合という面から独自のログ基盤を構築しています。

以下の記事では、クックパッドを支えるログ基盤の概要について説明しています。主にバックエンドの構成などに興味がある方はご覧ください。

一般的な行動ロガーの実装

この記事ではクライアントサイドのログ実装に着目していきましょう。 なお、今回紹介するログ基盤は、どのクライアント実装によっても利用できる仕組みですが、この記事では、Swiftで記述したiOSアプリでの利用を例に取っています。

一般的な行動ロガーの実装として、以下のようなものが思い浮かぶでしょう。

Analytics.logEvent("select_content", parameters: [
  "content_type": "image",
  "content_id": "P12453",
  "items": [["name": "Kittens"]]
])

これはFirebase Analyticsのドキュメントで説明されているロギングの実装例です。

この方式ですと、クライアント側から任意のログを柔軟に送ることができます。しかし、スキーマレスであるこのようなログ実装の規模が大きくなっていったとき、すぐに収集がつかなくなってしまうのは想像に難くないでしょう。

これまでの問題点

クックパッドアプリの規模になると、日々膨大な量のログが送付され、リリースを重ねるごとにログの送信箇所が増えていきます。

また、多くの開発者が関わっているため、実装者のほか、後世でそのログを分析する人やディレクターも細かな仕様を把握する必要があります。実際に起こりうる問題について見て行きましょう。

行動ログへの注釈が難しい

現実のサービス開発における行動ログには様々なコンテキストが含まれています。

例えば、その行動ログがどのタイミングで送信されるのか、どのバージョンから実装されているのか、どのような定義域の値を送るのかなどでしょう。

クックパッドでは、これらのコンテキストに注釈を与えるため、dmemoという社内サービスを用いています。

dmemoを用いると、データウェアハウス(DWH)上の全てのテーブルやカラムにメタ情報を付与することができます。

f:id:gigi-net:20201102143355p:plain
データベースドキュメント管理ツールdmemo

しかし、現実には、柔軟すぎるログ実装には様々な問題があり、その度に各ログの持つコンテキストが膨大な物になっていきます。

バージョンごとの挙動差異の追跡が難しい

モバイルアプリのリリースごとの細かな挙動の変化を追うのが難しい問題もありました。

ログの実装は、バージョン毎に細かな挙動が修正されたり、特定のバージョンにおいては不具合によって期待した値が送られていないという問題も度々起こりえます。

このような場合もdmemoに注釈を付与して管理する必要がありました。

f:id:gigi-net:20201102143437p:plain
dmemoでのバージョン毎の挙動差異の注釈の様子

ログの実装ミスが防げない

冒頭のロガーの例のようなスキーマレスなインターフェイスは、実装時の柔軟性が上がりますが、実装時のミスを防ぐことが難しくなります。

フィールド名のtypoや、必要なペイロードの欠損、型の間違いなどのミスは常に起こりえます。

ログの実装をミスしたリリースが世に出てしまうと何が起きるのでしょうか。そうです、またdmemoへの記述量が増えていきます。

ログイベントの廃止が難しい

ログの廃止の問題もありました。分析の必要性がなくなったり、該当する機能そのものがなくなったりしたケースです。

この場合は、あるバージョンからログが送信されなくなります。同様に、dmemoに全て記述しておく必要があります。

ログの送付し忘れやデグレーションに気付きづらい

逆に、送信しなくてはならないログの送信がされていないケースもあります。

これは、実装時の単なる実装漏れから、リファクタリング時にうっかり処理が消えてしまうことも考えられます。

上記のログの廃止も併せ、それぞれのログが送られているべきなのか、そうではないのか、確認が必要でした。

これらの問題は、全て現実世界における行動ログの持つコンテキストが膨大なことに起因します。このようなドキュメンテーションの多くをdmemoに依存する必要がありました。

ドキュメントからロガーの実装を生成するログ基盤

そこで、今回実現したのが、Markdownで書かれたログ定義ドキュメントからのログ実装の自動生成です。

ログ定義をヒューマンリーダブルなドキュメントとして記述し、そこからクライアント実装のコードを自動生成します。

例を見てみましょう。

1. ログ仕様を決めてドキュメントを記述する

まずログの仕様を決め、そのドキュメントを専用の文法に従ってMarkdownで記述します。

# recipe_search

レシピ検索画面のイベントです

## search

レシピ検索を行った際に送付されます

- keyword: !string 256
    - 検索キーワード
- order: SearchOrder
    - 検索順
    - latest,popularity

## show_recipe

検索結果画面からレシピ詳細画面に遷移する際に送付されます

- recipe_id: !integer
    - 表示したレシピのID

このとき、ログ定義にはドキュメントを追記することもできます。

ログ定義の構成

このMarkdownがどのような構造になっているかを簡単に見て行きましょう。ログ定義はカテゴリ、イベント、カラムの3つの要素で構成されます。

カテゴリ (recipe_search)

複数のイベントの集合です。近しい機能(例えば一つの画面上に実装されているものなど)を同一のカテゴリとして扱います。

大見出しがカテゴリを表現します。カテゴリはいくつかのイベントを持ちます。

イベント (search, show_recipe)

特定の行動に対応する単位です。「検索を行った」(search)や「レシピを表示した」(show_recipe)などの単位が挙げられます。

それぞれの小見出しが、そのカテゴリに属するイベントを表現します。

カラム

各イベントに付与されるペイロードを表します。各カラムは型を持ち、この型がデータベース上の型と一致します。

また、全てのイベントが共通して持つペイロードは別途定義しています。 例えば、送信日時やユーザーID、送信者のOSやアプリケーションバージョンといった項目が挙げられます。

2. クライアント用のログ実装を生成する

次に、トランスパイラを実行します。

$ ./generate-log-classes

これによって、例えばiOSアプリにおいては、以下のようなSwiftのソースコードが生成されます。

ここで生成された RecipeSearchが、アプリケーション上でログイベントを表現する型となります。

例えば、レシピ検索のイベントはRecipeSearch.search として利用できます。

/// レシピ検索画面のイベントです
public enum RecipeSearch: LogCategory {
    public static var categoryName: String { "recipe_search" }
    public var eventName: String {
        switch self {
        case .search: return "search"
        case .showRecipe: return "show_recipe"
        }
    }
    public func makePayload() -> [String: Any] {
        switch self {
        case let .search(keyword, order):
            return [
                "keyword": keyword.validateLength(within: 256).dump(),
                "order": order.dump(),
            ].compactMapValues { $0 }
        case let .showRecipe(recipeId):
            return [
                "recipe_id": recipeId.dump(),
            ].compactMapValues { $0 }
        }
    }

    /// レシピ検索を行った際に送付されます
    case search(keyword: String, order: SearchOrder)
    /// 検索結果画面からレシピ詳細画面に遷移する際に送付されます
    case showRecipe(recipeId: Int64)
}

3. アプリケーション上でログを送信する

最後に生成されたログイベントを用いて、ログを送付します。

ログを送付したいタイミングで以下を呼び出します。

presentRecipeDetailViewController(recipeID: 42)
logger.postLog(RecipeSearch.showRecipe(recipeId: 42))

このようにアプリケーション開発者は、ログの仕様をMarkdownで記述するだけで、簡単にログの実装を完了できます。

このロガーは、冒頭に紹介したスキーマレスなロガーと違い、各ログイベントが型で保証されています。 これにより、フィールド名の間違い、欠損、型の間違いなどのヒューマンエラーを防いでいます。

ログ基盤の構成

このログ基盤は、以下のような構成で実現されています。

f:id:gigi-net:20201102143935p:plain
ログ基盤の構成

ログ定義

ログ定義はMarkdownで記述され、各アプリケーションのリポジトリ上に置かれています。

ログ定義と実装が紐付くことで、ログ定義の変遷やバージョン毎の挙動の違いも、通常のソースコードと同様にバージョン管理ツールから追跡可能になりました。

Markdownパーサー

ログ定義Markdownをパースし、中間表現に変換します。これを実現するMarkdownコンパイラ daifuku を実装しました。Rubyで記述しています。

daifukuは、Markdownを中間表現(RubyオブジェクトやJSONなど)に変換することのみを担当します。ここで重要なのは、daifukuはコード生成の責務を負わないことです。

コード生成は各プロジェクトがパースされた中間表現を読み取って行います。

これにより、変換先の実装言語によらず、汎用的にこのログ基盤を利用できるようになりました。

クックパッドでは、これと同様のログ生成の仕組みを、iOSアプリのほか、Androidアプリと、Webフロントエンドでも実現しています。 daifukuが生成した中間表現を、それぞれSwift, Kotlin, TypeScriptのコードにトランスパイルすることで実現しています。

コンパイル時にログ定義の簡易的なバリデーションも行っています。定義が命名規則に合致するかチェックしたり、利用できないカラム名を定義していないかなどです。

トランスパイラ(コード生成機)

daifukuが生成した中間表現を、テンプレートエンジンを用いて、各言語の実装にトランスパイルします。 このトランスパイラは、各プロジェクト毎に用意されています。多くはRubyで実装されていますが、任意の言語で実装できます。

ロガー

トランスパイラが生成したログイベントを解釈し、ログ送信ライブラリに渡す層です。

アプリケーション開発者は、ログを送りたい場面でログイベントオブジェクトを作り、ロガーに渡します。

logger.postLog(RecipeSearch.showRecipe(recipeId: 42))

ログ送信ライブラリ

最後が、収集されたログをログバックエンドに送信するための層です。

iOSアプリでは、以前からクックパッドで利用されている、ログ収集ライブラリのPureeを用いています。

Pureeはログのバッファリングや永続化を自動的に行い、非同期でログバックエンドへのバッチ送信を行うライブラリです。

PureeはOSSとして公開されており、他社のプロダクトでも多く利用されています。

さらに簡単で安全なログ開発基盤へ

この仕組みを運用することで、冒頭に挙げた問題の多くを解決することができるようになりました。

ここからは、利便性と堅牢性をさらに高める、発展的な機能について説明していきます。

廃止になったログイベントの表現

ログイベントの廃止を、特殊なアノテーションで指定することもできます。

## [obsolete] tap_promotion_banner
    - バナーをタップしたときに送付されます
    - バージョンXX.Xからバナーは表示されなくなりました

ログ定義に[obsolete] 指定を追加すると、このログイベントをコード生成から除外することができます。

単純なドキュメントからの削除ではなく、特殊なアノテーションを付加して記述できるようにしているのは、ログ調査の簡略化のための工夫です。 これにより、Markdown上にドキュメントを残しつつ、アプリケーションからは送ることができないログを表現できます。

ログ定義の静的解析による実装し忘れの検知

冒頭で挙げた、ログの送信し忘れ、実装漏れはある程度の静的解析でチェックすることができるようになりました。 前述のobsolete指定がされていないかつ、アプリケーション上のどこからも送信されていないログは、実装忘れである可能性が高くなります。

このチェックをCI上で実行し、利用されていないログイベントが見つかった場合は、実装か廃止を促します。

IDEのドキュメンテーションとの統合

トランスパイル時に、各言語のドキュメンテーションの仕組みに沿ったコメントを生成することで、ドキュメントをIDEに統合することもしています。

Markdownに記述したドキュメントは、コード生成時にログイベントのDocumentation Commentとして出力されます。 これにより、実装時に簡単にログの仕様を把握することができます。

f:id:gigi-net:20201102144026p:plain
Xcode上でログ定義をコード補完している様子

言語機能による型安全なログイベント

コード生成時に出力先の言語の型システムを利用することで、さらに堅牢なロガーの実装を生成できます。

もう一度、レシピ検索の行動ログの例を見てみましょう。

# recipe_search

## search

- keyword: !string 256
    - 検索キーワード
- exclude_keyword: !string? 256
    - 除外キーワード
- order: SearchOrder
    - 検索順
    - recent,popularity

このとき、各カラムの型としてデータベースに格納するプリミティブな型(!から始まります)以外に、特殊な型指定を利用することで、ログイベントが要求するペイロードの型を細かく制御することができます。

いくつか例を見ていきましょう。

オプショナル型

型指定に?を付けることで、仕様上nullが入りうるカラム、そうではないカラムを区別することができます。

exclude_keywordカラムは検索時の除外キーワードです。通常のキーワードが必須であるのに対し、除外キーワードは必須ではありません。

オプショナル型は、上記の例にある exclude_keyword のような、状況により付与しないカラムの表現に役立ちます。

オプショナル型でないカラムは、自動的にnullを非許容とするため、実装時に空の値が送られてしまうことを型システムで防ぐことができます。*1

logger.postLog(RecipeSearch.search(keyword: nil, excludeKeyword: nil)) // keywordにnilは渡すことができない

カスタム型

カスタム型を使って、任意の型をペイロードとして渡すこともできます。代表的な使用例は、特定の定義域しか取らない文字列型の表現です。

order カラムは検索順を記録する文字列型のカラムです。新着順(recent)か、人気順(popularity)のいずれかの値を取ります。

このカラムを単なる文字列型として扱ってしまうと、アプリケーション実装者のミスにより、定義外の値が混入してしまう可能性があります。

logger.postLog(RecipeSearch.search(order: "oldest")) // このorderは渡せない

そこで、このようなenumを定義し、ログイベントがこの値を要求することで、定義外の値を送ってしまうことを型システムで防いでいます。

public enum SearchOrder: String, ColumnType {
    case recent
    case popularity
}

この場合は実態はStringのenumなので、ログバックエンドには文字列型として送信されます。

これらの工夫により、アプリケーション実装者が利用するロガーは型が保証されています。これにより、意図しない値が入り込むことを実装レベルで防いでいます。

logger.postLog(RecipeSearch.search(keyword: "いちごどうふ", 
                                                             excludeKeyword: nil, 
                                                             order: .popularity))

まとめ

今回はクックパッドのログ基盤のフロントエンドでの取り組みを中心にお伝えしました。

ログ定義がドキュメントベースで管理されるようになったことで、冒頭で挙げた種々の問題を一度に解決することができました。

このログ基盤の導入以前には、ログの実装コンテキストを追ったり、実装ミスを防ぐのが難しい状況でした。 しかし、導入後は、ドキュメントとログ実装が紐付くことで、開発・分析時共に、ログの設計、実装、利用が全て容易に行えるようになったことがおわかり頂けたと思います。

ログ基盤は、組織によってニーズが異なり、なかなか汎用的な仕組みを作ることが難しい領域だと感じています。 この記事が最高のログ基盤を作るための一助となりましたら幸いです。

クックパッドでは最高の行動ログ基盤を使って開発したいエンジニアを募集しています。

*1:なお、どれだけ気をつけてもDBに到達する頃にはなぜかnullになっているケースを完全に防げないことはあまりにも有名である

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/