SentryでiOSアプリのイベントログを収集する

こんにちは、R&Dチームの中村健太郎です。

Mask R-CNNでいちごを検出する話3DCGモデルからデータセットを作る記事を過去に投稿しましたが、今回はガラリと変わってiOSアプリの話で記事を書きました。余談ですがiOSアプリを作る手段はReactNativeだったり、Swiftでゴリゴリだったりしますが、実際のところ今どのくらいの割合でそれらが採用されているのかが気になるこの頃です。

はじめに

クライアントアプリにおけるイベントログ収集のモチベーション

どれだけ気をつけて開発をしていても、リリース後のバグや予期しない挙動は発生してしまいます。

サーバーサイドの場合、サーバーで発生した問題・サーバー起因の問題はサーバーログを頼りに原因の究明と改善を進めていくと思います。 しかしクライアントアプリの問題(ネットワークが絡まない問題)の場合は、ログを取っていたとしてもログファイルを受け取る窓口を別途用意する手間や、ログファイルやり取り自体の手間もかかってしまいます。

またクライアントアプリならではの特徴として、ユーザー(人)を介してしまうとリテラシーによって得られる発生状況や環境情報がバラバラ、かつ本当にその情報と実態が一致しているかの確認にも時間がかかってしまいます。これは仕方のないことです。

イベントログの収集はクライアントアプリにおいて、リリース後の問題に関する情報を素早く・正確にキャッチする重要な役割になると思います。

Sentry

sentry.io

タイトルにもある通り今回はSentryを使って収集してみます。使ってみた主観的な点も含めた特徴を3つ上げると以下のようになりました。

今回はiOSアプリ(Swift)にSentry SDKを導入して、実際にイベントログが収集できているところまでを確認してみたいと思います。

Sentry iOS SDKを導入してみる

Sentryプロジェクトの作成

Sentryへのサインアップは完了している前提で進めます。 Organization name はsentry-example、User nameはkentaro.nakamuraとしていますので適宜読み替えてください。

プロジェクト一覧よりCreateProjectボタンをクリックします。

f:id:optim-tech:20200116183439p:plain

次にプロジェクト一覧からSwiftを選択して、CreateProjectボタンをクリックします。今回プロジェクト名は省略しているので、デフォルトのswiftになっています。

f:id:optim-tech:20200116183432p:plain

すると、クライアントアプリ用SDK(sentry-cocoa*1)のインストール方法の画面に飛びます。実はもう既にWebからやる必要のある最低限の作業は終わっています。

f:id:optim-tech:20200116183427p:plain

sentry-cocoaの導入

Sentry Docs: Installationに記載されているため省略します。私はCarthage*2を使いました。 バージョン4.4.3以降はSwift Package Managerも対応しています。

iOSアプリでの設定

今回はXcode上でSingle View Appのプロジェクトを作成して試してみます。

Sentry Docs: Configurationに記載の通り、AppDelegate内でインスタンスを生成します。

//  AppDelegate.swift

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        do {
            Client.shared = try Client(dsn: "https://xxxxxxxxxxxxxxxxx@sentry.io/xxxxxxx")
            try Client.shared?.startCrashHandler()

        } catch let error {
            print("\(error)")
        }

        return true
    }

このときdsnに指定したアドレスはhttps://[CLIENT KEY]@sentry.io/[PROJECT ID]の形式になっており、https://sentry.io/settings/[YOUR ORGANIZATION NAME]/projects/[YOUR PROJECT NAME]/keys/へアクセスするとアドレスとクライアントキーの確認・再生成・失効をすることができます。

Errorイベントを送信してみる

ボタンを1つStoryboard上で作成・配置して、ボタンを押したタイミングでErrorイベントを送信するようにしてみます。

以下のようにボタンを配置して、ViewControllerと紐付けます。ボタンをタップしたときにレベルがErrorのイベントを送信するようにしてみます。

f:id:optim-tech:20200116183424p:plain

//  ViewController.swift

import UIKit
import Sentry


class ViewController: UIViewController {

    @IBOutlet var crashButton :UIButton!
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    @IBAction func buttonTapped(_ sender : Any) {
        
        Client.shared?.send(event: Event(level: .error), completion: { (error) in
            if let e = error {
                print(e)
            }
        })

    }

起動してボタンをタップします。このときにSentryへイベントが送信されているはずです。

f:id:optim-tech:20200116183421p:plain

SentryのIssuesタブを確認すると新しいIssueが作成されており、イベントが送信されていることが確認できます。

f:id:optim-tech:20200116184123p:plain

Issueは同じEventをグループ化したようなもので、同じイベントが何回飛んできたか、何人のユーザー(クライアント)から飛んできたのかがIssuesのダッシュボード上で確認できます。

イベントについて

デフォルトで取得できる情報

先ほど送信したErrorイベントで取れている情報を見てみます。一部情報に関しては伏せさせていただきます。 特にカスタマイズしない状態でもユーザー・デバイス・アプリに関する基本的な情報が一通り取れています。

f:id:optim-tech:20200116184128p:plain

f:id:optim-tech:20200116184131p:plain

送信情報のカスタマイズ

以下のようにeventインスタンスにいくつか情報を加えて送信してみます。

//  ViewController.swift

import UIKit
import Sentry


class ViewController: UIViewController {

