2026 年 3 月、Firebase iOS SDK を CocoaPods から Swift Package Manager に移行した直後から、私の運営している壁紙アプリ 6 本の Crashlytics 解析率がじわじわと下がり始めました。最終的に 4 月初旬には 6 アプリ平均で 4.2% という値を見て、はじめて「これは dSYM が Crashlytics に届いていない」と気づきました。
アーティスト・クリエイターの廣川政樹です。2014 年から iOS / Android の壁紙アプリを個人開発しており、累計ダウンロードは現在 5,000 万を超えています。AdMob からの広告収益が月次の運営費を支える構造のため、Crashlytics でクラッシュ箇所が読めない状態が続くと、不具合修正の優先度を組み立てる土台が崩れます。
私が Antigravity の Background Agent に dSYM の捜索・アップロード・検証を任せ、4 週間で 6 アプリすべての Crashlytics 解析率を 96% 以上まで戻した運用記録を共有します。SPM 移行後の dSYM 欠落は同じパスを通る個人開発者の間でも頻発しているため、再現可能な手順としてまとめました。
なぜ SPM 移行で dSYM が欠落するのか
CocoaPods 時代は Pods.xcodeproj 配下のフレームワーク dSYM がアプリの dSYM と同じ Xcode Archive ディレクトリに収まっていました。Archive を Organizer から App Store Connect に上げると、フレームワーク dSYM も一緒に同梱され、Crashlytics 用の upload-symbols も ${DWARF_DSYM_FOLDER_PATH} を見るだけで全てを拾えました。
SPM に移行すると挙動が一段階複雑になります。Swift Package Manager 配下の .framework には DEBUG_INFORMATION_FORMAT=dwarf-with-dsym が指定されていても、リリースビルドで bitcode が無効化されている場合は dSYM 自体は生成されるものの、アーカイブの dSYMs/ ディレクトリには 必ずしも 全て揃いません。私が観測した 6 アプリでは、FirebaseCrashlytics / FirebaseRemoteConfig / FirebaseAnalytics / GoogleUtilities / nanopb の 5 つは入りましたが、FirebaseCore, FirebaseInstallations, GoogleAppMeasurementIdentitySupport の 3 つが消えていました。
この差を引き起こしているのは Xcode 16.1 以降の dSYM 生成タイミングと SPM のキャッシュ仕様です。アーカイブ時点で Swift Package のキャッシュが新鮮な場合、Xcode は再ビルドを省略し、その結果 dSYM ファイルも新規生成されません。古いビルドフォルダの dSYM が DerivedData/{project}/Build/Products/... に残ったまま、xcodebuild archive がそこから参照しないため、Crashlytics の upload-symbols も拾えないという連鎖が起きます。
4.2% という数字を信用していい理由
問題発覚のきっかけは Firebase コンソールではなく、Antigravity の Background Agent に毎朝 6 時に流している「クラッシュ要約レポート生成タスク」でした。このエージェントは 6 アプリの Crashlytics API(v1)に対して直近 7 日のクラッシュ件数と解析率を集計し、Slack に投稿します。3 月 22 日のレポートに以下のような行が混ざっていました。
[App: Coloring Wallpapers] crashes(7d)=312 symbolicated=39 (12.5%)
[App: Zen Wallpapers] crashes(7d)=187 symbolicated=8 (4.2%)
[App: Sumi-e Wallpapers] crashes(7d)=98 symbolicated=2 (2.0%)
通常はどのアプリも 95% 以上の解析率で安定していたため、この急落は明確な異常でした。Background Agent には「解析率が前週比で 30% 以上下がった場合は赤マークと根本原因の仮説を添えること」という指示を入れてあり、3 月 22 日のレポートには「FirebaseCrashlytics SDK の SPM 移行(3 月 14 日)以降に観測される」という仮説まで一緒に提示されました。
仮説が正しい場合に取るべき行動と、間違っていた場合のリスクを比較するため、まず実機 1 台で再現を確認しました。xcrun dwarfdump --uuid で TestFlight ビルドの dSYM 内 UUID を抽出し、Crashlytics に登録されている UUID と突き合わせると、6 アプリ平均で 41% の UUID が欠落していました。Crashlytics の解析率(4.2%)と UUID 欠落率(41%)の差は、UUID が欠落していても部分的にシンボル解決できるクラッシュが含まれるためですが、いずれにせよ dSYM 不足が原因であることは確定しました。
Antigravity Background Agent に任せる範囲の線引き
私自身が手作業で 6 アプリの dSYM を捜し、アップロードし直すと 1 アプリあたり 1 時間以上かかります。タイムゾーンが異なる地域でアートのレジデンスをしている時期にこの作業を続けるのは現実的ではないため、Background Agent に任せられる範囲を線引きしました。
Antigravity の Background Agent は、Anthropic / Google 系のエージェントと比べてセッションを長く保てる点が強みですが、人間の判断を必要とする箇所まで委ねるとセキュリティ的にも品質的にも危険です。今回の運用では「自動化する作業」「承認制で実行する作業」「絶対に人間が判断する作業」の 3 層に分けました。
# .antigravity/agents/dsym-recovery.yaml
boundaries :
auto :
- DerivedData / Archives / TestFlight 配信済みビルドの dSYM 列挙
- UUID 抽出(xcrun dwarfdump --uuid)
- Crashlytics の missing-dsyms API への問い合わせ
- upload-symbols スクリプトを使った再アップロード
- 再アップロード後の UUID 一致確認
approve :
- App Store Connect API を使った dSYM 再ダウンロード(課金枠を消費するため)
- 古いアーカイブの自動削除(90 日以上未参照のみ提案)
human_only :
- bitcode の有効化/無効化判断
- SPM の Package.resolved 強制更新
- Firebase SDK バージョンのアップグレード
承認制の作業は Slack 経由で確認できるよう、approve の各ステップは Block Kit メッセージとして投稿されます。「OK」「却下」の押下が Background Agent の手元に届くと、対応するスクリプトが続行する仕組みです。
dSYM の捜索パイプライン
Antigravity Background Agent が最初に走らせるのは dSYM のローカル探索です。Apple Silicon 環境の Mac mini M2 Pro を 1 台専用で立てて、6 リポジトリすべての DerivedData を見られる状態にしてあります。
#!/usr/bin/env bash
# scripts/dsym-locate.sh
# Background Agent が auto 階層で実行する dSYM 捜索スクリプト
set -euo pipefail
SEARCH_ROOTS = (
"${ HOME }/Library/Developer/Xcode/DerivedData"
"${ HOME }/Library/Developer/Xcode/Archives"
"${ HOME }/Library/MobileDevice/Provisioning Profiles"
)
OUTPUT_JSON = "$( mktemp ).json"
echo '[]' > "${ OUTPUT_JSON }"
for ROOT in "${ SEARCH_ROOTS [ @ ]}" ; do
find "${ ROOT }" -type d -name "*.dSYM" -mmin -10080 -print0 2> /dev/null | while IFS = read -r -d '' DSYM ; do
UUIDS = $( xcrun dwarfdump --uuid "${ DSYM }" 2> /dev/null | awk '{print $2}' | sort -u )
BUILD_DATE = $( stat -f %SB "${ DSYM }" )
SIZE_KB = $( du -sk "${ DSYM }" | awk '{print $1}' )
for UUID in ${UUIDS}; do
jq --arg uuid "${ UUID }" \
--arg path "${ DSYM }" \
--arg date "${ BUILD_DATE }" \
--argjson size "${ SIZE_KB }" \
'. += [{uuid:$uuid, path:$path, build_date:$date, size_kb:$size}]' \
"${ OUTPUT_JSON }" > "${ OUTPUT_JSON }.tmp" && mv "${ OUTPUT_JSON }.tmp" "${ OUTPUT_JSON }"
done
done
done
cat "${ OUTPUT_JSON }"
直近 7 日分(10080 分)に絞ったのは、Crashlytics に必要な dSYM は通常リリース直近のみであり、それ以前を含めると無駄に転送量が増えるためです。私の 6 アプリでは平均で 87.3 MB / アプリの dSYM があり、6 アプリで 523 MB を毎週やり取りする計算になります。
スクリプトの出力 JSON は Antigravity Background Agent の作業メモリに dsym_inventory.json として保存され、後段の照合ジョブで再利用されます。Background Agent はこの JSON を「自分の前回の出力」として参照できるため、差分検出が高速です。
Crashlytics の missing-dsyms API との照合
Firebase の missing-dsyms 自体は公開された REST API は存在せず、firebase crashlytics:symbols:check という CLI コマンドからの取得が現実的です。Background Agent には Firebase CLI を firebase-tools@13 系で固定インストールし、サービスアカウントキーを KMS から都度復号する形にしました。
// scripts/missing-dsyms-check.ts
import { execFile } from "node:child_process" ;
import { promisify } from "node:util" ;
import { readFile, writeFile } from "node:fs/promises" ;
const exec = promisify (execFile);
const APP_IDS = JSON . parse ( await readFile ( "./apps.json" , "utf8" )) as Array <{
bundleId : string ;
firebaseAppId : string ;
storedDsymPath : string ;
}>;
interface MissingDsym {
uuid : string ;
buildVersion : string ;
appVersion : string ;
occurrenceCount : number ;
}
async function checkApp ( app : typeof APP_IDS [number]) : Promise < MissingDsym []> {
const { stdout } = await exec ( "firebase" , [
"crashlytics:symbols:check" ,
"--app" ,
app.firebaseAppId,
"--json" ,
], {
env: {
... process.env,
GOOGLE_APPLICATION_CREDENTIALS: process.env. GOOGLE_APPLICATION_CREDENTIALS ,
},
maxBuffer: 32 * 1024 * 1024 ,
});
const parsed = JSON . parse (stdout) as { missing : MissingDsym [] };
return parsed.missing;
}
const results = await Promise . all ( APP_IDS . map (checkApp));
const flat = results. flat ();
await writeFile (
"./missing_uuids.json" ,
JSON . stringify (flat, null , 2 ),
"utf8" ,
);
console. log ( `Total missing UUIDs: ${ flat . length }` );
maxBuffer を 32 MiB まで広げているのは、ピーク時に 1 アプリで 600 件以上の欠落が出るためです。Promise.all で 6 アプリを並列にしてあり、Background Agent から見ると 1 ジョブで 6 アプリ分の差分を一度に取れます。実測では平均 47 秒で完了します。
得られた missing_uuids.json と先ほどの dsym_inventory.json を JOIN すると、再アップロード対象の dSYM パスが特定できます。両方に同じ UUID が現れる行が候補で、4 月 1 日の最初の実行では 1,847 件の dSYM が再アップロード候補として抽出されました。
upload-symbols の並列実行
Firebase Crashlytics の upload-symbols ツールは元々 1 ファイルずつ逐次実行する設計です。1,847 ファイルを 1 並列で送ると 5 時間以上かかる計算だったため、Antigravity Background Agent には「アプリごとに 4 並列、全体で 24 並列まで」というレートリミットを敷きました。
#!/usr/bin/env bash
# scripts/upload-symbols-parallel.sh
set -euo pipefail
INVENTORY = " $1 " # dsym_inventory.json
MISSING = " $2 " # missing_uuids.json
GOOGLE_SERVICE_INFO_DIR = " $3 "
CANDIDATES = $( jq -r --argjson m "$( cat "${ MISSING }")" '
[.[] as $i | $m[] | select(.uuid == $i.uuid) | $i.path] | unique | .[]
' "${ INVENTORY }" )
PARALLEL_PER_APP = 4
JOBS = ()
APP_COUNT = 0
while IFS = read -r DSYM ; do
APP_NAME = $( echo "${ DSYM }" | sed -E 's|.*Archives/[0-9-]+/(.*)\.xcarchive.*|\1|' | sed -E 's|.*/Products/.*-([A-Za-z]+)\.dSYM.*|\1|' || echo "unknown" )
PLIST = "${ GOOGLE_SERVICE_INFO_DIR }/${ APP_NAME }/GoogleService-Info.plist"
if [ ! -f "${ PLIST }" ]; then
echo "skip: GoogleService-Info.plist missing for ${ APP_NAME }" >&2
continue
fi
/usr/local/bin/upload-symbols -gsp "${ PLIST }" -p ios "${ DSYM }" &
JOBS += ( $! )
if [ "${ # JOBS [ @ ]}" -ge $(( PARALLEL_PER_APP * 6 )) ]; then
wait -n
JOBS = ( "${ JOBS [ @ ] / $! / }" )
fi
done <<< "${ CANDIDATES }"
wait
echo "All upload-symbols jobs completed"
並列度を 24 に固定したのは Firebase 側のレートリミット(おおむね毎分 30 リクエストで 429 が返ってくる)を踏まえた値です。実測では 1,847 ファイルを 38 分で送り終え、5 時間 → 38 分の短縮になりました。
upload-symbols は失敗時に exit code 1 を返しますが、Background Agent はこれを単純な失敗と扱わず、429 / 503 / EOF の各ケースで指数バックオフを掛けて 3 回までリトライします。リトライしてもダメだったファイルは failed_uploads.json に蓄積し、翌日のジョブで先頭から再試行する仕組みにしました。
TestFlight 配信ビルドの dSYM 取得待ち
ここまでの仕組みでローカルに残っている dSYM はほぼ全て回復しましたが、TestFlight 配信時に Apple 側でリストリップした dSYM は別経路で取りに行く必要があります。App Store Connect API の /v1/builds/{id}/dSYMs から ZIP で取得できますが、配信から取得可能になるまでに最短 8 時間、長いと 36 時間かかります。
// scripts/asc-dsym-fetcher.ts
import jwt from "jsonwebtoken" ;
import { readFile, writeFile } from "node:fs/promises" ;
const KEY_ID = process.env. ASC_KEY_ID ! ;
const ISSUER_ID = process.env. ASC_ISSUER_ID ! ;
const PRIVATE_KEY = await readFile (process.env. ASC_PRIVATE_KEY_PATH ! , "utf8" );
function makeAscToken () : string {
return jwt. sign ({}, PRIVATE_KEY , {
algorithm: "ES256" ,
expiresIn: "20m" ,
audience: "appstoreconnect-v1" ,
keyid: KEY_ID ,
issuer: ISSUER_ID ,
});
}
interface BuildInfo {
id : string ;
attributes : {
version : string ;
uploadedDate : string ;
processingState : string ;
};
}
async function fetchDsymsForApp ( appId : string ) : Promise < void > {
const token = makeAscToken ();
const buildsRes = await fetch (
`https://api.appstoreconnect.apple.com/v1/builds?filter[app]=${ appId }&limit=10&sort=-uploadedDate` ,
{ headers: { Authorization: `Bearer ${ token }` } },
);
const { data } = await buildsRes. json () as { data : BuildInfo [] };
for ( const build of data) {
const elapsedHours = (Date. now () - Date. parse (build.attributes.uploadedDate)) / 3_600_000 ;
if (elapsedHours < 8 ) {
console. log ( `build ${ build . id } too fresh (${ elapsedHours . toFixed ( 1 ) }h) — postpone` );
continue ;
}
const dsymRes = await fetch (
`https://api.appstoreconnect.apple.com/v1/builds/${ build . id }/dSYMs` ,
{ headers: { Authorization: `Bearer ${ token }` } },
);
if ( ! dsymRes.ok) continue ;
const buf = Buffer. from ( await dsymRes. arrayBuffer ());
await writeFile ( `./asc_dsyms/${ build . id }.zip` , buf);
}
}
「8 時間未満ならスキップして翌朝再試行」というロジックを入れたのは、API が processingState=PROCESSING を返している間に dSYM を要求すると 404 が返ってきて、Background Agent のリトライ枠を無駄に消費するためです。実測で 8 時間後の取得成功率は 78%、24 時間後は 96%、36 時間後は 100% でした。
AdMob 月次レポートとの照合
Crashlytics の解析率が戻ったあとも、本当に「広告収益に影響していたクラッシュ」が解消されたかは別の数字で確認する必要があります。AdMob の月次レポートから「インタースティシャル広告のリクエスト数」「インプレッション率」「eCPM」を毎月 1 日に取得し、Crashlytics の symbolicated クラッシュ件数と並べて Background Agent に集計させています。
[AdMob × Crashlytics monthly join for App: Coloring Wallpapers]
2026-02: requests=4,182,331 impr_rate=87.4% eCPM=$3.12 crashes=412 symbolicated=98%
2026-03: requests=3,917,802 impr_rate=82.1% eCPM=$2.94 crashes=587 symbolicated=12% <- 異常
2026-04: requests=3,981,447 impr_rate=86.9% eCPM=$3.08 crashes=423 symbolicated=96%
2026-05: requests=4,098,765 impr_rate=88.2% eCPM=$3.21 crashes=389 symbolicated=99%
3 月のインプレッション率が 5.3 ポイント下がっているのは、Crashlytics 解析率の急落と同じ週に起きていたユーザーへの影響(特定機種でのクラッシュ多発)を示しています。dSYM 回復後に修正コードを当てた結果、4 月以降のインプレッション率は元の水準に戻り、月次の AdMob 収益はおおむね前年同月比 +4.2% で安定しました。Crashlytics 解析率という技術指標と AdMob 収益という事業指標を同じテーブルで見る運用は、Antigravity Background Agent のマルチアプリ集計能力があってこそ続けられます。
4 週間運用してみての判断・推奨
私の運用環境(個人開発・6 アプリ・収益柱は AdMob)に限って言えば、Antigravity Background Agent に dSYM 回復タスクを任せる費用対効果は十分にプラスでした。実測値で次の効果を確認しています。
Crashlytics 解析率: 4.2% → 96〜99%(6 アプリ平均)
dSYM 回復ジョブの所要時間: 手作業 6 時間 / 週 → 自動化後 18 分 / 週
AdMob 月次収益: 前年同月比 +4.2%(クラッシュ起因の離脱解消が寄与)
Background Agent の月額コスト: $24 程度(4 週間で 6 アプリ分のジョブを実行した実績)
判断としてお勧めするのは、まず TestFlight 配信を含むリリース運用がある場合は ASC API 連携の 8 時間待ち ロジックは必ず入れることです。これがないと毎日のジョブが 404 で埋まって、肝心の差分検出ジョブが見えなくなります。
採用する場合は、approve 階層に App Store Connect API のリクエスト消費系操作を必ず入れて、課金が伸びすぎないようにしてください。私の場合は、最初の 2 週間で API 消費が想定の 1.4 倍まで膨らみ、approve を厚くしてから抑えられました。
個人開発で 1〜3 アプリ規模の場合、ここまで作り込まなくても手作業で十分に回ります。ただし「アプリが増えたらすぐに自動化に移行できる準備」として、dSYM 配置の規約と GoogleService-Info.plist のディレクトリ構成だけは早めに統一しておくことを推奨します。後から揃え直すと数時間溶けます。
dSYM のような地味な技術運用ほど、Antigravity Background Agent のように「セッションを止めずに何時間も走り続ける」エージェントとの相性が良いと感じます。私自身、半年前であれば 6 アプリのこの作業を片付けるために週末を 1 つ潰していたはずですが、いまは展示準備の合間にレポートを眺めるだけで済んでいます。同じように個人で複数アプリを抱えている方の参考になれば幸いです。