Crashlyticsのコンソールを朝に開いて、Top 10のクラッシュを一件ずつ追いかける作業は、個人で6本のアプリを同時に運営していた頃の私にとって、もっとも消耗する仕事でした。運営本数が増えるほどクラッシュレポートは積み上がり、「朝のうちに読みきれない」という状態が常態になっていきました。Antigravityに一括で渡せば終わると思っていた時期もありますが、実運用に持ち込むと、一つのエージェントに全任せにする設計では破綻するという感触を得ました。
そこで5つのサブエージェントに責務を分割し、それぞれが「ここから先は人間が見る」「ここまでは自動で進める」という境界線を持つ構成に再設計しました。二週間運用したところ、朝の平均トリアージ時間は89分から14分に短縮され、誤検知率も18%から6%に下がりました。本稿はその設計図と運用ログ、そして個人開発者が同じ構成を組むときに気をつけるべき点をまとめた記録です。
なぜ一つのエージェントに全任せできなかったか
最初に試したのはCrashlyticsのスタックトレースをそのままAntigravityの単一エージェントに渡し、「原因を特定して修正パッチを出して」とだけ依頼する構成でした。短期的にはこれで動きます。ただし二週間も回すと以下の3つの破綻が表面化しました。
第一に、コンテキストが膨らみすぎてエージェントが情報の優先順位を誤ります。クラッシュの内容、対象アプリのコードベース、過去の類似クラッシュ、リリースノート、依存ライブラリのバージョン差分まで毎回読み込ませると、入力量だけで2万トークンを超え、肝心のスタックトレースの末尾を雑に扱うようになりました。これは個人開発で扱う4本以上のアプリを一つのワークフローに通したときに特に顕著でした。
第二に、人間がレビューに入る場所を決められません。「Antigravityの提案するパッチをそのままmainにマージしますか?」という問いに即答できる個人開発者はほぼいないはずです。私自身、運営しているアプリのうちAdMobの収益が大きい数本については、リグレッションを一度でも出すと月の売上が二桁パーセント単位で揺れるため、ノーレビューでのマージは選択肢に入りませんでした。
第三に、失敗時の切り戻しがエージェント単位で取れません。原因特定が間違っていたのか、再現スクリプトが甘かったのか、パッチの当て方がまずかったのかを、ログから後追いできない構造になっていました。
この3つを同時に解決するために、エージェントを5つに分割し、それぞれが構造化された入出力スキーマだけを介してやり取りする構成に変えました。
5つのサブエージェントに責務を分割する設計図
設計はシンプルです。各エージェントは前段の出力JSONを受け取り、自分の専門領域だけを実行し、次段が読める形のJSONを出力します。境界をJSONで固めることで、どの段で失敗したかが必ず特定できるようになります。
Fetcher Agent — Crashlyticsからクラッシュ群を取得し、正規化してJSONに整形する
Classifier Agent — クラッシュを「リグレッション/既知未対応/外部要因/環境固有」の4種に分類し、優先度スコアを付与する
Repro Agent — 上位優先度のクラッシュについて再現スクリプトを試作し、シミュレータで実行する
Patch Agent — 再現できたクラッシュに対して修正パッチとテストを生成する
PR Agent — Draft PRを作成し、必要なreviewerをアサインしてリンクをSlackに通知する
それぞれのエージェントには「自分の出力にどの情報を含めるか」「次段にどの情報を渡してはいけないか」を明示しました。例えばFetcher Agentは生のスタックトレースを次段に渡してよいですが、内部のユーザーIDやメールアドレスといったPIIは正規化のタイミングで落とすルールにしています。
下記がパイプライン全体の骨格を表現したAntigravityのagents.mdの抜粋です。
# Crashlytics Triage Pipeline
## Agents
### fetcher
- role: Crashlyticsから過去24時間の致命的クラッシュを取得し、正規化済みJSONを出力
- inputs: { app_ids: string[] }
- outputs: schema://crash-batch-v1.json
- tools: gcp-bigquery, firebase-crashlytics-rest
- constraints:
- PII(user_email, custom_keys.user_id)を出力から除外
- 1リクエストあたり最大50件まで
### classifier
- role: クラッシュ群を4カテゴリに分類しスコアリング
- inputs: schema://crash-batch-v1.json
- outputs: schema://crash-triaged-v1.json
- tools: codebase-search, git-log-since
- constraints:
- リグレッションと判定する場合は対応するcommit hashを必ず添える
### repro
- role: 上位5件の再現スクリプトを試作してシミュレータで実行
- inputs: schema://crash-triaged-v1.json
- outputs: schema://crash-reproduced-v1.json
- tools: xcodebuild-test, gradle-test, simctl
- constraints:
- 5分以内に再現できない場合はskip理由を記述
エージェント定義をMarkdownのagents.mdに集約しておくと、Antigravity側がpipeline全体を一つの宣言として読み込めます。スキーマファイルを別途用意しておけば、出力のバリデーションも自動で挟めます。
Fetcher Agent: Crashlyticsから正規化済みクラッシュを取り出す
Fetcher Agentの実装には2つの責務があります。一つはBigQueryまたはCrashlytics REST APIから生データを取得すること。もう一つはPIIを除去し、次段が扱いやすい形式に正規化することです。
私の場合、6本のアプリを同時に処理するため、対象アプリのリストを設定ファイルから読み込むようにしました。AdMobの収益寄与が大きいアプリは閾値を低く、リリース直後のアプリは閾値を高く設定するなど、アプリごとに優先度の重みを変えています。
# agents/fetcher/main.py
import json
import os
from datetime import datetime, timedelta, timezone
from google.cloud import bigquery
PII_FIELDS = { "user_email" , "custom_keys.user_id" , "device.serial" }
def normalize_crash (row: dict ) -> dict :
"""生クラッシュをPII除去後の正規化形式に変換する"""
return {
"issue_id" : row[ "issue_id" ],
"title" : row[ "issue_title" ],
"subtitle" : row.get( "issue_subtitle" , "" ),
"app_id" : row[ "app_id" ],
"app_version" : row[ "application" ][ "display_version" ],
"os" : f ' { row[ "os" ][ "name" ] } { row[ "os" ][ "display_version" ] } ' ,
"device" : f ' { row[ "device" ][ "manufacturer" ] } { row[ "device" ][ "model" ] } ' ,
"occurred_at" : row[ "event_timestamp" ].isoformat(),
"fatal" : row[ "is_fatal" ],
"stack_top" : _top_frames(row[ "exceptions" ][ 0 ][ "frames" ], n = 8 ),
}
def _top_frames (frames: list , n: int ) -> list :
"""先頭nフレームのみを抽出。アプリ自前のシンボルのみを優先する"""
own_prefix = os.environ[ "APP_BUNDLE_PREFIX" ]
own = [f for f in frames if f.get( "file" , "" ).startswith(own_prefix)]
rest = [f for f in frames if f not in own]
chosen = (own + rest)[:n]
return [{ "file" : f[ "file" ], "line" : f[ "line" ], "symbol" : f.get( "symbol" )} for f in chosen]
def fetch (app_ids: list[ str ], hours: int = 24 ) -> dict :
client = bigquery.Client()
since = datetime.now(timezone.utc) - timedelta( hours = hours)
sql = """
SELECT * FROM `crashlytics.firebase_crashlytics.*`
WHERE event_timestamp > @since
AND is_fatal = TRUE
AND application.app_id IN UNNEST(@app_ids)
LIMIT 50
"""
job = client.query(
sql,
job_config = bigquery.QueryJobConfig(
query_parameters = [
bigquery.ScalarQueryParameter( "since" , "TIMESTAMP" , since),
bigquery.ArrayQueryParameter( "app_ids" , "STRING" , app_ids),
]
),
)
crashes = [normalize_crash( dict (row)) for row in job.result()]
return { "schema" : "crash-batch-v1" , "fetched_at" : datetime.now(timezone.utc).isoformat(), "crashes" : crashes}
if __name__ == "__main__" :
out = fetch(json.loads(os.environ[ "APP_IDS" ]))
print (json.dumps(out, ensure_ascii = False ))
PII除去を入口で済ませることで、後段のエージェントに渡るログから恒常的に個人情報が消えます。これは6本のアプリでGoogle PlayとApp Storeの両方を運用している立場では実装上の必須事項です。
ハマりどころとして、Crashlyticsのスタックフレームには自前シンボルとSDK内部のフレームが混在しており、フレーム上位8件をそのまま渡すと自前コードが一切含まれないケースがありました。_top_framesで自前シンボルを優先的に拾うように書き換えてから、後段の精度が体感で30%ほど改善しています。
Classifier Agent: クラッシュを4種類に分類し優先度を付ける
Fetcher出力を受け取ったClassifier Agentは、各クラッシュを以下の4分類に振り分けます。
リグレッション : 直近30日のcommitに該当ファイルへの変更があり、過去90日に同じissue_idが報告されていないもの
既知未対応 : 過去90日に同じissue_idが繰り返し報告されているもの
外部要因 : 自前コード以外のフレーム(Firebase SDK、OS API)で発生しているもの
環境固有 : 特定機種・特定OSに発生比率が偏っているもの
この分類は、Antigravityのコードベース検索ツールとgit log参照を組み合わせて行います。プロンプトでは「該当ファイルのblameを30日遡り、直近のcommit著者と日付をJSONに含めること」を明示しました。
// agents/classifier/prompt.ts
export const CLASSIFIER_INSTRUCTIONS = `
You are a triage classifier for mobile app crashes.
For each crash, output one of:
- "regression": file modified within last 30 days AND not previously reported
- "known_open": same issue_id reported ≥3 times in last 90 days
- "external": top frame in vendor SDK (Firebase, FBSDK, etc.)
- "device_specific": occurs in ≥80% same device or OS
Append a priority_score 0-100 weighted by:
- regression: base 70
- known_open with recent volume spike: base 60
- external: base 20
- device_specific affecting current OS: base 50
Multiply by app weight (provided in input) for revenue-bearing apps.
Use codebase-search to verify file existence before classifying as regression.
` ;
ここでの落とし穴は、新人エージェントにとって「直近30日のcommit」と「過去90日に同じissue_idがあったか」の区別が曖昧になりがちな点です。git log --since="30 days ago" -- <file>の結果がゼロのときは必ずknown_open寄りに倒すよう明示すると、誤分類が減ります。
私の運用では、リグレッション判定されたクラッシュにはアプリの重みづけ係数を掛け合わせます。例えばAdMob収益の比率が高いアプリの優先度を1.5倍に、リリース直後のテスト中アプリは0.8倍にするといった具合です。これにより、本番運用で本当に痛い順に並びます。
Repro Agent: シミュレータで再現スクリプトを試作する
上位5件に絞ったクラッシュについて、Repro Agentが再現スクリプトを書きます。iOSはxcodebuild test、Androidは./gradlew connectedAndroidTestで実行する想定です。
# agents/repro/runner.py
import subprocess
import re
import json
from pathlib import Path
class ReproResult :
REPRODUCED = "reproduced"
FLAKY = "flaky"
NOT_REPRODUCED = "not_reproduced"
SKIPPED = "skipped"
def run_ios_repro (script_path: Path, scheme: str , timeout: int = 300 ) -> dict :
"""xcodebuild testで再現スクリプトを3回実行し、揺らぎを判定する"""
successes = 0
failures = 0
for attempt in range ( 3 ):
try :
res = subprocess.run(
[ "xcodebuild" , "test" ,
"-scheme" , scheme,
"-destination" , "platform=iOS Simulator,name=iPhone 15" ,
"-only-testing" , str (script_path)],
capture_output = True , text = True , timeout = timeout,
)
# クラッシュ再現テストは「失敗するのが正解」
if "Test Suite 'All tests' failed" in res.stdout:
successes += 1
else :
failures += 1
except subprocess.TimeoutExpired:
failures += 1
if successes == 3 :
return { "status" : ReproResult. REPRODUCED , "successes" : 3 }
if successes >= 1 :
return { "status" : ReproResult. FLAKY , "successes" : successes}
return { "status" : ReproResult. NOT_REPRODUCED , "successes" : 0 }
3回連続で再現できた場合のみREPRODUCED、1〜2回ならFlakyとして次段で人間レビュー必須にしています。本番環境のクラッシュは必ずしも100%再現するとは限らず、Flaky扱いを丁寧に作っておかないとパッチを当てた後に「直ったのか分からない」状態に陥ります。
ここで個人開発の現場で大事なことを一つ。シミュレータでの再現にこだわりすぎないことです。実機のメモリ条件やネットワーク遅延が原因のクラッシュは、シミュレータでは絶対に再現しません。Repro AgentがNOT_REPRODUCEDを出した場合は、その理由を必ずJSONに記録させ、後段のPatch Agentが「再現できていないが状況証拠で対処する」モードに入れるようにしています。
Patch Agent: 修正パッチとテストを同時に生成する
再現できたクラッシュに対して、Patch Agentが修正パッチとリグレッションテストを生成します。ここではAntigravityの強みであるコードベース横断の差分プレビューを最大限に活用します。
# agents/patch/prompt_builder.py
from pathlib import Path
import json
def build_patch_prompt (crash: dict , repro: dict ) -> str :
"""再現済みクラッシュの修正プロンプトを組み立てる"""
return f """
ROLE: You are a senior iOS/Android engineer working with a 12-year indie developer.
CRASH:
{ json.dumps(crash, ensure_ascii = False , indent = 2 ) }
REPRO:
- script: { repro[ 'script_path' ] }
- status: { repro[ 'status' ] }
- successes: { repro[ 'successes' ] } / 3
REQUIREMENTS:
1. Produce a minimal patch that makes the repro script pass (i.e., no crash).
2. Add a regression test in the same module as the crashing file.
3. DO NOT change unrelated public APIs.
4. DO NOT introduce new third-party dependencies.
5. If the fix requires architectural changes, output a "needs-discussion" marker instead of code.
OUTPUT FORMAT:
- patch: unified diff
- test: full new test file content
- risk_level: "low" | "medium" | "high"
- rollback_note: 1-2 sentences describing how to revert
"""
Patch Agentの出力には必ずrisk_levelとrollback_noteを含めています。これはPR Agentが次段で「自動マージできるか」「人間レビュー必須か」を判定するための材料です。
注意点として、エージェントに自由を与えすぎると無関係なリファクタリングを混ぜ込んできます。「DO NOT change unrelated public APIs」を強めに指示してから、PR Agentに渡る差分が本筋のみに収まるようになりました。これは個人開発で12年やってきた経験上、リファクタリングと修正は別PRに切るのが事故を減らす鉄則です。
PR Agent: Draft PRを作って人間レビューに渡す境界線
最終段のPR AgentはGitHubのREST APIを叩いてDraft PRを作成します。ここで設計の核心となるのが「自動承認可能/レビュー必須」の境界線です。
# agents/pr/decision.py
def decide_review_mode (patch: dict , crash: dict , app_meta: dict ) -> str :
"""3つの条件を全て満たすときのみ自動承認モードを有効化する"""
if patch[ "risk_level" ] != "low" :
return "human_review_required"
if app_meta[ "monthly_revenue_jpy" ] > 100_000 :
return "human_review_required"
if crash[ "app_version" ].endswith( ".0" ): # メジャー直後は慎重に
return "human_review_required"
return "auto_approve_candidate"
自動承認モードといっても、私の運用では実際にmainへ自動マージはしていません。Draft PRをReady for reviewに昇格させ、Slackで「自動承認候補です」とフラグを立てるだけにしています。最後のmergeボタンは必ず人間が押す設計です。
これは収益の柱になっているアプリでリグレッションを出した場合の影響を考えれば、自明な判断です。1日のmerge数を稼ぐより、1ヶ月のリリース品質を担保する方が個人開発者の事業継続には効きます。
人間レビューが必須になった場合、PR AgentはAntigravityのrunbookエージェントと連携してreviewerに「このクラッシュは過去にこんな同種事例がありました」というコメントを自動添付します。コードレビュー時の認知負荷を下げるための地味な工夫ですが、夜中にSlackを見る私自身にとっては効きました。
二週間運用して見えた数値
このパイプラインを2026年5月初旬から二週間運用したログを集計しました。比較対象は、Antigravityの単一エージェントに全任せしていた時期の4月後半2週間です。
指標 単一エージェント 5エージェント分割 改善率
1日の平均トリアージ時間 89分 14分 84%短縮
誤検知率(無関係パッチ) 18% 6% 67%減
自動再現できたクラッシュ 11% 38% 3.5倍
Draft PR作成までの中央値 41分 9分 78%短縮
1ヶ月あたりのリリースリグレッション 3件 1件 67%減
朝の作業時間が89分から14分になった効果は、個人開発の心理的負担を激減させました。アプリの新機能開発に頭を使える時間が戻ってきた感覚があります。
特に大きかったのは「自動再現できたクラッシュ」が11%から38%に伸びた点です。これはRepro Agent専任で再現スクリプトの生成にコンテキストを集中させたことが効きました。再現できないクラッシュは結局その日のうちには直せないため、自動再現率は実質的に「その日中に潰せる件数」と等価になります。
数値で表しきれない効果として、Slackの#crashlyticsチャンネルが朝7時前後に静かにアップデートされ、自動承認候補と人間レビュー必須のクラッシュが整然と並ぶ状態になりました。コーヒーを淹れている間にDraft PRが揃っている朝は、手作業でクラッシュを一件ずつ追っていた頃の朝と比べると、別世界のように感じます。
このパイプラインで気づいた個人開発者向けの設計指針
二週間運用して見えた知見をいくつか残します。同じような構成を組む個人開発者の参考になれば幸いです。
エージェントは責務を細かく分けるほどデバッグが楽になります。失敗時に「どの段で詰まったか」が即座に分かることは、運用が回り続けるうえでの生命線です。逆に責務を欲張ったエージェントは、半年後に保守できなくなる感覚があります。一つの段が一つの仕事だけを担うように組んでおくと、後から手を入れても全体が崩れにくく、結果として長く使い続けられる構成になると感じています。
人間レビューを残す境界は、収益額と再現確度の二軸で決めるとぶれません。月の売上が大きいアプリのリグレッションは、どれほど低リスク判定でも一度人間が見るルールにしておくと精神衛生が保てます。
入出力スキーマをJSONで固めて、各エージェントの出力をログに残しておくと、後から「あのときどうしてこの判定になったのか」を再現できます。これはエージェントの振る舞いをチューニングするうえで欠かせない作業ログになります。
次に試したいのは、Patch Agentが生成したパッチを段階公開(10%/50%/100%)と連携させることです。リスク中程度のパッチは段階公開のみに自動デプロイし、100%公開は人間判断に残す——という運用が組めれば、修正のスピードと安全性をさらに両立できるはずです。
朝のクラッシュトリアージは、放っておくと個人開発者の創造的な時間を確実に削っていきます。エージェントに分担させる構成は、その時間を取り戻すための実用的な選択肢の一つになるのではないかと考えています。同じ課題に取り組んでいる方の知見もぜひ聞かせてください。お読みいただきありがとうございました。