    @IBOutlet var crashButton :UIButton!
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    @IBAction func buttonTapped(_ sender : Any) {
        
        let event = Event(level: .error)
        event.environment = "develop"
        event.message = "Custom event message"
        event.extra = ["at": "ViewController.swift:25"]

        Client.shared?.send(event: event, completion: { (error) in
            if let e = error {
                print(e)
            }
        })

    }
}

Issuesタブを確認すると新しいIssueが増えています。

f:id:optim-tech:20200116184432p:plain

Eventの詳細を確認するとenvironmentタグ、ADDITIONAL DATAの項目がそれぞれ追加されていることが確認できます。

f:id:optim-tech:20200116184429p:plain

f:id:optim-tech:20200116184427p:plain

基本的にevent.extraへ別途追加したい情報を入れておけば任意の情報を収集することができます。

さらにユーザーの情報も追加して、ユーザーごとのイベントトラッキングを行うこともできます。その他、詳しくはSentry Docs: Cocoa - Advanced Usage に説明がありますので、一読してみてください。

Loggerと組み合わせる

XCGLogger

すでに何らかのLoggerを使っている場合、Loggerの出力先に別途Sentryを追加することで、今までコンソールで見ていたエラーログをそのまま集約することができます。(もちろん不必要なイベントを大量に表示しても仕方ないので、取捨選択は必要だと思います。SentryでのEventのQuotaに関してはこちらに説明があります。)

今回はXCGLoggerのLogDestinationをカスタマイズして、Sentryへログを送信してみます。CarthageでXCGLoggerは導入済みとします。

github.com

初めから用意されているFileDestination.swift を参考にSentryDestinationクラスを作成します。Eventレベルはとりあえずerror一択とします。

//  SentryDestination.swift

import Sentry
import XCGLogger

class SentryDestination : BaseQueuedDestination {

    override func output(logDetails: LogDetails, message: String) {
        
        let event = Event(level: .error)
        event.message = logDetails.message
        event.logger = self.identifier
        event.extra = ["at": "\(logDetails.fileName):\(logDetails.lineNumber)",
                       "detail": message]
        
        event.tags = logDetails.userInfo as? [String: String] ?? [:]
        Client.shared?.send(event: event)
    }
}

AppDelegateも変更します。

//  AppDelegate.swift

import UIKit
import Sentry
import XCGLogger

let logger: XCGLogger = {
    let logger = XCGLogger.default

    do {
        Client.shared = try Client(dsn: "https://xxxxxxxxxxxxxx@sentry.io/xxxxxxx")
        try Client.shared?.startCrashHandler()

    } catch let error {
        print("\(error)")
    }
    
    let sentryDestination = SentryDestination(owner: logger, identifier: UUID.init().uuidString)
    logger.add(destination: sentryDestination)
    
    return logger
}()


@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        return true
    }

    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        
        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }

    func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
        
    }
}

ボタンを押したときはloggerに記録するようにします。

//  ViewController.swift

import UIKit
import Sentry


class ViewController: UIViewController {

    @IBOutlet var crashButton :UIButton!
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    @IBAction func buttonTapped(_ sender : Any) {
        logger.error("Something is wrong")
    }
}

シミュレーターで実行して、同じようにボタンを押してみると.....。

SentryへEventが送信されており、ADDITIONAL DATAにもLoggerの情報が書き込まれています。

f:id:optim-tech:20200116184437p:plain

f:id:optim-tech:20200116184435p:plain

おわりに

今回はクライアントアプリからSentryにイベントを送るところがメインになりましたが、それ以外にもダッシュボードからできることも結構多いです。うまく活用していくことでユーザーから問い合わせが来たときには既に開発サイドでエラーを把握していて、必要に応じて修正が始まっているようなスピード感での対応も可能になってくると思います。

OPTiMではサーバーサイドだけでなく、クライアントアプリが好きなメンバーも絶賛募集中です! AI/IoTと絡めた面白いアプリをご一緒に開発しましょう〜

www.optim.co.jp

ライセンス表記

アイキャッチ画像はSentry Logos and Branding で公式に配布されているものを使用しています。

*1:macOS用のアプリ開発フレームワークをCocoaというのでsentry-cocoaという呼び方です

*2:各種ライブラリを簡単に導入するためのパッケージマネージャーの1つです