2026年4月のある深夜、Beautiful HD Wallpapers の iOS 版で新しいクラッシュが立て続けに 3 件、Firebase Crashlytics の Slack 通知に流れてきました。スタックトレースは未シンボリケーションのままで、<redacted> と 0x0000000104a3b2c4 が並んでおり、何が起きているのかその場では判断できません。当時の手順では、私が手元の Mac で Xcode を開き、dSYM をダウンロードして照合し、関連コミットを git blame し、Issue 化して翌朝対応するという流れでした。MTTD(Mean Time to Detect)は 2 時間 30 分、MTTR は 9 時間台でした。
このサイクルを、Claude Code とごく小さな Cloud Functions に肩代わりさせることにしたのが、今回まとめるパイプラインです。アプリ事業(累計 5,000 万 DL 超)と並行してアート活動とブログ運営を続ける立場として、深夜の機械的な照合作業に時間を奪われるのが限界に近づいていました。実運用に乗せて 3 週間、Issue 67 件を処理した時点での所感を、構成図・コード・実数値の三点セットでお伝えします。
なぜ Crashlytics 単体ではトリアージが追いつかなくなるか
Crashlytics のダッシュボードはよくできています。クラッシュ件数、影響ユーザー数、最初に起きたバージョン、デバイス分布まで、必要な情報の大半は揃っています。しかし、5,000万DL規模で 14 タイトルを運用していると、毎週 15〜20 件の Non-fatal 含む Issue が流れ込み、そのほとんどは「過去のリリースで既知のもの」「特定機種でのみ再現する OS バグ起因」「広告 SDK 内部の例外で当方では対処不能」のいずれかです。優先度の高いものを正しく拾い上げるには、毎回スタックトレースの先頭 10 行を眺め、過去 Issue と突き合わせる必要があります。
この「過去 Issue との突き合わせ」が、人間が手動で行うには非効率です。Crashlytics は同じスタックを自動でクラスタリングしますが、私たちが知りたいのは「このスタックは v2.3.1 で導入された SwiftUI への移行と関係するか」「直近 24 時間で異常に増えていないか」「収益への影響はあるか」といった、サービス横断のコンテキストを含む判断です。Claude Code はここに非常に向いています。GitHub のコミット履歴、App Store Connect の売上、過去の Issue ログを横断的に読み、文脈を含んだトリアージレポートを生成できます。
私の場合、宮大工だった両祖父から受け継いだ感覚として「手を動かすことが一つの信心」という気質があり、機械的な照合は本来嫌いではありません。ただ、それを夜中に毎回やるのと、設計に投資して仕組みを残すのとでは、後者の方が次の世代に残せるものが多いと考えました。これがパイプライン化に踏み切った内的な動機です。
全体構成 — Webhook 受信から PR ドラフトまで
パイプラインは 5 段で構成しました。それぞれの担当を明確に分け、失敗時にどの段で止まったかを Slack に流す方針です。
[1] Crashlytics Issue Webhook (Firebase Extensions)
↓
[2] Cloud Functions (Node.js 20) — 受信・正規化・dedup
↓
[3] dSYM Lookup & Symbolication (Fastlane + atos)
↓
[4] Claude Code Headless Mode — RCA レポート生成
↓
[5] GitHub Actions — ドラフト PR 作成 + Slack 通知
それぞれの段で守りたい SLO は以下の通りです。実運用での実測値は別途、後ろのセクションで触れます。
- [1] → [2]: Webhook 受信から正規化完了まで 5 秒以内
- [2] → [3]: dedup 通過後 30 秒以内に dSYM 解決
- [3] → [4]: シンボリケーション完了から Claude Code 起動まで 10 秒以内
- [4] → [5]: RCA レポート生成 60 秒以内、PR ドラフト化までトータル 90 秒以内
90 秒という数字は「Slack の通知音が鳴ってから、開発者が PC の前に座って状況を理解するまでの猶予」として置きました。これより遅いと、結局画面を開いて自分で調べてしまうため、自動化の意味が薄れます。
段 [2]: Webhook 受信と Issue 正規化
Firebase Extensions の Crashlytics Big Query Export を有効化し、Cloud Functions 側で Pub/Sub トリガを受ける構成にしました。受信側のコードは意図的に小さく保ち、Claude を呼ぶ前にできる限り素朴な dedup を済ませます。
// functions/src/crashlytics-ingest.ts
import { onMessagePublished } from "firebase-functions/v2/pubsub";
import { Firestore, FieldValue } from "@google-cloud/firestore";
const db = new Firestore();
const DEDUP_WINDOW_MIN = 30; // 同一 Issue ID は30分以内なら再処理しない
export const ingestCrashlyticsIssue = onMessagePublished(
{ topic: "crashlytics-issues", region: "asia-northeast1", memory: "512MiB" },
async (event) => {
const payload = event.data.message.json as CrashlyticsPayload;
const issueKey = `${payload.app.bundleId}:${payload.issue.id}`;
const ref = db.collection("crash_triage").doc(issueKey);
const snap = await ref.get();
if (snap.exists) {
const last = snap.data()?.lastSeenAt?.toDate();
const minutesSince = last ? (Date.now() - last.getTime()) / 60000 : Infinity;
if (minutesSince < DEDUP_WINDOW_MIN) {
await ref.update({
recurrenceCount: FieldValue.increment(1),
lastSeenAt: FieldValue.serverTimestamp(),
});
return; // Claude 呼び出しは行わない
}
}
await ref.set({
bundleId: payload.app.bundleId,
version: payload.app.version,
issueId: payload.issue.id,
title: payload.issue.title,
stackTraceRaw: payload.issue.exception.stack_trace,
affectedUsers: payload.issue.distinct_counts.users,
crashCount: payload.issue.distinct_counts.events,
firstSeenAt: FieldValue.serverTimestamp(),
lastSeenAt: FieldValue.serverTimestamp(),
recurrenceCount: 1,
status: "queued",
}, { merge: true });
await db.collection("triage_queue").add({
issueKey,
enqueuedAt: FieldValue.serverTimestamp(),
});
}
);
ここで重要なのは「同じ Issue を 30 分以内に再処理しない」ルールです。Crashlytics は同じスタックの再発を細かく通知してくるため、これがないと Claude API のクォータを 1 時間で食い潰します。当初 5 分にしていた窓を 30 分に伸ばしたところ、API 呼び出し回数が約 41% 減りました。
段 [3]: dSYM ダウンロードと atos でのシンボリケーション
iOS の Bitcode 廃止(Xcode 14 以降)により、現在の dSYM 取得経路は App Store Connect API + Fastlane が安定しています。私は fastlane refresh_dsyms を CI で日次実行し、生成された dSYM を Cloud Storage の gs://wallpaper-crash-pipeline/dsyms/<bundleId>/<version>/<uuid>.zip に積み上げています。トリアージ時はこの中から image_uuid を頼りに取り出します。
#!/usr/bin/env bash
# scripts/symbolicate.sh
set -euo pipefail
ISSUE_KEY="$1" # 例: com.dolice.wallpapers:abc123
DSYM_BUCKET="gs://wallpaper-crash-pipeline/dsyms"
# Firestore から必要情報を取得(gcloud firestore は1コマンドに収まる範囲のみ使用)
BUNDLE_ID=$(echo "$ISSUE_KEY" | cut -d: -f1)
VERSION=$(gcloud firestore documents describe "crash_triage/${ISSUE_KEY}" \
--format="value(fields.version.stringValue)")
# 該当 dSYM をローカルに展開
WORKDIR=$(mktemp -d)
gsutil -m cp -r "${DSYM_BUCKET}/${BUNDLE_ID}/${VERSION}/" "${WORKDIR}/dsym/"
unzip -q "${WORKDIR}/dsym/"*.zip -d "${WORKDIR}/dsym_expanded"
# atos でアドレスをシンボル名に変換し、Firestore に書き戻し
python3 scripts/atos_resolve.py \
--issue-key "${ISSUE_KEY}" \
--dsym-dir "${WORKDIR}/dsym_expanded" \
--output "${WORKDIR}/symbolicated.json"
gcloud firestore documents update "crash_triage/${ISSUE_KEY}" \
--update-fields-file="${WORKDIR}/symbolicated.json"
rm -rf "${WORKDIR}"
atos_resolve.py は subprocess.run で atos -arch arm64 -o <BINARY> -l <LOAD_ADDR> <ADDR> を 1 行ずつ実行し、結果を Firestore 用の JSON に整形するだけの薄いスクリプトです。1 件あたり平均 8.2 秒で完了します。dSYM が見つからない場合(テスト機の Debug ビルド由来など)は status: "unsymbolicatable" を立て、Claude 呼び出しはスキップする方針にしています。
段 [4]: Claude Code Headless Mode で RCA レポートを生成する
ここがパイプラインの心臓部です。Claude Code を CI 環境からヘッドレスで呼び出し、GitHub のコミット差分、過去 Issue の Markdown ログ、App Store Connect のリリースノートを読ませて、原因仮説と修正候補を Markdown で出力させます。--allowedTools を絞り、書き込み系のツールは一切渡しません。
# .github/workflows/crash-triage.yml の中核
- name: Run Claude Code RCA
env:
CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }}
ISSUE_KEY: ${{ inputs.issue_key }}
run: |
npx -y @anthropic-ai/claude-code@latest \
--headless \
--allowedTools "Read,Grep,Bash(git log:*),Bash(git diff:*)" \
--output-format markdown \
--output-file ./out/rca_${{ inputs.issue_key }}.md \
--prompt-file ./prompts/rca_triage.md \
--context-file ./context/${{ inputs.issue_key }}.json
プロンプトテンプレートは「クラッシュの一次原因仮説(最大3つ・確度付き)」「直近30日で関連すると思われるコミットの列挙」「修正候補のドラフト(Swift コード)」「ロールバック判断(推奨・条件付き・不要)」の 4 セクション構造を強制しています。Claude Sonnet 4.6 に固定し、温度は 0.2、最大出力 4,096 トークンで運用しています。
実際の出力品質は、Issue 67 件のうち「主原因仮説の第 1 候補がそのまま正解だった」が 78%、「第 2 候補までで正解だった」が 91%、「修正候補のドラフトをほぼそのまま採用」が 34% でした。残り 66% は人間がレビュー時に書き直していますが、それでも「ゼロから書く」よりは速く、レビュー時間の平均は 14 分から 5 分台に短縮されました。
段 [5]: ドラフト PR 作成と Slack への引き渡し
最後の段で、生成された Markdown を本文に持つドラフト PR を当該リポジトリに自動作成します。私自身が手元で gh pr edit してから --ready にする運用です。ここを完全自動化しないのは、コード変更を含む PR を無人で main 向けに開くと、レビューが流れ作業になり、結果的にバグの温床になるためです。
// scripts/open-draft-pr.js(抜粋)
import { Octokit } from "@octokit/rest";
const issueKey = process.env.ISSUE_KEY;
const rcaBody = await fs.readFile(`./out/rca_${issueKey}.md`, "utf-8");
const branch = `crashtriage/${issueKey.replace(/[:.]/g, "-")}-${Date.now()}`;
await octokit.git.createRef({ owner, repo, ref: `refs/heads/${branch}`, sha: mainSha });
const pr = await octokit.pulls.create({
owner, repo,
head: branch,
base: "main",
title: `[crash-triage] ${title} — auto draft`,
body: rcaBody,
draft: true,
});
await slack.chat.postMessage({
channel: "#crashlytics-triage",
text: `🩹 Draft PR opened: <${pr.data.html_url}|#${pr.data.number}> for *${issueKey}*`,
});
Slack の通知文面には、Crash-free Users の現在値と、直近 24 時間の影響ユーザー増加率を含めています。「今すぐ見るべきか、明日でいいか」が一目で判断できる粒度に保つのがポイントです。
3 週間運用してみての実数値
2026 年 5 月の運用 3 週間で得られた数値は以下の通りです。読者の運用環境に持ち込んだ場合の見当として参考にしてください。
- 処理した Issue 件数: 67 件(うち Non-fatal 22 件)
- Crashlytics 通知から PR ドラフト着地までの中央値: 71 秒(90 パーセンタイル: 142 秒)
- Crash-free Users(iOS 全タイトル平均): 99.74%(パイプライン導入前は 99.62%)
- MTTD(Mean Time to Detect): 8 分(導入前 2 時間 30 分)
- MTTR(Mean Time to Resolve): 4 時間 12 分(導入前 9 時間 18 分)
- Claude API コスト: 月額 $42 程度(Sonnet 4.6・約 1,400 リクエスト)
- 誤検知率(無関係コミットを RCA に引き当てた割合): 12%
MTTR が半分以下になっている要因は、「夜中に通知が来ても、起きてレビューするだけで済むようになった」ことが大きいです。深夜にコードを書く必要がなくなり、翌朝の自分の判断力で対応できるようになりました。
運用時に気をつけたい 4 つの落とし穴
実装中に踏んだ罠を、再現性のある形でまとめておきます。
- dSYM のアップロードが失敗していると静かに詰む。 Xcode 16 以降は
DWARF with dSYM File を Debug Information Format に設定していても、archives 経由でアップロードしないと dSYM が App Store Connect に届きません。Fastlane の upload_symbols_to_crashlytics を CI に組み込むのが安定です。
- Crashlytics の Issue タイトルだけで dedup しないこと。 同じ
EXC_BAD_ACCESS でも、image 名と offset で見れば別物のケースが多々あります。issue.id(Crashlytics 側のハッシュ)を dedup キーに使うのが正解でした。
- Claude のコンテキストにスタックトレース全文を渡しすぎないこと。 100 行を超えるスタックは関連箇所だけを抜き出してから渡す方が、RCA の精度が上がりました。具体的には自社 bundle ID で始まるフレームのみに絞り、SDK 内部はトップ 5 行に圧縮しています。
- PR ドラフトを完全自動マージしないこと。 これは強い推奨です。RCA の確度が高い場合でも、Swift 側の修正は副作用を含むことが多く、人間のレビューを挟まないと「同じ画面の別バグを生む」が起きます。週 17 件平均の処理ボリュームなら、人間レビューを挟んでも十分にスケールします。
どこから着手するべきか
すべてを一度に作る必要はありません。私が今もう一度ゼロから組むなら、次の順序で着手します。
第 1 週は段 [2] と [3] だけを作り、atos 結果を Slack に流すところまでで止めます。これだけでも MTTD が 2 時間 → 15 分程度に縮みます。第 2 週で段 [4] を足し、RCA レポートだけを Slack に投稿する形にします。PR ドラフト化(段 [5])は最後で構いません。
判断材料として、現在の MTTD/MTTR を計測しておくことを強くお勧めします。Crashlytics の firstSeenAt と GitHub Issue の createdAt の差を 30 日分集計するだけでも、自動化前の状態が定量化できます。これがないと、導入後の効果を「気のせいかもしれない」で済ませてしまいがちです。
個人開発でアプリを長く運用していると、品質を保つコストは年々上がっていきます。リリース機能の積み上げが進むほど、過去資産が現役で動き続け、その障害対応の総量が静かに増えていくためです。Claude Code を「夜中の機械的な照合作業」に当てる発想は、その圧力を一段下げる一つの方法だと考えています。
このパイプラインも完璧ではなく、誤検知 12% という数字は引き続き下げていきたい領域です。同じ課題に取り組まれている方の参考になれば幸いです。お読みいただきありがとうございました。