Article by: Mischan Toosarani-Hausberger , Roman Zavarnitsyn (了読時間:13分)
Androidにおけるネイティブクラッシュは、これまで本来あるべきよりもデバッグが難しいものでした。
Androidには独自のクラッシュレポーター(debuggerd)があり、クラッシュしたスレッド、実行中の他のすべてのスレッド、レジスタ状態、メモリマップを tombstone と呼ばれるファイルに記録します。tombstone は長年Androidの一部であり、実際にはAndroid最初期のコミットの頃から、形を変えつつ存在してきました。
問題は、Androidの歴史の大半において、アプリ内部から tombstone をプログラム的に読み取ることができなかったことです。そのため、SDKベースのネイティブクラッシュレポート機能(私たちのものを含む)は、プラットフォーム側ですでに存在している仕組みを独自に再実装せざるを得ませんでした。その代償として、バイナリサイズの増加、不完全なJavaフレームのシンボリケーション、さらに変化し続けるAOSPに追従するために維持しなければならないC++フォークが発生していました。
Android 11(SDK level 30)では ApplicationExitInfo が導入されました。さらに Android 12(SDK level 31)では、ApplicationExitInfo.REASON_CRASH_NATIVE に対する trace input stream へのアクセスが追加されました。
SentryのAndroid SDKは、バージョン8.30.0以降、Android 12以上を実行しているすべてのデバイスでこのストリームを読み取り、ネイティブクラッシュイベントとして送信します。これにより、ネイティブコードを使用するAndroidアプリのクラッシュレポートは大幅に改善されました。基本的なクラッシュ通知だけが必要なチームにも、詳細なデバッグ情報が必要なチームにも有効です。
ここからは、以前はどのように動作していたのか、既存のNDK統合を壊さずにこれをSDKへ組み込むために何が必要だったのか、そしてこれによってどのような改善がもたらされたのかを見ていきます。
tombstoneサポート以前:変化し続けるターゲットを追い続けるフォーク
特に、libunwindstack(現在も debuggerd、そして tombstone のスタックトレース生成に使用されているAOSPのプラットフォームアンワインダ)を統合したことは、Sentry Android SDKでネイティブクラッシュをサポートする上で重要な転機となりました。なぜかというと、Native Development Kit(NDK)には汎用的なスタックウォーカーが存在しなかったからです(今でも存在しません)。
libunwindstack はNDKの一部ではなく、Android Open Source Project(AOSP)のプラットフォームコードの一部であるため、通常の方法ではアプリ開発者から直接利用できません。Sentryは、NDKでビルドできるようプラットフォームコードへパッチを当てたリポジトリをフォークし、その後も上流のパッチ版に変更がないまま、そのフォークを維持してきました。
これにより、非常に複雑なAndroid Runtime(ART)環境の中でスタックトレースを取得できるようになりました。ARTでは、通常のネイティブコード、VM実行の一部であるネイティブコード、さらにJava/Kotlinフレームが混在します。Java/Kotlinフレームも、インタープリタ実行・JIT・AOTのいずれかで動作しているため、ネイティブフレームとして現れます。
これは通常のスタックウォーカーにとってすでに困難ですが、シンボリケーションの観点でも問題になります。Sentryが現実的に収集できる以上に多くのOEMビルドが存在するためです。そのため、主要なライブラリ群はバックエンド側に存在していても、すべてを利用できるとは限りません。結果として、Androidにおけるシンボリケーションはクライアント側で実行されます。しかし、プラットフォームコードの大規模な再構成が進むにつれ、手動で上流コードに追従する作業は時間とともに非常に困難になっていきました。
さらに、libunwindstack はC++ライブラリであるため、標準ライブラリの使用量自体は少ないものの、ABI非互換な標準ライブラリバージョンからランタイム時に隔離するため、標準ライブラリを静的リンクする必要があります。
これにより、いくつかの課題も生じました。
- 最大の問題は常にサイズでした。x86、x86_64、armeabi-v7a、arm64-v8a 向けのバイナリを同梱する必要があるため、現在ではネイティブエラーレポートやインストルメンテーションを必要とするすべてのアプリに対し、ストリップ済みバイナリを約1MiB追加しています。そのうちSentry SDKコード自体が占めるのは20%程度で、残りは libunwindstack と、それが依存するC++インフラです。
- 実装の不完全さもあります。ライブラリサイズがすでに大きいため、一部機能はビルドから除外されています。現時点ではDEX/OATシンボリケーションが存在せず(つまりJavaフレームはシンボリケーションされません)、さらにOATフレーム内のDWARF CFI探索サポートも不完全です。その結果、リリースビルドではスタックトレースが極端に短くなることがあります。
- コンテキストの不足も問題でした。Androidでクラッシュ処理を行う inproc バックエンドは、設計上スレッドを停止しません。そのため、取得できるのはクラッシュしたスレッドのスタックトレースだけです。特にAndroidでは、これだけではクラッシュの根本原因を突き止めるには不十分なことが多くあります。
これらの問題はいずれも修正可能ではありますが、そのためには大きな労力が必要であり、さらに変化し続けるAOSPに長期的に追従し続ける必要があります。
tombstoneサポートの導入により、Android 12以降を利用するユーザーに対して、ここまで挙げた問題をすべて解決できるようになります。Android 12以上は、受信イベント数・ユーザーベースの両方において急速に割合を増やしています。同時に、これによってエッジケース向けのより良い解決策に取り組む道も開かれます。

