Open6

エラーログ管理ソフトGlitchTipをセルフホストしてsentryを代替する

yunayuna

Sentryとは?

Sentryは、一言で言えばエラーログ監視・追跡・管理サービスです。
https://sentry.io/

ほぼ全てと言っていいくらい多くの言語用にSDKが用意されていて、
https://glitchtip.com/sdkdocs
エラーログを随時サーバーに送信、
すぐにメールでエラー情報を共有したり、問題管理やエラーを管理することができるツールです。

エラーログ件数による従量課金(有料版だと、最も安くても4500円/月 程度発生します)なので、
多くのシステムを扱っていたり、突発的に多くのエラーが発生すると、金額が上がったり
ログの制限数を突破して月の途中からログが取れなくなったりします。

代替手段は?

上記課題解決のため、代替手段を探したところ、
SentryのSDKをそのまま利用できて(つまり既に埋め込んだ各アプリのコード変更不要で)、
セルフホストができるGlitchTip を見つけました。
SentryのSDKがそのまま利用できる=ほぼ全ての言語に対応しているということです!

GlitchTip雑感

https://glitchtip.com/
簡単に言うと、シンプルにしたAlternative Sentryです。

まず、セルフホストではなく、SaaSを使ってみて、
エラーが発生したときに、メールやwebhookが飛ばせて、内容が把握でき、
ある程度エラーを追跡管理できれば問題ないので、機能面では問題ありませんでした。

機能面の確認ができたので、セルフホストに挑戦したメモを残しておきます。

yunayuna

ソースコード

https://gitlab.com/glitchtip/glitchtip
django / Postgres 製アプリです。

やり方

ここを読んで行けばおおよそ問題ないです。
https://glitchtip.com/documentation/install

今回はdockerからインストールしたので、簡単に導入できました。

いくつか引っかかったポイント

EMAIL_URL

これはエラー通知や、メールアドレスの認証などで利用されるGlitchTipからのメール送信に使うSMTP設定です。
django environというツールの記法?のようなのですが、
設定に手間取りました。
https://django-environ.readthedocs.io/en/latest/types.html

結論

sakura ではsslで送信され、ユーザー名がメールアドレスなので、以下のようになります。

smtp+ssl://<user_name>%40<sakuraで登録したid>.sakura.ne.jp:<pass>@<sakuraで登録したid>.sakura.ne.jp:465

sample

smtp+ssl://user%40example.sakura.ne.jp:<pass>@example.sakura.ne.jp:465

ssl通信なのでsmtp+ssl://とすることと、
最初のユーザー名の@を%40とエンコードしているのがポイントです。

ドメインの設定と認証

GlitchTipのサーバーを例えば以下のようなドメインで立ち上げたとします。
https://glitchtip.example.com

すると、
各アプリからのエラーログは、アプリごと(GlitchTipではProjectと呼びます)のIDをつけた
以下のようなDNSにログを送信することになります。
https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@glitchtip.example.com/1

なので、ssl認証をする場合、この例だと

*.example.com

で認証取得する必要があるので、あらかじめ確認の上で認証キーを取得してください。

yunayuna

alertメールが飛んでこないことがある

Alertは、プロジェクトごとに、X分にX回まで送信する、という設定ができます。
それ以外にメールを飛ばす条件として、
すでに発生したことがあるissueで、かつmark as resolvedやignoreなどの設定がされていないこと。

があります。

が、上記の条件に合っていても、なぜかメールが飛んでこないことがありました。
メール送信は、本モジュールと別にceleryで非同期に送信処理を行い、
送信が完了すると、テーブルの該当カラムのis_sentがTrueになる、という仕様なのですが、

Alertはシステム運用する立場としては非常に重要で、見逃したくありません。

そこで、テーブルチェックして、上記is_sentがFalseのまま2分以上経過しているものがあれば、
メール送信してis_sentフラグをTrueに更新する、という簡易プログラムを書いてcronで定期実行することで対応しました。

main.rs
use anyhow::Result;
use glitchtip_notifier::util::mail::send_mail;
use tokio_postgres::{NoTls, Error};

use chrono::{DateTime, Utc};

use serde_json::Value;

#[tokio::main]
async fn main() -> Result<()> {
     // 1. PostgreSQLに接続
     let connection_str = "postgres://postgres:<password>@localhost:5432/postgres";
     let (client, connection) = tokio_postgres::connect(connection_str, NoTls).await?;
 
     // 非同期タスクとして接続処理を実行(バックグラウンドでエラーチェックを行う)
     tokio::spawn(async move {
         if let Err(e) = connection.await {
             eprintln!("Connection error: {}", e);
         }
     });
 
     // 2. SELECTクエリの実行
     let select_query = "
         SELECT 
             alerts_notification.id as alerts_notification_id,
             alerts_notification_issues.issue_id,
             alerts_notification_issues.id AS alerts_notification_issues_id,
             issue_events_issue.status,
             issue_events_issue.first_seen,
             issue_events_issue.last_seen,
             issue_events_issue.count,
             issue_events_issue.title,
             issue_events_issue.project_id,
             issue_events_issueevent.data,
             issue_events_issueevent.tags,
             projects_project.name as project_name
         FROM alerts_notification 
         INNER JOIN alerts_notification_issues ON alerts_notification.id = alerts_notification_issues.notification_id
         INNER JOIN issue_events_issue ON alerts_notification_issues.issue_id = issue_events_issue.id
         INNER JOIN issue_events_issueevent ON issue_events_issueevent.issue_id = issue_events_issue.id 
             AND received > current_timestamp - interval '10 minutes'
         INNER JOIN projects_project on projects_project.id = issue_events_issue.project_id
         WHERE alerts_notification.created > current_timestamp - interval '10 minutes'
                AND alerts_notification.created < current_timestamp - interval '2 minutes'
                AND alerts_notification.is_sent = False
     ";
 
     // クエリを実行し、結果を取得
     let rows = client.query(select_query, &[]).await?;
 
     // 結果の各行に対して処理(変数にセットし、printlnで表示)
     let mut text_str_all: String = String::new();
     let mut mail_title = String::new();
     for row in rows {
         let alerts_notification_id: i64 = row.get("alerts_notification_id");
         let issue_id: i64 = row.get("issue_id");
         let status: i16 = row.get("status");
         let first_seen: DateTime<Utc> = row.get("first_seen");
         let last_seen: DateTime<Utc> = row.get("last_seen");
         let count: i32 = row.get("count");
         let project_id: i64 = row.get("project_id");
         let data: Value = row.get("data");
         let tags: Value = row.get("tags");
         let title: String = row.get("title");
         let project_name: String = row.get("project_name");
 
         println!("issue_id: {}", issue_id);
         println!("status: {:?}", status);
         println!("first_seen: {:?}", first_seen);
         println!("last_seen: {:?}", last_seen);
         println!("count: {:?}", count);
         println!("project_id: {:?}", project_id);
         let yaml_str: String = serde_yaml::to_string(&data.get("message").unwrap_or(&serde_json::Value::Null))?;
         println!("{}", yaml_str);
         let tags_str: String = serde_yaml::to_string(&tags)?;
         println!("tags: {:?}", tags_str);
         if mail_title == "" {
            mail_title = format!("{:?} - {:?}", project_name, title );
         }

        //上記全てをテキストの文字列にまとめる(わかりやすく改行ごとに+=で繋げる)
        text_str_all += &format!("link: https://app.glitchtip.generalworks.co.jp/generalworks/issues/{}\n\n", issue_id);
        text_str_all += &format!("Project: {}\nTitle: {}\n\n", project_name, title);
        text_str_all += &format!("data:\n{}\n", yaml_str);
        // text_str_all += &format!("alerts_notification_issues_id: {}\n", alerts_notification_issues_id);
        text_str_all += &format!("count: {}\n", count);
        // text_str_all += &format!("status: {}\n", status);
        text_str_all += &format!("first_seen: {}\n", first_seen);
        text_str_all += &format!("last_seen: {}\n", last_seen); 
        // text_str_all += &format!("project_id: {}\n", project_id);
        text_str_all += &format!("tags:\n{}\n", tags_str);
        text_str_all += "\n---\n\n"; // Add separator between issues
        

        //  3. updateクエリで is_sent を true に更新
         let update_query = "UPDATE alerts_notification SET is_sent = true WHERE id = $1";
         client.execute(update_query, &[&alerts_notification_id]).await?;
     }

    // メール送信の設定
    send_mail(
        mail_title.as_str(),
        text_str_all.as_str(),
    )
    .await;

    Ok(())
}

