28日間で50人以上のユーザーが同じクラッシュで落ちていると気づいたのは、Firebase Crashlytics のダッシュボードを眺めていた夜のことでした。
個人で壁紙アプリを開発・運営していると、クラッシュ対応は他の作業に押し出されて後回しになりがちです。壁紙アプリ「Beautiful HD Wallpapers」の Android 版 v2.0.0 をリリースした直後、Crashlytics の Issues タブに java.lang.IndexOutOfBoundsException: Inconsistent detected. Invalid view holder adapter position という行が静かに増えていきました。RecyclerView のアダプターに何かが起きています。でも「何が」おかしいのか、スタックトレースだけでは見えてきませんでした。
この記事は、その問題を Gemini API を使って解析するパイプラインを構築し、実際に根本原因を特定するまでの記録です。コードはすべて動作確認済みです。Firebase Crashlytics REST API と Gemini API を組み合わせた自動分析の仕組みを、最初から作れるようにまとめました。
Crashlytics ダッシュボードの限界と、Gemini に任せた理由
Firebase Crashlytics は優れたツールです。クラッシュの件数、影響ユーザー数、スタックトレース、デバイスモデルと OS バージョンの分布——これらが一箇所に集まります。ただ、「なぜ起きたか」を教えてくれるわけではありません。
RecyclerView の IndexOutOfBoundsException は、特に原因が複数考えられる問題です。データ更新と UI スレッドの非同期競合、notifyDataSetChanged() と個別通知メソッドの混在、メインスレッド外でのデータリスト変更、アダプターが保持するリストと実際のデータの参照が別になっている——どれも「ありうる」原因です。一つひとつ仮説を立てて試すのは時間がかかります。個人開発者にとって、クラッシュ調査に費やす時間はそのまま機会損失につながります。
また、Crashlytics のダッシュボードだけ見ていると、どのクラッシュを最初に修正すべきか優先順位をつけるのも難しいと感じていました。発生回数が多いものが必ずしも最も影響が大きいわけではなく、ユーザーの離脱率に直結しているのは「特定の操作中に落ちるクラッシュ」だったりします。優先順位の判断もある程度自動化できれば、と思っていました。
そこで試みたのが、クラッシュログをそのまま Gemini API に渡して構造化した分析を返してもらう、というアプローチでした。結論から言えば、Gemini 2.5 Pro は最初の試行で「参照の分離(defensive copy の欠如)」を根本原因として挙げ、修正コードの骨格まで提示しました。ダッシュボードを眺めて仮説を立てるよりも、はるかに早く答えに辿り着けました。
Firebase Crashlytics REST API でログを取得する
まず Crashlytics の生ログを自動で取得する仕組みが必要です。Crashlytics はダッシュボード上でしか見られないと思われがちですが、Firebase Admin SDK と REST API を通じてプログラムから取得できます。
Firebase コンソール → プロジェクトの設定 → サービスアカウント → 「Firebase Admin SDK の秘密鍵を生成」で JSON をダウンロードします。このファイルを安全な場所に保管し、GitHub Actions の Secrets に登録します。ローカルに置いたまま git に追加しないよう、.gitignore に必ず記載してください。
from google.oauth2 import service_account
import google.auth.transport.requests
import requests
import json
SCOPES = [ "https://www.googleapis.com/auth/cloud-platform" ]
SERVICE_ACCOUNT_FILE = "serviceAccountKey.json"
credentials = service_account.Credentials.from_service_account_file(
SERVICE_ACCOUNT_FILE , scopes = SCOPES
)
def get_access_token (credentials) -> str :
if not credentials.valid:
credentials.refresh(google.auth.transport.requests.Request())
return credentials.token
次に Crashlytics REST API でイシューを取得します。フィルタリングのクエリパラメータを活用することで、影響の大きいイシューだけに絞れます。occurrenceCount と affectedUsersCount の両方でフィルタするのが実用的です。件数が多くても影響ユーザーが少ない場合(例: 特定の機種固有のバグ)は後回しにして構いません。
def get_crash_issues (
project_id: str ,
app_id: str ,
credentials,
min_occurrence_count: int = 10 ,
page_size: int = 20
) -> list[ dict ]:
token = get_access_token(credentials)
headers = {
"Authorization" : f "Bearer { token } " ,
"Content-Type" : "application/json"
}
url = (
f "https://firebaseappdistribution.googleapis.com/v1alpha/"
f "projects/ { project_id } /apps/ { app_id } /issues"
)
params = {
"pageSize" : page_size,
"filter" : f "state=open AND occurrenceCount>= { min_occurrence_count } " ,
"orderBy" : "occurrenceCount desc"
}
response = requests.get(url, headers = headers, params = params)
response.raise_for_status()
return response.json().get( "issues" , [])
Gemini API でクラッシュログを構造化分析する
取得したクラッシュイシューを Gemini API に渡します。ここで重要なのは「自然言語の回答」ではなく「構造化データ」で返してもらうことです。JSON 形式にすることで、後続の処理(GitHub Issues 自動起票・Slack 通知など)に直接使えます。
なぜ構造化出力にこだわるかというと、自然言語の回答を後から解析しようとすると、Gemini の回答スタイルが変わるたびにパースロジックが壊れるリスクがあるからです。Pydantic でスキーマを定義して response_mime_type="application/json" を指定することで、常に同じ形式の JSON が返ってくることを保証できます。
from pydantic import BaseModel, Field
from typing import Optional
import google.generativeai as genai
class CrashAnalysis ( BaseModel ):
root_cause: str = Field( description = "クラッシュの根本原因(1〜3文で簡潔に)" )
affected_components: list[ str ] = Field( description = "影響を受けているコンポーネント名のリスト" )
fix_approach: str = Field( description = "修正のアプローチと理由(3〜5文)" )
code_fix_snippet: str = Field( description = "Kotlin による修正コードの例" )
confidence: str = Field( description = "分析の信頼度: high / medium / low" )
additional_info_needed: Optional[ str ] = Field(
default = None , description = "追加情報があれば精度が上がる点"
)
genai.configure( api_key = "YOUR_GEMINI_API_KEY" )
model = genai.GenerativeModel( "gemini-2.5-pro" )
プロンプト設計で気づいたこと
プロンプトに渡す情報は「スタックトレースだけ」では不十分でした。OS バージョンの分布と、クラッシュが起きているクラスの簡単なコードコンテキストを加えることで、分析精度が大きく変わります。私が試行錯誤した結果、以下の情報を必ず含めるようにしました。
最初のバージョンのプロンプトは単純にスタックトレースを渡すだけでした。その場合の回答は「メインスレッドでの notifyDataSetChanged() の呼び出しを確認してください」という一般的なものでした。役に立つ情報ではあるものの、具体的な修正箇所を特定するには不十分です。
2つ目のバージョンで OS バージョン分布を追加したところ、回答が少し具体的になりました。クラッシュが特定 OS バージョンに偏っていない(Android 10、11、12 にわたって発生している)という事実から、OS 固有の問題ではなくアーキテクチャの問題だと Gemini が絞り込み始めたのです。
3つ目のバージョンでクラッシュが起きている WallpaperGridFragment.onLoadFinished() の実装概要を加えたところ、「防御的コピーの欠如」という具体的な診断が出ました。このプロセスを経て、現在のプロンプト形式に落ち着いています。
def analyze_crash (issue_data: dict , code_context: str = "" ) -> CrashAnalysis:
stack_trace = issue_data.get( "stackTrace" , "Not available" )
exception_type = issue_data.get( "exceptionType" , "Unknown" )
occurrence_count = issue_data.get( "occurrenceCount" , 0 )
affected_users = issue_data.get( "affectedUsersCount" , 0 )
os_versions = issue_data.get( "affectedOsVersions" , [])
context_section = ""
if code_context:
context_section = f " \n ## 該当箇所のコード \n ```kotlin \n{ code_context }\n ``` \n "
prompt = f """あなたは Android アプリ開発の専門家です。
以下のクラッシュレポートを分析し、根本原因と修正方法を Kotlin コード付きで提示してください。
例外タイプ: { exception_type }
発生回数: { occurrence_count }
影響ユーザー数: { affected_users }
影響 OS バージョン: { ', ' .join( str (v) for v in os_versions) }
スタックトレース:
{ stack_trace }
{ context_section }
"""
response = model.generate_content(
prompt,
generation_config = genai.GenerationConfig(
response_mime_type = "application/json" ,
response_schema = CrashAnalysis.model_json_schema(),
)
)
return CrashAnalysis( ** json.loads(response.text))
RecyclerView IndexOutOfBoundsException の実例 — Gemini は何と言ったか
Beautiful HD Wallpapers v2.0.0 で実際に発生したクラッシュのデータを渡したときの Gemini 2.5 Pro の回答を紹介します。スタックトレースは実際のものをベースに一部簡略化しています。
入力したスタックトレース(抜粋)と OS バージョン別分布(Android 10: 38%、Android 11: 29%、Android 12: 22%、その他: 11%)を渡したところ、Gemini 2.5 Pro から confidence: "high" の回答が返ってきました。根本原因として「WallpaperAdapter が保持する wallpapers リストがバックグラウンドスレッドから直接変更されており、UI スレッドが保持するビューホルダーの位置情報と不整合が生じています。防御的コピーが欠如しており、外部から渡されたリストの参照をそのまま保持しているのが原因」と診断されました。
この診断を受けて実際のコードを確認したところ、指摘通りでした。
// ❌ Before: 外部から渡されたリストの参照をそのまま持つ
class WallpaperAdapter : RecyclerView . Adapter < WallpaperAdapter . ViewHolder >() {
private var wallpapers: MutableList < Wallpaper > = mutableListOf ()
fun updateWallpapers (newData: List < Wallpaper >) {
wallpapers. clear ()
wallpapers. addAll (newData) // バックグラウンドで同じリストが変更される可能性
notifyDataSetChanged ()
}
override fun getItemCount () = wallpapers.size
}
// ✅ After: 防御的コピーでスレッドセーフに (v2.1.0 での修正)
class WallpaperAdapter : RecyclerView . Adapter < WallpaperAdapter . ViewHolder >() {
private var wallpapers: List < Wallpaper > = emptyList ()
fun updateWallpapers (newData: List < Wallpaper >) {
wallpapers = newData. toList () // 新しいリストのコピーを作成
notifyDataSetChanged ()
}
override fun getItemCount () = wallpapers.size
}
さらに根本的な改善として、ListAdapter + DiffUtil への移行も実施しました。submitList() はスレッドセーフに差分計算してくれるため、防御的コピーを手動で管理する必要がなくなります。この修正を v2.1.0 に適用した結果、28日間で50件以上発生していたこのクラッシュは、v2.1.0 リリース後に消滅しました。
スタックトレースだけを渡した最初の試行では一般的な回答しか得られませんでしたが、クラッシュが起きているクラスのコードの断片(50〜100行程度)を一緒に渡すと、診断精度が大きく向上しました。これは私が試行錯誤の中で気づいた最も重要な教訓です。
もう一つの実例 — Glide 5.0.5 + AGP 9.x の Java 8 デシュガリング問題
同じく v2.1.0 の開発中に直面した別の問題も Gemini で分析しました。症状は Android 6.0.1(API 23)のユーザーでのみ java.lang.NoClassDefFoundError: Failed resolution of: Ljava/util/function/Supplier; が発生し、Android 10 以降では再現しない、というものでした。
OS バージョン分布が「Android 6.0.1 のみ」で発生しているというデータを Gemini に渡した点が決定的でした。この情報から Gemini は即座に「OS バージョン固有の問題 → 古い Android での API 互換性の問題」という方向に絞り込み、Glide 5.0.5 が使用している java.util.function.Supplier(Java 8 API)が Android 6.0.1 でサポートされていないという根本原因を特定しました。
修正は app/build.gradle.kts に isCoreLibraryDesugaringEnabled = true を追加し、desugar_jdk_libs の依存関係を追加するだけでした。
android {
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
}
dependencies {
coreLibraryDesugaring ( "com.android.tools:desugar_jdk_libs:2.1.4" )
}
この 1 行の追加で、Android 6.0.1 ユーザー全員のクラッシュが消えました。AGP 9.x からデシュガリングのデフォルト挙動が変わったことで、以前は動いていた設定が機能しなくなっていたというのが Gemini の診断で、Glide のバージョンと AGP のデシュガリング設定の関係を即座に紐付けていた点には正直驚きました。
自動分析パイプラインの全体設計
個別のクラッシュを手動で Gemini に渡すだけでなく、定期的に自動実行する仕組みを作ると実用的です。私が採用した構成は、Firebase Crashlytics → Python スクリプト(GitHub Actions)→ Gemini API → GitHub Issues 自動起票 + Slack 通知、という流れです。
毎日 09:00 JST(= 00:00 UTC)に GitHub Actions が起動し、前日の未解決イシューの中から occurrenceCount > 10 のものをフィルタリングして Gemini に送ります。重大なクラッシュ(affected_users >= 20 または occurrence_count >= 50)は Gemini 2.5 Pro で詳細分析し、中程度のクラッシュは Flash で初期スクリーニングします。分析結果は GitHub Issues として自動起票され、Slack チャンネルにも通知されます。
name : Daily Crash Analysis with Gemini
on :
schedule :
- cron : '0 0 * * *'
workflow_dispatch :
jobs :
analyze-crashes :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- uses : actions/setup-python@v5
with :
python-version : '3.12'
- run : pip install google-generativeai google-auth requests pydantic
- run : python scripts/crash_analysis.py
env :
GEMINI_API_KEY : ${{ secrets.GEMINI_API_KEY }}
FIREBASE_SERVICE_ACCOUNT : ${{ secrets.FIREBASE_SERVICE_ACCOUNT }}
GITHUB_TOKEN : ${{ secrets.GITHUB_TOKEN }}
SLACK_WEBHOOK_URL : ${{ secrets.SLACK_WEBHOOK_URL }}
トリガー条件の設計
全クラッシュを Gemini で分析するとコストが増えます。以下の条件でフィルタリングしています。
def should_analyze (issue: dict ) -> tuple[ bool , str ]:
occurrence_count = issue.get( "occurrenceCount" , 0 )
affected_users = issue.get( "affectedUsersCount" , 0 )
is_regression = issue.get( "isRegression" , False )
if affected_users >= 20 or occurrence_count >= 50 :
return True , "gemini-2.5-pro" # 重大クラッシュ: Pro で詳細分析
if occurrence_count >= 10 or affected_users >= 5 :
return True , "gemini-2.5-flash" # 中程度: Flash でスクリーニング
if is_regression:
return True , "gemini-2.5-pro" # リグレッション: Pro で精査
return False , ""
月間 AU 300 万のアプリでこの条件を使うと、1日に分析対象になるイシューは平均 3〜7 件です。
Slack 通知の実装
分析結果を Slack に流すことで、クラッシュの発生を見逃しにくくなります。
import requests as http_requests
def notify_slack (webhook_url: str , analysis: CrashAnalysis, issue_data: dict ) -> None :
message = {
"text" : f ":bug: *クラッシュ検出* — { issue_data.get( 'exceptionType' , 'Unknown' ) } " ,
"blocks" : [
{
"type" : "section" ,
"text" : {
"type" : "mrkdwn" ,
"text" : (
f "*根本原因*: { analysis.root_cause }\n "
f "*発生回数*: { issue_data.get( 'occurrenceCount' , 'N/A' ) }\n "
f "*影響ユーザー*: { issue_data.get( 'affectedUsersCount' , 'N/A' ) }\n "
f "*Gemini 信頼度*: { analysis.confidence } "
)
}
}
]
}
http_requests.post(webhook_url, json = message, timeout = 10 )
コスト試算と Flash/Pro の使い分け基準
Gemini の API 費用を具体的に試算します。クラッシュ分析の入力トークン数は、スタックトレース・OS バージョン分布・コードコンテキストを含めると 2,000〜4,000 トークン程度です。出力の構造化 JSON は 500〜1,200 トークン程度です。
2026年5月時点の価格で比較すると、Gemini 2.5 Flash は入力 $0.15/1M トークン・出力 $0.60/1M トークンで 1分析あたり約 $0.001、Gemini 2.5 Pro は入力 $1.25/1M トークン・出力 $5.00/1M トークンで 1分析あたり約 $0.010 です。
1日5件(Flash 3件 + Pro 2件)・月150件として試算すると、Flash 90件で月額 $0.09、Pro 60件で月額 $0.60、合計で月額 $0.69(約100円)です。現実的な範囲に収まっています。
実験の結果、Flash は「よくあるパターン」のクラッシュには十分ですが、アプリ固有のアーキテクチャに関わる問題では Pro の方が明らかに精度が高いと感じています。Flash の診断精度が不十分だと感じる場面が増えてきたら、Flash の confidence: "low" をエスカレーションのトリガーとして Pro に再分析させる二段階方式が費用対効果の面でも最適です。
実運用して分かった限界と補完戦略
Gemini によるクラッシュ分析は強力ですが、限界もあります。得意なのは、RecyclerView・NullPointerException などの既知のパターン、ライブラリのバージョンアップに起因する互換性問題(OS バージョン分布との照合)、スタックトレースから影響コンポーネントを特定することです。
苦手なのは、アプリ固有のビジネスロジックに起因するクラッシュ(コードなしには判断不可)、タイミング問題(競合状態)の特定(スタックトレースだけでは情報が不足)、カスタムライブラリ内部の問題です。
補完策として、苦手なケースにはプロンプトに「アーキテクチャの概要」と「該当クラスのコード全体」を追加しています。コードが 300〜500 行程度なら Gemini 2.5 Pro の長大なコンテキストウィンドウで問題なく処理できます。また、confidence: "low" を返したケースは、私が手動でスタックトレースを精査するフラグとして活用しています。完全な自動化ではなく、「Gemini で初期スクリーニング → 人間が判断」という協調モデルが実用的です。
Play Console の「Android Vitals」セクションと連携させることも検討しています。Play Console は Crash-free users の比率を日次で追跡しており、この数値が下がったタイミングで GitHub Actions を手動トリガーして即時分析する、という流れにすると、問題の発覚から対応開始までの時間をさらに短縮できそうです。
Gemini の診断が外れたときの対処法
ここまで成功事例を中心に書きましたが、Gemini の診断が外れることも当然あります。実運用の中で気づいたパターンを共有します。
最も多いケースは、Gemini が「スレッド問題」という大きなカテゴリは正しく特定するものの、具体的なコード上の原因を別の箇所に誤帰属することです。例えば「アダプターの notifyDataSetChanged() がメインスレッドで呼ばれていない」という診断が出たが、実際にはメインスレッドで呼んでいた、ということがありました。この場合、Gemini が「正しい問題の隣にある問題」を指摘していたことになります。スレッド問題という方向性は合っていたため、その周辺を丁寧に見直したことで最終的に原因を特定できました。
また、Crashlytics のスタックトレースは Android のバージョンや minification の設定によって情報量が変わります。ProGuard や R8 でコードが難読化されている場合、クラス名やメソッド名が短縮されており、Gemini がコードの文脈を正確に読めないことがあります。この場合は mapping.txt(R8 のマッピングファイル)をプロンプトに含めると改善します。Crashlytics は自動で mapping.txt をアップロードする機能を持っているので、Firebase コンソール上では逆難読化されたスタックトレースを見られますが、REST API 経由で取得する場合は注意が必要です。
Gemini が confidence: "medium" または "low" を返した場合は、私は必ず手動で確認するようにしています。「信頼度が低い」という自己申告を信用することが大切で、low の場合に自動起票してしまうと、エンジニアの注意を無駄に向けてしまいます。low は「情報が足りない」サインとして捉え、追加情報を用意してから再分析する、というフローが実用的です。
Play Console Android Vitals との連携
Firebase Crashlytics の自動分析と、Google Play Console の Android Vitals を連携させることで、クラッシュ対応のトリガーをさらに洗練できます。
Android Vitals では Crash-free users rate(クラッシュが発生しなかったユーザーの割合)が日次で追跡されています。この数値が前日比で一定以上下がった場合、即座に Crashlytics 分析を GitHub Actions で手動トリガーする仕組みを作ると、日次実行を待たずに問題を把握できます。
私は現在、Crash-free users rate が 99.5% を下回ったときに Slack に通知が来るようにしており、その通知を受けてから GitHub Actions を手動で発火させています。完全な自動化ではありませんが、問題の発覚からGemini 分析の結果を得るまでの時間が大幅に短縮されました。月間 AU 300 万のアプリで、重大なクラッシュのリリースから発見・修正開始までの平均時間が、この仕組みを導入する前と比べて体感で半分以下になっています。
このアプローチを iOS にも適用する
今回は Android + Firebase Crashlytics の組み合わせで説明しましたが、iOS アプリでも同じアプローチが使えます。iOS の場合は Firebase Crashlytics の iOS SDK をそのまま使えるため、REST API のエンドポイントは共通です。ただし、iOS のスタックトレースは Swift のシンボル情報の形式が異なるため、プロンプトの冒頭に「これは iOS Swift アプリのスタックトレースです」と明示すると、Gemini が正しい文脈で解析してくれます。
私が開発している 4 本の iOS アプリ(Beautiful HD Wallpapers、Ukiyo-e Wallpapers、Relaxing Healing、Law of Attraction Everyday)でも同じパイプラインを試しており、特に Firebase CocoaPods から Swift Package Manager への移行作業中に発生した依存関係由来のクラッシュで効果を発揮しました。移行に伴う dSYM のアップロード設定の問題も Gemini が指摘してくれた事例の一つです。
このように、Firebase を使っているプロジェクトであれば Android・iOS を問わず同じパイプラインが使えることは、複数プラットフォームを一人で管理する個人開発者にとって特に大きなメリットです。
全体を振り返って — まず 1 件のクラッシュで試してみる
Firebase Crashlytics REST API と Gemini API を組み合わせることで、クラッシュ分析を半自動化できます。まず試してほしいのは、Crashlytics のダッシュボードから影響ユーザー数が最も多い未解決イシューを 1 件選び、スタックトレースと OS バージョン分布を今日紹介したプロンプト形式で Gemini に渡してみることです。Gemini の診断精度を自分の目で確認してから、自動化パイプラインの構築に進むのが現実的な進め方です。
パイプラインの構築そのものはそれほど複雑ではなく、Python スクリプト 200 行程度と GitHub Actions の YAML ファイルで完結します。最初から完全な自動化を目指す必要はなく、「Gemini に分析させる → 結果を Slack で確認 → 良さそうであれば GitHub Issue 起票ボタンを押す」という半自動のフローから始めるのが無理なく導入できる方法です。運用しながら信頼度の閾値や通知ルールを調整していけば、数週間で自分のアプリに合った設定が見えてきます。
クラッシュ対応でいちばん厄介なのは、ユーザーが既に離れてしまった後になって問題へ気づくことです。原因の切り分けに費やしていた時間を Gemini に肩代わりさせると、この「気づきの遅れ」が構造的に縮みます。ダッシュボードを眺めて仮説を並べる代わりに、最初の一手として構造化された診断が手元に届く。その差が、個人開発のリリースサイクルにそのまま効いてきます。
まずは 1 件のクラッシュで診断精度を自分の目で確かめ、手応えがあれば半自動フローへ広げてみてください。同じ課題に取り組んでいる方の参考になれば幸いです。