Android 12+ は、過去30日間に取り込まれた20億件以上のAndroidエラーイベントのうち、約69%を占めています。
tombstone によって得られるもの
前述した問題(サイズ、不完全なトレース、Javaシンボリケーションの欠如、保守負荷)はすべて、デバイス上にすでに存在するプラットフォームのクラッシュ基盤を再実装していたことに起因しています。
tombstone は、それらをすべて解決します。
- すべてのスレッドを完全にシンボリケーション
inproc バックエンドではクラッシュしたスレッドしか取得できませんでしたが、tombstone ではクラッシュ時点のすべてのスレッドのスタックトレースとレジスタセットを取得できます。Androidでは、クラッシュしたスレッドは単に別スレッドで発生した問題の被害者であることがよくあります。全スレッドを確認できるかどうかは、解決可能なクラッシュになるか、不可解な問題のまま終わるかの違いになります。 - Java/Kotlinフレームの解決
プラットフォームのアンワインダは、フォークされたNDKビルドではアクセスできないART内部情報へ完全にアクセスできます。バイナリサイズ削減のため意図的に除外していたDEX/OATシンボリケーションも、追加コストなしで利用できます。 - スタックトレースのためのバイナリオーバーヘッドが不要
tombstone は、プラットフォーム自身の libunwindstack によって生成されます。これは、私たちがこれまでフォークして配布していたものと同じライブラリです。サポート対象ABI向けに必要だった約1MiBのバイナリ増加は、tombstone のみを利用するアプリではゼロになります。 - 保守負荷をプラットフォーム側へ移譲
AOSPの構造変更を追跡し、NDK向けにC++フォークを維持する代わりに、私たちは構造化された出力を利用するだけで済みます。 - レジスタのメモリコンテキスト
クラッシュしたスレッドのレジスタ内ポインタ周辺のメモリダンプによって、クラッシュ時に処理されていたデータを確認できます。(現時点ではSentryイベントペイロードやUIにはまだ統合されていません。) - シンボル解決
クライアント側でモジュール情報と解決済みシンボルを取得できるようになったため、送信前にランタイム内部フレームなど、アクションにつながらないトレース内容を除去することも可能になります。
使用方法
tombstone サポートは sentry-android-core バージョン8.30.0以降で利用できます。
Android 12以上で動作するアプリで tombstone を有効化すると、アプリに影響するすべてのネイティブクラッシュについて、より完全なレポートが自動的に送信されるようになります。Native SDK / NDK統合を使用していた場合でも、すべてのスレッドに対してより良いスタックトレースが取得でき、ネイティブ側で設定したコンテキストも引き続き確認できます。
もしネイティブコード内で Native SDK インターフェースを直接使用したことがないのであれば、NDK統合を無効化する選択肢を検討できます。アプリ利用者の多くがAndroid 12以上へ移行している場合、両方の統合を同時に動かし続ける意味はもはやありません。
一方で、Native SDK インターフェースを現在も直接使用している場合でも、両統合はユーザー体験を損なうことなく共存できます。
この機能を有効化したい場合は、SentryAndroidOptions を通じてプログラム的に設定できます。