crontab設定

*/3 * * * * /path/to/glitchtip_notifier
yunayuna

alert自体が作成されない場合への対応

上記、alertイベントが作成されるのにメールが飛ばないのではなく、
そもそもalertイベントが作成されないことも稀にある。

issueがDBに登録されない、ということは無さそうなので、
自分でissueを拾って完璧にコントロールしたい場合は、簡易プログラムで制御するのもアリ。

crate sledで、メール送信したissueeventを記録し、次回以降スキップする
※not in 句の数には注意

(issue数があまりに多い場合、まとめてメールを送る)

main.rs
use anyhow::Result;
use std::collections::HashSet;
use glitchtip_notifier::util::mail::send_mail;
use tokio_postgres::{NoTls, Error};
use sled;
use uuid::Uuid;
use unicode_segmentation::UnicodeSegmentation;



use chrono::{DateTime, Utc, NaiveDateTime};

use serde_json::Value;

#[tokio::main]
async fn main() -> Result<()> {
     // 1. PostgreSQLに接続
     let connection_str = "postgres://postgres:<password>@localhost:5432/postgres";
     let (client, connection) = tokio_postgres::connect(connection_str, NoTls).await?;
 
     // 非同期タスクとして接続処理を実行(バックグラウンドでエラーチェックを行う)
     tokio::spawn(async move {
         if let Err(e) = connection.await {
             eprintln!("Connection error: {}", e);
         }
     });
 
     // "my_db" というディレクトリにデータベースをオープン(存在しなければ作成)
     let db = sled::open("sent_issueevent_ids")?;
    println!("open db.");
     // 保存した id を読み出す例
     let mut all_keys: Vec<String> = Vec::new();   
     let mut delete_keys: Vec<String> = Vec::new();
     for (key, value) in db.iter().filter_map(|result| result.ok()) {
        let key_str = String::from_utf8(key.to_vec()).unwrap_or_default();
        println!("key_str: {:?}", key_str);
        let value_str = String::from_utf8(value.to_vec()).unwrap_or_default();
        // NaiveDateTimeをパース
        let naive_datetime = NaiveDateTime::parse_from_str(value_str.as_str(), "%Y-%m-%d %H:%M:%S")
        .expect("Failed to parse date string");

        // NaiveDateTimeからDateTime<Utc>に変換
        let received_time: DateTime<Utc> = DateTime::from_utc(naive_datetime, Utc);

        if received_time < Utc::now() - chrono::Duration::minutes(60) { // 1時間以上前のものは削除
            delete_keys.push(key_str);
        } else {
            all_keys.push(format!("'{}'", key_str));
        }
        
     }
     for delete_key in delete_keys {
        db.remove(delete_key.as_bytes())?;
     }
     // 2. SELECTクエリの実行
     // メール未送信で、3分経過しているもの(60分以内のものに限る)
     // issueが未解決のもの
     // 既に送信済みのissue_events_issueeventのidを除外

     let mut not_in_str = String::new();
     if all_keys.len() > 0 {
        not_in_str = format!("AND issue_events_issueevent.id NOT IN ({})", all_keys.join(","));
     }
     let select_query = format!("
         SELECT 
            issue_events_issueevent.id as issueevent_id,
            issue_events_issue.id as issue_id,
            issue_events_issue.status,
            issue_events_issue.first_seen,
            issue_events_issue.last_seen,
            issue_events_issue.count,
            issue_events_issue.title,
            issue_events_issue.project_id,
            issue_events_issueevent.data,
            issue_events_issueevent.tags,
            issue_events_issueevent.timestamp,
            issue_events_issueevent.received,
            projects_project.name as project_name
        FROM issue_events_issueevent
        INNER JOIN issue_events_issue ON issue_events_issueevent.issue_id = issue_events_issue.id 
            AND received > current_timestamp - interval '30 minutes'
            AND issue_events_issue.status = 0
        INNER JOIN projects_project on projects_project.id = issue_events_issue.project_id
        WHERE issue_events_issueevent.received > current_timestamp - interval '30 minutes'
            {};
     ", not_in_str);
    //  AND issue_events_issueevent.received < current_timestamp - interval '1 minutes' この条件は除外
 

     println!("sql: {:?}", select_query);
     // クエリを実行し、結果を取得
     let rows = client.query(&select_query, &[]).await?;
 
     let mut title_text_tuple_list: Vec<(String, String, String, String, String)> = Vec::new(); 
     let mut issue_ids: HashSet<i64> = HashSet::new();
     for row in rows {
        println!("sql: {:?}", row);
        //  let alerts_notification_id: i64 = row.get("alerts_notification_id");
         let issueevent_id: Uuid = row.get("issueevent_id");
         let issue_id: i64 = row.get("issue_id");
         let status: i16 = row.get("status");
         let first_seen: DateTime<Utc> = row.get("first_seen");
         let last_seen: DateTime<Utc> = row.get("last_seen");
         let received: DateTime<Utc> = row.get("received");
         let timestamp: DateTime<Utc> = row.get("timestamp");
         let count: i32 = row.get("count");
         let project_id: i64 = row.get("project_id");
         let data: Value = row.get("data");
         let tags: Value = row.get("tags");
         let title: String = row.get("title");
         let project_name: String = row.get("project_name");
 
        db.insert(issueevent_id.to_string().as_bytes(), received.format("%Y-%m-%d %H:%M:%S").to_string().as_bytes())?;

        if issue_ids.contains(&issue_id) {
            continue;
        }
        issue_ids.insert(issue_id);

        let mut text_str_all: String = String::new();
        // let mut mail_title = String::new();

        //timestampを、JSTに変換
        let timestamp_jst = timestamp.with_timezone(&chrono::FixedOffset::east(9 * 3600));
        let timestamp_str = timestamp_jst.format("%Y-%m-%d %H:%M:%S").to_string();
        println!("timestamp_str: {:?}", timestamp_str);
        println!("issueevent_id: {:?}", issueevent_id);
         println!("issue_id: {}", issue_id);
         println!("status: {:?}", status);
         println!("first_seen: {:?}", first_seen);
         println!("last_seen: {:?}", last_seen);
         println!("count: {:?}", count);
         println!("project_id: {:?}", project_id);
         
         let yaml_str: String = serde_yaml::to_string(&data.get("message").unwrap_or(&serde_json::Value::Null))?;
         println!("{}", yaml_str);
         let tags_str: String = serde_yaml::to_string(&tags)?;
         println!("tags: {:?}", tags_str);
        //  if mail_title == "" {
        //     mail_title = format!("{}, {}-{} {}", count, project_name, issue_id,title );
        //  }

        let mail_title = format!("{}, {}-{} {}", count, project_name, issue_id,title );    

        //上記全てをテキストの文字列にまとめる(わかりやすく改行ごとに+=で繋げる)
        let link = format!("https://app.glitchtip.generalworks.co.jp/generalworks/issues/{}", issue_id); 
        text_str_all += &format!("at(日本時間): {}\n", timestamp_str);
        text_str_all += &format!("link: {}\n", link);
        text_str_all += &format!("Project: {}\nTitle: {}\n\n", project_name, title);
        text_str_all += &format!("data:\n{}\n", yaml_str);
        // text_str_all += &format!("alerts_notification_issues_id: {}\n", alerts_notification_issues_id);
        text_str_all += &format!("count: {}\n", count);
        // text_str_all += &format!("status: {}\n", status);
        text_str_all += &format!("first_seen: {}\n", first_seen);
        text_str_all += &format!("last_seen: {}\n", last_seen); 
        // text_str_all += &format!("project_id: {}\n", project_id);
        text_str_all += &format!("tags:\n{}\n", tags_str);
        text_str_all += "\n---\n\n"; // Add separator between issues
            
        title_text_tuple_list.push((mail_title, text_str_all, link, yaml_str, timestamp_str));
        
     }
     //対象が5件以下なら、それぞれ別メールで送信
     if title_text_tuple_list.len() <= 5 {
        for (mail_title, text_str_all, _, _, _) in title_text_tuple_list {
            send_mail(mail_title.as_str(), text_str_all.as_str()).await;
        }
     } else {
        //対象が5件より大きい場合、まとめて一つのメールで送信
        let title = format!("Total {} alerts!", title_text_tuple_list.len());
        let mut text_str_all = String::new();
        for (mail_title, _, link, yaml_str, timestamp_str) in title_text_tuple_list {
            text_str_all += &format!("{}\n", mail_title);
            text_str_all += &format!("at(日本時間): {}\n", timestamp_str);
            text_str_all += &format!("{}\n", link);
            // let first_100_chars: String = yaml_str.as_str().graphemes(true).take(300).collect();
            // text_str_all += &format!("{}\n", first_100_chars);
            text_str_all += "\n-----------------------------------------------------\n\n"; // Add separator between issues
        }
        send_mail(title.as_str(), text_str_all.as_str()).await;
     }

     db.flush()?;

    // // メール送信の設定
    // if text_str_all != "" {
    //     send_mail(
    //         mail_title.as_str(),
    //         text_str_all.as_str(),
    //     )
    //     .await;
    // }

    Ok(())
}
yunayuna

iOS用のdSYMを追加する

iOSのクラッシュレポートを確認するためには、アプリをビルドした時に生成されるdSYMという設定ファイルを、事前にglitchtipにアップしておく必要があります。

sentryはできるけど、glitchtipはできないかな?と思ってたところ、
ここの最後に説明があるように、やり方があるようです。
https://glitchtip.com/sdkdocs/cocoa

APIにpostするエンドポイントが用意されてる。
https://app.glitchtip.com/api/docs#/default/apps_difs_api_dsyms

結論

Sentryの用意しているsentry-cliを使って、以下の手順でglitchtipへファイルアップ可能です。
が、crashReport時、dSYMを元にした情報に変換されませんでした。
したがって、glitchtipは利用しつつも、クラッシュレポートの分析用に、Crashlyticsを使うことにします。

GlitchTipへのdSYMアップ方法

upload-difは未対応(コード中に"Not implemented. It is a dummy API to keep sentry-cli upload-dif happy"の記述あり)なので、毎回新規登録のコマンドを打つ必要はあります。

sentry-cliインストール

https://docs.sentry.io/cli/installation/

curl -sL https://sentry.io/get-cli/ | sh
# ビルドの再現性のためバージョン固定がおすすめ
curl -sL https://sentry.io/get-cli/ | SENTRY_CLI_VERSION="2.43.0" sh

# 動作確認
sentry-cli --help

環境変数で設定する場合

  1.  export SENTRY_URL=https://your-glitchtip-url.com/
     export SENTRY_AUTH_TOKEN=YOUR_GLITCHTIP_AUTH_TOKEN
     export SENTRY_ORG=YOUR_ORGANIZATION_SLUG
     # プロジェクト固有の操作の場合は SENTRY_PROJECT も設定
     # export SENTRY_PROJECT=YOUR_PROJECT_SLUG
    
    • SENTRY_URL: あなたのGlitchTipインスタンスのベースURLを指定します。末尾のスラッシュ / を忘れないでください。
    • SENTRY_AUTH_TOKEN: GlitchTipのWeb UIで生成した認証トークンを指定します(通常、User Settings > API Keys や Organization Settings > Auth Tokens などで作成できます)。
    • SENTRY_ORG: GlitchTipのOrganization Slugを指定します。
    • SENTRY_PROJECT: GlitchTipのProject Slugを指定します(コマンドによっては不要な場合もあります)。

.sentryclirc ファイルを使用して変数指定

sentry-cli は、コマンド実行時のカレントワーキングディレクトリ、その親ディレクトリ、またはユーザーのホームディレクトリ (~/.sentryclirc) にある .sentryclirc ファイルを自動的に読み込みます。

ファイルは以下のような INI 形式で記述します。

.sentryclirc
[defaults]
url = https://your-glitchtip-url.com/
org = YOUR_ORGANIZATION_SLUG
project = YOUR_PROJECT_SLUG

[auth]
token = YOUR_GLITCHTIP_AUTH_TOKEN

dSYMのアップロード

sentry-cli debug-files upload --auth-token sntrys_YOUR_TOKEN_HERE \
  --include-sources \
  --org example-org \
  --project example-project \
  PATH_TO_DSYMS

※org名やproject名は、glitchtipで各プロジェクトのsettingのurlを見るとわかります。
だいたいは、名前のlowercaseになってるっぽい。
https://app.glitchtip.sample.com/org_name/settings/projects/project_name

GlitchTipのコードからの分析メモ

GlitchTipプロジェクトのコードを調査した結果、dSYMファイル(またはAPIコード内で同様に扱われるProguardファイル)は以下のPostgreSQLテーブルに分散して保存されることがわかりました:

  1. files_fileblob テーブル: ファイルの実際のコンテンツ(バイナリ)がSHA1チェックサムと共に管理されます。
  2. files_file テーブル: ファイル名、サイズ、チェックサムなどの基本的なファイルメタデータと、files_fileblob への参照が保存されます。
  3. difs_debuginformationfile テーブル: プロジェクトとの関連付け、デバッグID、シンボルタイプなどのデバッグ情報ファイル固有のメタデータと、files_file への参照が保存されます。

このように、ファイルデータは複数のテーブルにまたがって構造的に保存されています。

コード詳細

@router.post("projects/{slug:organization_slug}/{slug:project_slug}/files/dsyms/") という関数が、指定されたAPIエンドポイントに対応する処理を定義しています。

この関数の処理内容は以下の通りです。

  1. アップロードされたファイル(zip形式であると想定)を受け取ります。
  2. zipファイル内の各ファイルを処理します。
  3. extract_proguard_id や extract_proguard_metadata といった関数名から、このエンドポイントは実際には Proguard マッピングファイル(主にAndroidで使用)を処理しているように見えます。dSYMファイル(iOS/macOS)を直接処理するロジックは見当たりませんでしたが、同様の仕組みで他のデバッグ情報ファイルも扱っている可能性があります。
  4. create_dif_from_read_only_file 関数内で、ファイルの保存処理が行われます。
    • ファイルのSHA1チェックサムを計算します。
    • FileBlob モデルを使用して、ファイルの実際のコンテンツを保存します。FileBlob はチェックサムに基づいてコンテンツを管理し、重複を避ける仕組みと思われます。実際の保存先(ディスク、S3など)はDjangoの設定に依存しますが、データベースにはコンテンツのチェックサムとサイズが記録されます (テーブル名: files_fileblob)。
    • File モデルを使用して、ファイル名、ヘッダー、チェックサム、サイズなどのメタデータと、対応する FileBlob への参照を保存します (テーブル名: files_file)。
    • DebugInformationFile モデルを使用して、プロジェクトとの関連付け、ファイル名、対応する File への参照、および追加のメタデータ(debug_id や symbol_type など)をJSON形式で data フィールドに保存します (テーブル名: difs_debuginformationfile)。

以下、ディープサーチ結果のメモ。

GlitchTip自宅ホスト環境でのiOSクラッシュレポートのシンボル化(dSYM)設定ガイド

dSYMファイルの役割と必要性

iOSアプリのクラッシュログを**人間が読める形(シンボル化)に復元するには、ビルド時に生成される dSYMファイル(Debug Symbol File)が必要です。dSYMにはコンパイル後のアプリのシンボル情報(関数名や行番号など)が含まれており、これをGlitchTipにアップロードすることで、クラッシュ発生時のスタックトレース上のアドレスを実際のコード位置に解決(シンボル化)できます​**

sentry.ichizoku.io

****。dSYMをアップロードしていない場合、GlitchTip上のクラッシュレポートにはメモリアドレスや難読化された情報しか表示されず、どの箇所でエラーが起きたか判別しづらくなります。したがってProGuardマッピングやdSYM、PDBなどのデバッグファイルは必ずアップロードすることが重要です​

sentry.ichizoku.io

※Appleの「Bitcode」を有効にしてビルドしたアプリをApp Store経由で配布する場合、Apple側で再コンパイルが行われるためApp Store Connectから最終的なdSYMをダウンロードし、それをGlitchTipにアップロードする必要があります。Xcode 14以降ではBitcodeは廃止されましたが、もし以前の環境でBitcodeを使っている場合はこの点に注意してください。(Fastlaneのdownload_dsymsアクションなどを使うとApp StoreのdSYM自動取得が可能です。)

GlitchTipへのdSYMファイルアップロード方法

GlitchTipはSentryとAPI互換性があるため、Sentry用のツールやAPIエンドポイントを利用してdSYMをアップロード可能です​

glitchtip.com

。GlitchTip自体のWeb UIから設定できる項目とあわせて、以下では代表的なアップロード方法を順に紹介します​

docs.sentry.io

1. プロジェクト設定(UI)での確認と手動アップロード

GlitchTip(およびSentry)のプロジェクト設定画面には、アップロード済みのデバッグシンボルを一覧表示する**「Debug Files」(デバッグファイル)セクション**があります​

docs.sentry.io

。まずGlitchTipの管理画面で該当のプロジェクトを開き、**Project Settings(プロジェクト設定)**に移動してください。その中に「Debug Information Files」または「Debug Files」といった項目があり、アップロード済みのdSYMがあればそこで確認できます​

docs.sentry.io

現在のGlitchTipでは、Sentryと同様にWeb UI上で直接dSYMファイルをアップロードする機能は限定的です(ドラッグ&ドロップでのアップロードUIは提供されないことがあります)。そのため、以下に説明するAPIやCLIツールを使ってアップロードし、アップロード結果をUIで確認するのが一般的な手順になります​

docs.sentry.io

。手動でアップロードする際は、ビルドによって生成された .dSYM フォルダを .zip 圧縮したファイルを準備しておきます(単一のdSYMであればzip化、複数ある場合はまとめてzip化可能です)。

2. APIエンドポイントを直接使ったdSYMアップロード(curlの例)

GlitchTipはSentry互換のREST APIを提供しており、dSYMファイル用に以下のエンドポイントが用意されています。

  • エンドポイント: /api/0/projects/{organization_slug}/{project_slug}/files/dsyms/ (POST)

  • 認証: Auth Tokenによる認証が必要(HTTPヘッダーにAuthorization: Bearer <トークン>を付与)​

    github.com

  • 送信データ: フォームデータ(multipart/form-data)でdSYMのZIPファイルを添付(フィールド名file)。必要に応じてnameフィールドでファイル名を指定します。

手順:

  1. Authトークンの発行: GlitchTipの画面で自分のプロフィールから「Auth Tokens」を開き、新しいトークンを発行します(トークンには必要な権限スコープを付与します。dSYMアップロードには通常project:write権限が必要です。不要なスコープは付けないようにしましょう​

    glitchtip.com

    )。トークン文字列を控えておきます。

  2. スラッグの確認: アップロード先のOrganizationのスラッグProjectのスラッグを確認します。これはGlitchTipのURLやプロジェクト設定ページで確認できます(例: https://<your-glitchtip>/organizations/【組織slug】/projects/【プロジェクトslug】/…)。

  3. dSYMファイルの準備: Xcodeビルド後の .dSYM フォルダを.zip圧縮します。ファイル名は任意ですが、後でどのビルドのdSYMか分かるようにしておくと良いでしょう。
    例: アプリ名が"MyApp"の場合、ビルド生成されたMyApp.app.dSYMフォルダを右クリックして圧縮し、MyApp.app.dSYM.zipというファイルを得ます。

  4. curlコマンドでアップロード: ターミナルからcurlを使い、HTTP POSTリクエストでdSYMをアップロードします。以下は例です(<...>は適宜置き換えてください)。

export TOKEN=<上記で取得したAuthトークン>
curl -H "Authorization: Bearer $TOKEN" \
     -X POST \
     -F file=@MyApp.app.dSYM.zip \
     -F name=MyApp.app.dSYM.zip \
     https://<あなたのGlitchTipドメイン>/api/0/projects/<Orgスラッグ>/<Projスラッグ>/files/dsyms/

上記コマンドを実行すると、正常であればHTTPステータス201などでアップロード成功の応答が返ります。アップロード後、GlitchTipのプロジェクト設定画面の「Debug Files」一覧に、今送信したZIPファイル(dSYM)が登録されていることを確認できます。

※このAPIを使った方法は、裏側ではSentry CLIツールも同じエンドポイントを叩いており、認証ヘッダにBearerトークンを付与してマルチパートでファイル送信するという点で共通しています​

github.com

。スクリプトを自作する場合も、この形式に従えばdSYMを直接アップロードできます。

3. Sentry CLIツールを利用したdSYMアップロード

Sentry公式のCLIクライアントである**sentry-cli**を使う方法が最も便利で推奨されます。GlitchTipはSentry API互換なので、このツールをそのまま利用可能です​

glitchtip.com

sentry-cliを使うと、ローカルのdSYMファイルを自動的にスキャンしてアップロードし、既にアップ済みのファイルはスキップするなどの機能を備えています​

docs.sentry.io

準備:

  • sentry-cliをインストールします(Homebrew利用の場合: brew install getsentry/tools/sentry-cli。npm経由や静的バイナリのダウンロードでも可)。インストール後、ターミナルでwhich sentry-cliコマンドでパスが通っているか確認してください。

  • GlitchTipのAuthトークンを用意します(前述の手順で取得済みのもの)。

  • 自宅ホストのGlitchTipに接続する設定: sentry-cliはデフォルトではSentry(sentry.io)クラウドを向いているため、自前のGlitchTipサーバーURLを指定する必要があります。​

    docs.sentry.io

    方法は2通りあります。

    • 方法A: コマンドごとに--urlオプションを付けるか、または一度 sentry-cli login コマンドでURLを指定してログイン設定を行います:
sentry-cli --url https://<あなたのGlitchTipドメイン>/ login --auth-token <取得したAuthトークン>
  • これにより、~/.sentryclirc にデフォルトURLとしてあなたのGlitchTipアドレスが記録されます。

  • 方法B: 実行環境の環境変数に SENTRY_URL を設定します(他にもトークンやスラッグも環境変数で設定可能)​
    docs.sentry.io

export SENTRY_URL=https://<あなたのGlitchTipドメイン>/
export SENTRY_AUTH_TOKEN=<取得したAuthトークン>
export SENTRY_ORG=<Organizationのスラッグ>
export SENTRY_PROJECT=<Projectのスラッグ>

これらを設定しておけば、以降のsentry-cliコマンドは指定のGlitchTipサーバー・プロジェクトに対して実行されます。

dSYMファイルのアップロード:

準備ができたら、以下のコマンドでdSYMをアップロードします。

sentry-cli debug-files upload --include-sources \
    --org <Organizationスラッグ> --project <Projectスラッグ> \
    path/to/dSYMファイルまたはディレクトリ
  • --include-sources オプションを付けることで、シンボル情報にソースマップ(ソース情報)も含めてアップロードできます​

    docs.sentry.io

    (任意ですが付けておくと問題ありません)。

  • --org--projectは環境変数を設定済みであれば省略可能です。--auth-tokenも同様です。

  • path/to/dSYMファイルには、単一のZIPファイルパスや、dSYMが格納されたフォルダパス(例: DerivedData内のDebug-iphoneosフォルダなど)を指定できます。指定したディレクトリ以下を再帰的にスキャンし、見つかったdSYMをまとめてアップロードしてくれます​

    docs.sentry.io

実行結果として「Found XX debug information files」「File upload complete」といったログが表示され、アップロード済みまたは新規アップロードされたファイル数が報告されます​

github.com

。既にアップロード済みのdSYMについてはスキップされるため、同じコマンドを何度実行しても重複して登録される心配はありません​

docs.sentry.io

アップロード後、GlitchTipのプロジェクト設定>Debug Files画面で対象のdSYMが登録されていることを確認しましょう。あとはアプリからクラッシュが報告された際に、スタックトレースがシンボル化されて表示されるようになります。

4. Xcodeビルド時に自動アップロードする(Run Scriptビルドフェーズ)

開発プロセスで毎回手動でdSYMをアップロードするのは手間がかかるため、Xcodeのビルドフローに組み込んで自動アップロードすることもできます。Xcodeの「Build Phases」にスクリプトを追加し、ビルド完了後にsentry-cliコマンドを呼び出す方法が一般的です​

docs.sentry.io

設定手順:

  1. Xcodeでプロジェクトのターゲット設定を開き、「Build Phases」タブを選択します。画面左上の「+」ボタンから**「New Run Script Phase」**を追加します。このスクリプトはアプリのビルド(アーカイブ)完了後に実行されるよう、できるだけ下の方に配置します(デフォルトでは新しく追加すると最後尾に配置されます)。

  2. 新規追加された「Run Script」のスクリプト欄に、以下の内容を記述します(必要に応じてパスやトークンをあなたの環境に合わせて変更してください)。

# Homebrewでインストールした場合、Apple Silicon Macならパスを通す
if [[ "$(uname -m)" == "arm64" ]]; then
    export PATH="/opt/homebrew/bin:$PATH"
fi

# sentry-cliが存在するか確認
if which sentry-cli >/dev/null; then
    # 必要な環境変数をセット
    export SENTRY_AUTH_TOKEN="<YOUR_AUTH_TOKEN>"      # あらかじめ用意したAuthトークン
    export SENTRY_ORG="<YOUR_ORG_SLUG>"               # 組織スラッグ
    export SENTRY_PROJECT="<YOUR_PROJECT_SLUG>"       # プロジェクトスラッグ
    export SENTRY_URL="https://<あなたのGlitchTipドメイン>/"  # 自前のGlitchTipサーバURL

    # dSYMアップロード実行(ビルドで生成されたDWARFフォルダを対象)
    ERROR=$(sentry-cli debug-files upload --include-sources "$DWARF_DSYM_FOLDER_PATH" 2>&1 >/dev/null)
    if [ $? -ne 0 ]; then
        echo "warning: sentry-cli upload failed - $ERROR"
    fi
else
    echo "warning: sentry-cli not installed (skip dSYM upload)."
fi
  • 上記スクリプトは簡略化した例ですが、ビルド成果物のdSYMが配置される$DWARF_DSYM_FOLDER_PATHディレクトリを対象にdebug-files uploadコマンドを実行しています。問題が発生した場合でもビルド自体は通すように(failビルドにしないように)警告扱いにしていますが、運用ポリシーによってはexit 1でビルド失敗させることもできます。またApple Silicon環境の場合にHomebrew経由のsentry-cliパスを通す処理も含めています。

  • Input Filesの設定(Xcode 14以降): 上記のようにネットワーク通信を行うスクリプトをビルドフェーズに追加する場合、Xcodeの新しい機能である**「Run Script Sandbox」**に対応する必要があります。スクリプトによる成果物へのアクセスを認めないと、dSYMが見つからず正常にアップロードできません。対策として、Run Script設定の「Input Files」に以下のようなパスを追加してください​

    docs.sentry.io

    ${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}
    

1. これはビルド生成物としてのdSYMの場所をXcodeに認識させるための指定です。また、Xcode設定の**「ENABLE_USER_SCRIPT_SANDBOXING」を"NO"に設定**する必要があります​
    
    [docs.sentry.io](https://docs.sentry.io/platforms/apple/guides/ios/dsym/#:~:text=%24)
    
    (プロジェクトのビルド設定を開き検索すると該当項目が見つかります)。これらの設定により、ビルド後のスクリプトから安全にdSYMファイルへアクセスしアップロードが可能になります。
    

このRun Scriptを組み込んでおけば、**ローカルでビルドやアーカイブするたびに自動で最新dSYMがGlitchTipにアップロード**されます。開発中のビルドであっても、クラッシュ報告を収集するテスト段階であればアップロードしておくことで、後からデバッグしやすくなります。なお、開発中の頻繁なビルドで毎回アップロードするとビルド時間が延びるため、必要に応じてスクリプト内でConfiguration(例: Releaseビルド時のみ実行)を条件分岐させることも検討してください。

### 5. CI/CDパイプラインやFastlaneを用いた自動アップロード

CIサーバ上でビルドしている場合や、Fastlaneなどのデプロイツールを使用している場合も、dSYMのアップロードを自動化できます。CI環境では機密情報の管理に注意しつつ、ビルド後に`curl`や`sentry-cli`を実行するジョブを追加するのが一般的です。

- **FastlaneのSentryプラグインを使う方法**:  
    Fastlaneには公式のsentry-fastlane-pluginがあり、これを使うとdSYMアップロードを簡潔に記述できます。Fastlaneプラグインを導入後、Fastfile内で以下のようにアクションを呼び出します(GlitchTipの場合はURLを指定)​
    
    [github.com](https://github.com/getsentry/sentry-fastlane-plugin/issues/56#:~:text=Using%20,be%20working)
    。

```ruby:
sentry_upload_dsym(
  auth_token: "<取得したトークン>",
  org_slug: "<Orgスラッグ>",
  project_slug: "<Projスラッグ>",
  url: "https://<あなたのGlitchTipドメイン>"
)
  • 上記のようにurlパラメータでGlitchTipのホストを指定できます。古いバージョンのプラグインではurlパラメータが無い場合でも、Fastfileの先頭で環境変数 ENV["SENTRY_URL"] にホストURLを設定することで対応可能です​

    github.com

    。Fastlane実行時には機密情報であるトークン等を環境変数やCIのシークレットに保存し、コード上に直書きしないようにしましょう。

  • CI/CDでのスクリプト実行:
    JenkinsやGitHub Actions、GitLab CIなどでも、ビルド後のステップで前述のcurlコマンドやsentry-cliを呼び出すことができます。たとえばGitHub Actionsの場合、シークレットにAuthトークンやOrg/Projectスラッグを登録し、それらを環境変数としてuses: actions/[...]/runステップ内でexport SENTRY_URL=...およびsentry-cli debug-files upload ...を実行すると良いでしょう。BitriseやCodemagic等のモバイルCIサービスでも、公式ドキュメントでSentryへのdSYMアップロード手順が紹介されています​

    docs.codemagic.io

    。基本的な流れはどのCIでも同じです。

  • タイミングと頻度の調整:
    CIを利用する場合、どのビルドでdSYMをアップロードするかを決める必要があります。一般的にはリリースビルドや配布用のビルド(App Store/TestFlight提出ビルドなど)ごとにdSYMをアップロードします。開発版については、Crashlyticsの場合と同様に1日に1回まとめてアップロードする運用例もありますが、GlitchTipでは基本的にリアルタイムでアップロードして問題ありません。無用なアップロードを避けたい場合は、例えば「マージされたメインブランチのビルド時のみアップロードする」「毎晩定時にその日生成されたdSYMを一括アップロードする」などポリシーを決めて運用してください。

自動アップロード時のセキュリティ考慮とベストプラクティス

最後に、dSYMアップロードを自動化・運用するにあたってのセキュリティ上の注意点や運用上のTipsをまとめます。

  • Authトークンの安全な管理:
    GlitchTipのAuthトークンはプロジェクトの機密データにアクセスできるため、流出しないよう十分注意します。トークンはGitリポジトリに直接書かず、環境変数やCIのシークレット機能を用いて安全に注入してください。XcodeのRun Scriptに記載する場合も、上記例のように埋め込んでしまうと他の開発者にも見えてしまうため、可能であれば開発者各自のローカル環境変数から読み込む形にするか、社内のみで利用する専用トークンを発行するなどの運用ルールを決めましょう。GlitchTipではトークン発行時に付与する権限スコープを絞ることも可能なので、必要最小限(例: 該当プロジェクトの書き込み権限)のみを付与したトークンを使うのが望ましいです​

    glitchtip.com

  • CI上でのログ出力制御:
    CIパイプラインでsentry-cliを実行する際は、コンソールログに機密情報が漏れないよう配慮します。例えば先述のRun Script例では2>&1 >/dev/nullで標準出力を捨て、エラーのみを表示するようにしています​

    docs.sentry.io

    。CIでも類似のアプローチで、成功時の冗長なログを非表示にしつつ、エラー時だけ通知されるようにするとよいでしょう。特にcurlコマンドを使用する場合、誤ってトークンを含む全文字列を出力しないよう注意が必要です。

  • アプリバージョン管理:
    複数バージョンのアプリを運用している場合、それぞれのバージョンに対応するdSYMをきちんとGlitchTipに登録しておく必要があります。ビルドごとにUUIDが異なるdSYMが生成されるため、ビルドごとにアップロードを行う運用を徹底することが重要です​

    docs.sentry.io

    。アップロード漏れがあると、そのバージョンで発生したクラッシュがシンボル化されず分析が滞る原因になります。

  • アップロード後の確認:
    dSYMをアップロードしたら、実際にクラッシュ発生時にシンボル化されるか確認しましょう。GlitchTip上で新規のクラッシュIssueを開き、スタックトレースに関数名やソースコードの行番号が表示されていれば成功です。もし「シンボルが見つからない」旨のエラーや、依然としてアドレスだけの表示が出る場合は、プロジェクトのProcessing Issuesなどに「必要なシンボルファイルが欠落しています」といった警告が出ていないか確認します​

    forum.sentry.io

    。そうしたメッセージがある場合、該当UUIDのdSYMが正しくアップロードできていない可能性があるため、UUIDの一致するdSYMファイルを再確認して再アップロードしてください。

以上が、GlitchTip(セルフホスト環境)におけるiOSクラッシュレポートのシンボル化設定とdSYMアップロードの方法およびベストプラクティスです。**ポイントは「各ビルドのdSYMを確実に収集して遅滞なくアップロードすること」と「トークンなどの秘密情報を安全に扱うこと」**です。これらを踏まえて設定すれば、アプリのクラッシュ情報をGlitchTip上で迅速かつ正確に分析できるようになるでしょう。お役立てください。

参考資料: GlitchTip/Sentry公式ドキュメントおよび関連ブログ記事等

  • Sentry公式: _Uploading Debug Symbols (iOS)_​

    docs.sentry.io

    docs.sentry.io

    docs.sentry.io

  • GlitchTip公式ドキュメント: Integrations(Sentry API互換性とAuth Token説明)​

    glitchtip.com

    glitchtip.com

  • GitHub: sentry-fastlane-plugin Issues(Fastlaneから自前Sentry/GlitchTipにアップする方法)​

    github.com

    github.com

  • Ichizoku社ブログ: 開発者向けSentryクイックスタートガイド(デバッグファイル必須アップロードの指摘)​

    sentry.ichizoku.io

  • その他: Qiita/Zenn記事(CrashlyticsのdSYM運用例など)

yunayuna

SwiftUI + SPMプロジェクトへのCrashlytics導入手順

SwiftUIを使用したiOSアプリにFirebase Crashlyticsを組み込むにあたって、Swift Package Manager (SPM)を利用したセットアップが可能です。以前はCocoaPods経由の導入が主流でしたが、Firebase iOS SDKは現在SPMによる依存追加に対応しており、Xcodeから簡単にライブラリを取り込めます。以下にCocoaPodsを使わずにSPMでCrashlyticsを導入する手順をステップごとにまとめます。

  1. Firebase Crashlyticsのパッケージをプロジェクトに追加する: Xcodeの「File > Add Packages...」(またはプロジェクト設定のPackage Dependencies)から、FirebaseのSwift Packageを追加します。リポジトリURLは https://github.com/firebase/firebase-ios-sdk です。パッケージを読み込んだら、追加するプロダクトとして FirebaseCrashlytics(および依存するFirebaseAnalyticsやFirebaseCoreなど必要なもの)を選択します。SPM導入により、XcodeのSourcePackagesディレクトリ配下にFirebaseCrashlyticsなどのコードが取り込まれます。
  2. GoogleService-Info.plistを配置する: FirebaseプロジェクトからダウンロードしたGoogleService-Info.plistファイルを、Xcodeプロジェクト内の適切な場所(通常はターゲット直下)に追加します。このplistにはFirebaseアプリの設定(APIキーやプロジェクトID等)が含まれます。
  3. Firebaseの初期化コードを追加する: SwiftUIアプリではApp構造体の初期化やUIApplicationDelegateAdaptorを使ったAppDelegate経由で、アプリ起動時にFirebaseApp.configure()を呼ぶ必要があります​
    firebase.google.com
    。例えばSwiftUIの場合、@mainのApp構造体で次のように初期化します。
    import FirebaseCore
    import FirebaseCrashlytics
    @main
    struct MyApp: App {
        init() {
            FirebaseApp.configure()  // アプリ起動時にFirebaseを初期化
        }
        var body: some Scene { ... }
    }
    
    これによりCrashlyticsも含めFirebase SDK全体が初期化され、Crashlyticsのクラッシュハンドラもセットアップされます。
  4. Xcodeのビルド設定でdSYMの自動アップロードを設定する: Crashlyticsではビルド時にdSYMをアップロードするRun Scriptをプロジェクトに追加する必要があります​
    firebase.google.com
    。これはSPM導入時でも必要な手順です(CocoaPodsの場合とパスが異なる点に注意)。具体的な設定方法は以下のとおりです。
    • Debug Information Formatの確認: プロジェクトのターゲット設定で、各ビルド設定(Debug/Releaseなど)の「Debug Information Format」がDWARF with dSYMになっていることを確認します​
      firebase.google.com
      。デフォルトでReleaseはdSYM付きですが、DebugはDWARFのみになっているため、Debugも含め全てdSYM付きに変更します​
      firebase.google.com
    • Run Scriptフェーズの追加: Xcodeの「Build Phases」に新たに**"New Run Script Phase"を追加します​
      firebase.google.com

      firebase.google.com
      。このスクリプトは
      必ず最後のビルドフェーズ**になるように配置してください(Crashlyticsの処理は他の処理の後で行う必要があります​
      firebase.google.com
      )。
    • スクリプト内容の設定: 新規Run Scriptの「Shell」欄に、以下のスクリプトパスをそのままコピーします​
      firebase.google.com
      "${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run"
      

      ※SPMで導入した場合、上記のようにSourcePackages/checkouts/firebase-ios-sdk以下にCrashlytics用のrunスクリプトが存在します。CocoaPods利用時はパスが${SRCROOT}/Pods/FirebaseCrashlytics/runとなりますが、SPMではビルドディレクトリ経由のパス指定になります。

    • Input Filesの設定: Run ScriptのInput Files欄に以下のパスを追加します(環境変数はそのまま記述します)​
      firebase.google.com
      ${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}
      ${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME}
      ${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist
      $(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist
      $(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)
      
      これらは、それぞれdSYM本体のパス、dSYM内のシンボルファイル、dSYM内のInfo.plist、ビルド後のGoogleService-Info.plist、実行可能ファイルのパスです。XcodeのUser Script Sandboxing機能により、スクリプトでアクセスするファイルはInput Filesに明示する必要があるため、このリストを正しく設定してください​
      firebase.google.com

      firebase.google.com

      (※もしXcode設定でENABLE_USER_SCRIPT_SANDBOXING = NOとする場合、Input Filesの指定は省略できますが、セキュリティ上推奨されません。基本的には上記のように必要ファイルパスを列挙します。)
    • 実行権限の確認: 上記スクリプトパス先にあるrunファイルはスクリプトとして実行可能である必要があります。通常は問題ありませんが、コード署名の設定などで警告が出た場合は実行属性を確認してください。
      このRun Scriptを追加することで、ビルドやアーカイブのたびにCrashlytics用のスクリプトが実行され、dSYMが自動アップロードされます​
      firebase.google.com

      firebase.google.com
      。例えばアドホック配布やApp Storeアップロード用にアーカイブする際も、このフェーズによって最新シンボルがFirebaseに送信されます。
  5. 動作確認(テストクラッシュの送信): セットアップ後、Crashlyticsが正常に機能しているか確認するためにテスト用クラッシュを発生させます。Firebase公式ドキュメントではボタンを押してfatalError()を呼ぶコード例(SwiftUIの場合はButton("Crash") { fatalError("Test Crash") })が紹介されています​
    firebase.google.com

    firebase.google.com
    。アプリ内に一時的にこのようなクラッシュコードを仕込み、実行時に意図的にクラッシュさせてみます。クラッシュ後に再起動すると、FirebaseコンソールのCrashlyticsダッシュボードに該当クラッシュが表示され、スタックトレースがシンボル化されていれば導入成功です。

以上の手順により、Swift Package Manager経由でもCocoaPods無しでCrashlyticsを導入できます。公式ガイドにもSPM利用時のRun Script設定手順が詳しく掲載されています​

stackoverflow.com

stackoverflow.com

。ポイントとしては、Run ScriptのパスとInput Filesが正しく設定されていることと、FirebaseApp.configure()の呼び出しを忘れないことです。

SentryとCrashlyticsを同時導入した場合の挙動と制御

SentryとCrashlyticsを両方SDK導入した場合、クラッシュ検知の競合に注意が必要です。 前述のようにそれぞれがアプリのクラッシュ時に独自のハンドラ(シグナルハンドラやNSExceptionハンドラなど)を登録しますが、アプリが実際にクラッシュした際に両方のSDKが同時に完全なクラッシュ情報を取得できるとは限りません

stackoverflow.com

。クラッシュハンドラは基本的に最後に登録されたものが呼ばれる(または片方がMach例外ハンドラを占有してしまう)可能性があり、結果として「どちらか一方のレポートしか送信されなかった」ということも起こりえます​

stackoverflow.com

。Sentry社のエンジニアも「このような使い方はサポート対象外で、2つのSDKが衝突するため 未定義の動作 になる」と述べています​

stackoverflow.com

。実際、「両方入れてみたら大抵は両方にクラッシュが報告されたが、ケースによっては片方にしか出ないことがあった」という開発者の報告もあります​

forum.sentry.io

。つまり、SentryとCrashlyticsを同時に有効化するとクラッシュイベントが二重に送信される可能性もあれば、逆にどちらかにしか届かない可能性もあり、不確実なのです。

この問題を避け、クラッシュレポートをCrashlyticsだけに送るには、先述したようにSentry側でクラッシュイベントを送信しない設定にすることが重要です。 具体的には、Sentry初期化時のbeforeSendでクラッシュ由来のイベントを破棄する実装にしておきます(「Sentry SDKでクラッシュレポート送信を無効化する方法」を参照)。こうすることで、実際にクラッシュが発生した際のレポート送信先はCrashlytics側のみとなり、Sentryには同一クラッシュが重複送信されません。

さらに運用上は、クラッシュハンドラの競合による取りこぼしを避けるために、初期化の順序にも配慮するとよいでしょう。例えばCrashlytics(Firebase)の初期化処理をSentryより先に行い、その後にSentryをstartすることで、Crashlyticsのクラッシュハンドラが後からSentryに上書きされにくくする、という考え方があります(逆だと思われがちですが、CrashlyticsはFirebaseApp.configure()時にハンドラ登録するためSentryより先に初期化すると最後に初期化したSentryがハンドラを上書きしてしまう恐れがあります)。しかし、実際の挙動はSDK内部の実装やタイミングに依存し、「どちらを先に初期化すれば確実」といった明確な保証はありません。したがって最も確実なのはSentryでクラッシュを送信しない設定にすることです。これにより、仮にSentryがクラッシュを検知してもデータを破棄しますので、最終的にCrashlytics側でのみクラッシュ情報がレポートされます。

重要な点として、このようにSentryからクラッシュ報告を除外した場合、Sentryの機能の一部(リリースのクラッシュ率セッション追跡)に影響があります。Sentryはデフォルトでアプリ起動からのセッションを追い、クラッシュが発生するとそのセッションをクラッシュとしてマークします。しかしクラッシュイベント自体を送信しないと、Sentry側ではクラッシュが起きなかったものとして扱われ、セッションが正常終了扱いになる可能性があります。その結果、Sentryのリリースヘルス上はクラッシュ率0%と表示されてしまうなど、Crashlyticsで捕捉したクラッシュ数とSentry上の指標に乖離が生じます。この点については、クラッシュ率の指標はCrashlytics(Firebaseコンソール)側で確認し、Sentry上では主に非致命的エラーの追跡にフォーカスする、といった役割分担で補う形になるでしょう。

まとめると、Sentry+Crashlyticsの同時導入は公式には非推奨ではあるものの、Sentry側でクラッシュ送信をオプトアウトすれば「クラッシュはCrashlytics、非クラッシュのエラーやログはSentry」という使い分けは技術的に可能です。その際は必ず実機テスト等でCrashlyticsが全てのクラッシュを確実に記録できているか、またSentryにクラッシュイベントが送られていないかを検証してください​

stackoverflow.com

参考情報: SentryとCrashlyticsのハンドラ競合に関して、Stack Overflow上でも「それぞれがシグナルハンドラを設定するため、両方同時には動作しない。片方のSDKしかクラッシュを捉えられないケースがある」と言及されています​

stackoverflow.com

。実験上は両方動作したという報告もありますが、一貫しないため未定義動作とされています​

stackoverflow.com

両サービス併用時の使用例・ベストプラクティス

実際の開発現場でも、CrashlyticsとSentryを併用してそれぞれの利点を活かすケースがあります。たとえば「クラッシュ数や安定性指標は無償のCrashlyticsで集計し、詳細なデバッグ情報や非致命的エラー報告はSentryで管理する」という使い分けです。CrashlyticsはFirebaseエコシステムに統合されており、クラッシュフリー率のモニタリングやFirebaseサービスとの連携が強みです。一方、Sentryはスタックトレースの充実した情報提供やタグ/コンテキストのカスタマイズ、パフォーマンスモニタリング(トレースやアプリの応答性能計測)など包括的なエラートラッキングが可能です​

zenn.dev

。そのため、プロダクトによっては両者を役割分担させることで品質向上に役立てている例もあります。

しかし前述のように技術的な注意点がありますので、ベストプラクティスとしては以下の点を押さえて併用してください。

  • Sentry側のクラッシュ自動報告を無効化: beforeSendコールバックでクラッシュイベントをフィルタリングし、Crashlyticsと重複しないようにする(重複送信や検知漏れを防ぐための最重要ステップ)。​
    forum.sentry.io
  • dSYMの管理と運用を明確化: Crashlytics用のdSYMアップロードスクリプトが正しく動作していることをCIやリリースフローでチェックします。また、Sentryにも必要に応じて手動アップロードする(もっともクラッシュをSentry送信しないならSentry側にdSYMは不要とも言えますが、念のため設定しておくと万一誤ってクラッシュが飛んだ際にもシンボル化されます)。
  • ログ/エラー報告の方針整理: 非クラッシュのエラー(例えばAPIエラーやバリデーション失敗など)はSentryに集約し、Crashlyticsには送らないようにします。CrashlyticsもCrashlytics.crashlytics().log()record(error:)メソッドで任意の非致命的エラーを送信できますが、Sentryと重複すると混乱するため、非致命的なものはSentry、致命的クラッシュはCrashlyticsと明確に役割分担します。
  • リリースヘルスの指標確認: クラッシュ率についてはCrashlytics側(Firebaseコンソール)でモニタリングし、Sentryのリリースページ上のクラッシュ指標は参考程度に留めます(前述の通りクラッシュを送っていないため正確ではない)。代わりにSentryではIssue数やエラー傾向、ユーザフィードバック(ユーザにクラッシュ後送信させるコメント機能)などを活用します。
  • ドキュメンテーション: 将来的にチームメンバーが混乱しないよう、プロジェクトのREADMEやコメントに「クラッシュ報告はCrashlytics、その他のエラーはSentryに送信」という方針と設定方法を明記しておくことをおすすめします。

併用に関する情報源として、国内外のエンジニアブログでも言及が見られます。Flutterアプリ文脈ですが「両方入れた方が良さそう。Crashlyticsは無料だし入れておいて、不要なら後で外せばいい」という意見もあり​

zenn.dev

、無料で強力なCrashlyticsをクラッシュ用途に追加採用するケースは珍しくありません。一方でSentry社は公式に「複数のクラッシュレポートSDKを同時に使うのは推奨しない」としているため​

stackoverflow.com

どうしても両方使う必要がある場合にのみ慎重に設定・運用するのがベターです。

最後に、代替案として検討すべき点も触れておきます。もしSentryでのクラッシュ検知を無効にするカスタム実装に不安がある場合、いっそクラッシュ報告もCrashlytics一本に任せてSentryを外す選択もあります。ただしSentryには豊富なカスタムタグやエラー可視化機能があるため、通常は非致命エラーの追跡で依然として有用です。またその逆に、Sentryの有料プランを利用してクラッシュも非クラッシュも一元管理し、Crashlyticsを使わないという選択も考えられます。どちらを主軸にするかはチームの要求やコスト次第ですが、「Crashlytics+Sentry併用」の場合は以上のポイントに留意して設定することで、概ね意図した通りの運用が可能と言えるでしょう。

参考文献・情報源:

  • Firebase公式ドキュメント(Crashlytics iOS セットアップ)​
    firebase.google.com

    firebase.google.com
  • Sentry公式ドキュメント(iOS SDK設定・フィルタリング・シンボルアップロード)​
    docs.sentry.io

    docs.sentry.io
  • Sentry公式フォーラム「SentryとCrashlyticsの共存に関する質疑」​
    stackoverflow.com

    forum.sentry.io
  • エンジニアブログ: “FlutterアプリでSentryとFirebase Crashlyticsにエラーレポートを送り…”(Crashlyticsの挙動解説)​
    zenn.dev