良い感じにログを収集するライブラリ、Puree-Swiftをリリースしました

こんにちは。技術部モバイル基盤グループの三木(@)です。

クックパッドでは、Pureeと呼ばれるiOS/Android/ReactNative向けのログ収集ライブラリを公開しています。

モバイルアプリのログ収集ライブラリ「Puree」をリリースしました - クックパッド開発者ブログ

ログ収集ライブラリ Puree の iOS 版をリリースしました - クックパッド開発者ブログ

最近、以前開発されていたPureeをpure Swiftで書き換え、OSSとして公開しました。

この記事では、新しくなったPureeをご紹介します。

概要

クックパッドでは全社的にAmazon Redshiftを中心としたデータ活用基盤を構築しています。

クックパッドのデータ活用基盤 - クックパッド開発者ブログ

この仕組みを使い、公開している多くのモバイルアプリからも、1つのログ基盤にさまざまなログを集積させています。

しかし、モバイルアプリからのログ送信には、さまざまな状態を考慮する必要があります。 ログを送りたいタイミングに安定した通信が確保されているとは限らないですし、闇雲に送りすぎてしまうと、ユーザーさんのギガを圧迫してしまうかもしれません。

これらを解決するライブラリがPureeです。 ログをバッファリングし、まとめて送信したり、送信に失敗したログをキャッシュし、復元時にリトライする機能などを有しています。

f:id:gigi-net:20180227165235p:plain

Puree-Swiftの特徴

Puree-Swiftは、以前公開していたObjective-C版と異なり、以下のような特徴があります。

Objective-C版の設計思想を踏襲

Puree-SwiftはObjective-C版のPureeの置き換えを目指しています。そのため、タグシステムやプラグインの設計など、基本的な仕組みを踏襲しています。 詳しく知りたい方は以下の記事をご覧ください。

ログ収集ライブラリ Puree の iOS 版をリリースしました - クックパッド開発者ブログ

よりSwiftらしいインターフェイス

Objective-Cで書かれていた物をSwiftに刷新したため、よりSwiftから利用しやすいインターフェイスとなりました。

大きく変わったのはFilterOutputの実装方法で、以前は抽象クラスとして実装していたのですが、protocolを利用することができるようになり、よりSwiftらしいプロトコル指向な設計に生まれ変わりました。

依存関係の廃止

Objective-C版のPureeでは、未送信のログの永続化のため、YapDatabaseというSQLiteにアクセスするライブラリを利用していました。 しかしこのライブラリは最近メンテナンスが止まっていたり、Swiftで書かれていなかったりと、Pureeのメンテナンスを難しくする原因となっていました。 そのため、Puree-Swiftでは一切の依存関係を廃止して、iOS標準のファイルストレージを使うようにしています。

通常はこの利用方法で問題ありませんが、巨大なデータを扱いたい需要が出たときのために、LogStoreを自分でプラグインとして拡張できる設計になりました。 必要に応じてRealmやCoreDataなど、使いたいバックエンドを採用することができます。

実装例

それではさっそくPureeの実装例を見てみましょう。最終的には、以下のようなインターフェイスで任意の場所にログを送れるようになります。

ここでは、以下のようにPVログを送るまでの実装を考えてみます。

logger.postLog(["recipe_id": 42, "user_id": 100], tag: "pv.recipe.detail")

Pureeを扱うには以下の3ステップが必要です。

  1. ログを加工するFilterを実装する
  2. 収集されたログを外部に出力するOutputを実装する
  3. タグにより、どのFilterやOutputを利用するかルーティングする

より詳しい使い方はREADMEをご覧ください。

1. ログを加工するFilterを実装する

まず、Filterプロトコルを用いて、Filterを実装します。これは渡ってきた任意のデータをLogEntryに加工する役目を持っています。

ここでは単純に渡ってきたペイロードをJSONとしてエンコードして、LogEntryに格納しています。

import Foundation
import Puree

struct PVLogFilter: Filter {
    let tagPattern: TagPattern

    init(tagPattern: TagPattern, options: FilterOptions?) {
        self.tagPattern = tagPattern
    }

    func convertToLogs(_ payload: [String: Any]?, tag: String, captured: String?, logger: Logger) -> Set<LogEntry> {
        let currentDate = logger.currentDate

        let userData: Data?
        if let payload = payload {
            userData = try! JSONSerialization.data(withJSONObject: payload)
        } else {
            userData = nil
        }
        let log = LogEntry(tag: tag,
                           date: currentDate,
                           userData: userData)
        return [log]
    }
}