または、以下のように宣言的に記述しますAndroidManifest.xml。

tombstone はクラッシュ時点のすべてのスレッドを取得するため、Issue詳細ビューから任意のスレッドを直接確認できます。

「Most Relevant」ビューでは、トレースから実際に対処すべきフレームだけを抽出します。Issueのグルーピングや命名に使われる inApp JNI フレームを分離しつつ、Jetpack Compose のレイヤーは除外されます。

折りたたまれたフレームを展開すると、完全な流れを確認できます。__libc_init から始まり、プロセス起動、Androidのメッセージループ、ネイティブ/Javaランタイム間の境界をまたぐ処理、さらにビュー層を経由してクラッシュ地点へ至るまでの全体像が表示されます。

実装上の課題
tombstone サポートはSDKの多くのレイヤーに影響を与えました。これは、ネイティブクラッシュレポートがセッション管理、イベント重複排除、Envelopeキャッシュ、イベント拡張、さらに既存のNDK統合など、同じ種類のクラッシュをまったく異なる仕組みで扱う機能群と密接に関わっているためです。
ANR検出とのインフラ共有
最も直接的なアーキテクチャ上の課題は、SDKがすでに ApplicationExitInfo を利用する統合を持っていたことでした。それが REASON_ANR を扱うANR統合です。両方の統合は同じライフサイクルを必要とします。履歴上の終了リストを問い合わせ、すでに報告済みのエントリをスキップし、最新の(拡張可能な)エントリと古い(履歴上の)エントリを区別し、「最後に報告した」タイムスタンプマーカーを永続化し、前回セッションのフラッシュ完了を待ち、イベントがディスクへ書き込まれるまでブロックします。
これを複製するほうが早い道でしたが、実際の実装では代わりに、ポリシーインターフェースによってパラメータ化された汎用履歴ディスパッチャが抽出されました。各統合はポリシー(対象 reason、historical フラグ、レポートビルダー)を実装し、一方でディスパッチャ側は走査、順序管理、重複排除、フラッシュ調整を担います。Envelopeキャッシュのタイムスタンプマーカーシステムも同様に一般化され、ANRとtombstoneの両方のマーカーがポリモーフィックに処理されるようになりました。
このリファクタリングは、イベント処理にも連鎖的な影響を与えました。既存のANRイベントプロセッサはANR前提に強く結び付いており、すべての「backfillable」イベント(発生時にはそのセッションのライブスコープへアクセスできないものの、フォレンジック的にスコープを再構築できるイベント)をANRであるかのように拡張していました。そこへ tombstone も backfillable イベントとして流れるようになったため、プロセッサは拡張戦略インターフェースを持つ形へ一般化されました。
ANR固有のロジック(テキスト形式スレッドダンプからの例外生成、バックグラウンド/フォアグラウンドのフィンガープリンティング、プロファイルベースの culprit 特定)は専用の enricher へ移されました。同時に、共有される処理経路(スコープ復元、オプション復元、デバイス/OSコンテキスト)は、tombstone が独自 enricher を必要とせず完全に利用できる汎用デフォルト処理となりました。
この新しいインフラのかなりの部分は、他の ApplicationExitInfo カテゴリにも再利用できるようになっています。生成される成果物がイベントではなく、SDKクライアントレポート内のエントリになるケースでも利用される可能性があります。
NDK統合との共存
より深い問題は、tombstone と既存のSentry NDK統合(sentry-native-ndk を使用)が同じクラッシュを報告することでした。Native SDK は独自のシグナルハンドラによってランタイム時にシグナルを捕捉し、「outbox」に envelope を書き込みます。一方 tombstone は、Native SDK のシグナルハンドラが前のハンドラへチェーンした後に呼び出される、プラットフォーム側の debuggerd によって生成されます。しかし tombstone は、プロセスが終了した後、次回起動時に ApplicationExitInfo 経由でしか取得できません。
両方の統合が有効になっている場合、すべてのネイティブクラッシュが重複して報告されます。しかし完全な情報を得るには両方が必要です。tombstone が持つ、より豊富なスタックトレース、全スレッド情報、最新のメモリマップと、Native SDK が持つユーザー提供のスコープデータを組み合わせる必要があるためです。そのため、どちらか一方を単純に無効化することはできません。
これを解決するには、2つのイベントをタイムスタンプ(5秒以内の誤差許容)で関連付け、1つにマージする必要がありました。関連付け自体は単純でした。複雑だったのは、2つのイベントがマージされるまでに通る経路が異なっていたことです。
Native SDK は envelope を共有アプリディレクトリ(「outbox」)へシリアライズします。これは Android SDK に対して、送信可能な envelope が存在することを示すシグナルとして機能します。しかしネイティブクラッシュでは、このシグナルはクラッシュ中には通常の outbox 送信インフラに間に合いません。そのため次回起動時、このインフラはすべての envelope を完全にメモリへ読み込みます。本来の目的がバックエンドへの送信だけだからです。もしこれをマージ対象探索にも再利用していた場合、マージ対象となる1件のネイティブクラッシュイベントを見つけるためだけに、キュー内のすべての envelope をメモリへデシリアライズする必要がありました。オフライン状態で envelope が蓄積された端末では、ほぼ常に1件しか一致しないにもかかわらず、メモリ負荷とCPU負荷が急増することになります。
その代わりに、軽量なスキャンフェーズが導入されました。このフェーズでは各 envelope ファイルをストリーミング処理し、アイテムヘッダだけを解析し、完全なイベントをデシリアライズせずに streaming JSON を使って platform と timestamp フィールドだけを抽出します。bounded input stream は各 envelope アイテム内の位置を追跡し、未読バイトをスキップしながら次のアイテムへ正しく進みます。完全なデシリアライズは、タイムスタンプ一致が見つかった時点でのみ行われます。この結果として生まれたストリーミング envelope / event パース基盤は、SDKの他の部分でも再利用できる可能性があります。
マージ後のイベントには、既存の Tombstone および signalhandler メカニズムに加えて TombstoneMerged 例外メカニズムが付与され、バックエンド、開発者、顧客がイベントの由来を識別できるようになっています。
Session と crashedLastRun のライフサイクル
ネイティブクラッシュレポートは、慎重な調整を必要とする形でセッショントラッキングと相互作用します。tombstone 統合がクラッシュを処理する際には、前回セッションを crashed として終了し、crashedLastRun を true に設定する必要があります。しかし NDK統合には、このための独自メカニズムがすでに存在していました。次回起動時に session finalizer が確認するクラッシュマーカーファイルです。
そこで、専用のマーカーヒント(ANR用に使われるものとは意図的に別)が導入されました。これにより envelope キャッシュは、セッション永続化時に tombstone イベントを認識できるようになります。このヒントを検出すると、クラッシュタイムスタンプ付きで前回セッションを crashed として終了します。その後 session finalizer は、すでに crashed 状態になっていることを検出し、NDKクラッシュマーカーを再処理することなく crashedLastRun を設定します。重要なのは、tombstone 統合がクラッシュを処理したかどうかに関係なく、ネイティブクラッシュマーカーファイル自体は必ずクリーンアップされることです。そうしないと、NDK経路が以後の起動ごとに再報告してしまいます。
protobuf依存問題
Android の tombstone は、AOSPで定義された protobuf フォーマット(tombstone.proto)を使用しています。初期実装では protobuf-javalite を使ってデコードしていましたが、これがすぐに、すでに protobuf を利用しているSDK利用者(通常はFirebase経由)とのバージョン競合を引き起こしました。最初のリリースから1か月以内に、私たちはこれを epitaph に置き換えました。これは tombstone protobuf エンコーディング用に手書きされたデコーダで、推移的依存関係を持たず、サイズも約30KiBです。また、AOSP側で tombstone protobuf スキーマに変更が加えられた場合に早期検知できるよう、CIワークフローも追加しました。これにより、プラットフォーム側で重大なフォーマット変更が発生した際にすぐ把握できます。
これらすべての課題に共通するテーマは、ネイティブクラッシュレポートが単独で完結する機能ではないという点です。それはSDKのイベントパイプライン、セッションライフサイクル、ディスクキャッシュ、既存のNDK統合が交差する場所に存在しています。そして、それぞれのコンポーネントは、自分だけがその領域を担当する前提で設計されていました。
tombstone サポートを追加するということは、これらのコンポーネントに「共有」を学ばせることを意味していました。ANR検出との履歴ディスパッチャ共有、NDK統合との outbox 共有、新しいクラッシュソースとの session finalizer 共有、新しいイベントカテゴリとのイベントプロセッサ共有です。私たちは、それぞれの交差点において複製ではなくリファクタリングを選びました。その結果、最初のPRは大きくなり、レビューサイクルも少し長くなりましたが、最終的にアーキテクチャは少なくとも元と同じ程度には整理された状態に保たれました。特に共通Java SDKコアについては、動作上の変更は一切ありませんでした。
ギャップを埋める
tombstone サポートは、SentryがAndroid向けネイティブクラッシュレポートを最初に提供して以来存在していたギャップを埋めます。それは、プラットフォームがクラッシュについて知っている情報と、SDKが提供できる情報との間の差です。
このギャップは、アプリ内でプラットフォーム自身のクラッシュ基盤を部分的に再実装できる以上、一見すると恣意的なものに見えるかもしれません。しかし実際には、そのためにバイナリサイズ、保守負荷、そして依然として不完全な結果という代償を支払っていました。ApplicationExitInfo によって debuggerd が生成するデータへプログラム的にアクセスできるようになったことで、より少ないオーバーヘッドとより少ない可動部分で、より豊富なクラッシュコンテキストを提供できるようになりました。
もちろん制約もあります。この仕組みは Android 12 以上でしか動作しません。古いデバイスや、単なるエラーレポート以上にネイティブコードのインストルメンテーションを必要とするアプリでは、NDK統合は引き続き利用可能であり、両者は問題なく共存できます。しかし Android 12+ は、2026年3月時点で累積利用分布の75%を占めています(apilevels.com調べ)。すでにバランスは変わりました。現在、多くのアプリにおいて tombstone サポートが主要なネイティブクラッシュレポート経路となっており、sentry-native-ndk はフォールバックになっています。
tombstone サポートは sentry-android-core 8.30.0 以降で利用できます。設定方法や、アプリでNDK統合を維持するべきか無効化するべきかについては、Android SDKドキュメントを参照してください。
Original Page: Grave improvements: Native crash postmortems via Android tombstones
IchizokuはSentryと提携し、日本でSentry製品の導入支援、テクニカルサポート、ベストプラクティスの共有を行なっています。Ichizokuが提供するSentryの日本語サイトについてはこちらをご覧ください。またご導入についての相談は「お問い合わせ」からお気軽にお問い合わせください。