このFilter上で、全てのログに共通して付加したいペイロードを載せることもできます。 例えば、ユーザー情報などが考えられます。

2. 収集されたログを外部に出力するOutputを実装する

次に、収集されたログを外部に出力するためにOutputを実装します。

以下は渡ってきたLogEntryのペイロードを標準出力に出力するだけのOutputです。

class ConsoleOutput: Output {
    let tagPattern: String

    init(logStore: LogStore, tagPattern: String, options: OutputOptions?) {
        self.tagPattern = tagPattern
    }

    func emit(log: Log) {
        if let userData = log.userData {
            let jsonObject = try! JSONSerialization.jsonObject(with: log.userData)
            print(jsonObject)
        }
    }
}

BufferedOutput

Outputを用いると、ログが送信され、即座に出力されますが、代わりにBufferedOutputを用いると、一定数のログが溜まるまでバッファリングし、定期的にログを送ることができます。 以下のようにAPIリクエストを伴うようなログ送信に適しています。

class LogServerOutput: BufferedOutput {
    override func write(_ chunk: BufferedOutput.Chunk, completion: @escaping (Bool) -> Void) {
        let payload = chunk.logs.flatMap { log in
            if let userData = log.userData {
                return try? JSONSerialization.jsonObject(with: userData, options: [])
            }
            return nil
        }
        if let data = try? JSONSerialization.data(withJSONObject: payload, options: []) {
            let task = URLSession.shared.uploadTask(with: request, from: data)
            task.resume()
        }
    }
}

クックパッドでは、最初に紹介したログ基盤を利用するための、APIを提供しており、社内ライブラリとして、そのAPIに送信を行うOutputを提供しています。

このように、自前で用意したあらゆるログ基盤に出力することができますし、Firebase AnalyticsなどのmBaaSに対応することもできるでしょう。

3. タグにより、どのFilterやOutputを利用するかルーティングする

最後に、実装したFilterやOutputをどのログに対して適応するかのルーティングを定義しましょう。 Pureeは、ログに付加されたタグを元に、どのような処理を行うかを決定します。

import Puree

let configuration = Logger.Configuration(filterSettings: [
                                             FilterSetting(PVLogFilter.self,
                                                           tagPattern: TagPattern(string: "pv.**")!),
                                         ],
                                         outputSettings: [
                                             OutputSetting(ConsoleOutput.self,
                                                           tagPattern: TagPattern(string: "activity.**")!),
                                             OutputSetting(ConsoleOutput.self,
                                                           tagPattern: TagPattern(string: "pv.**")!),
                                             OutputSetting(LogServerOutput.self,
                                                           tagPattern: TagPattern(string: "pv.**")!),
                                         ])
let logger = try! Logger(configuration: configuration)
logger.postLog(["page_name": "top", "user_id": 100], tag: "pv.top")

例えば、上記のような定義ですと、それぞれのタグについて、以下のように処理が行われます。 これにより、ログの種類によって加工方法や出力先を変えることもできます。

tag name -> [ Filter Plugin ] -> [ Output Plugin ]
pv.recipe.list -> [ PVLogFilter ] -> [ ConsoleOutput ], [ LogServerOutput ]
pv.recipe.detail -> [ PVLogFilter ] -> [ ConsoleOutput ], [ LogServerOutput ]
activity.recipe.tap -> ( no filter ) -> [ ConsoleOutput ]
event.special -> ( no filter ) -> ( no output )

まとめ

  • iOSのログ収集ライブラリ、Puree-Swiftをリリースしました
  • すでにクックパッドアプリでは使われており、開発中の他のアプリでも利用される予定です
  • Outputを追加すれば、さまざまなログバックエンドに対応することができます

どうぞご利用ください。

try!Swift

f:id:gigi-net:20180227165258j:plain

ところで、明日3/1から開催のtry! Swift Tokyo 2018にクックパッドもブースを出展いたします。 オリジナルグッズの配布も行いますので、クックパッドでのiOS開発に興味のある方は是非遊びに来てください。

私もスピーカーとして登壇します。当日お会いしましたらよろしくおねがいします。

TOKYO - try! Swift Conference

/* */ @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;*/ /*}*/