Recovering Missing iOS dSYMs Across Six Apps with Antigravity's Background Agent — A Four-Week Operations Log
After migrating Firebase iOS SDK from CocoaPods to SPM, six wallpaper apps saw their Crashlytics symbolication rate collapse to as low as 4.2%. This is a four-week operations log of letting Antigravity's Background Agent track, upload, and verify 1,847 missing dSYMs across six repositories — including App Store Connect dSYM polling and AdMob revenue reconciliation.
In March 2026 I migrated the Firebase iOS SDK in my six wallpaper apps from CocoaPods to Swift Package Manager. Within two weeks the Crashlytics symbolication rate had slid from a steady 95%+ down to 4.2% on average. It took me longer than it should have to realize the dSYMs themselves were not making it to Crashlytics anymore.
I'm Masaki Hirokawa — an artist and indie developer at Dolice. I have been shipping iOS and Android wallpaper apps independently since 2014, and the cumulative downloads recently passed 50 million. AdMob revenue covers my monthly operating costs, so a dark Crashlytics dashboard means I can't prioritize the bug fixes that actually move that number.
This article is a four-week operations log of how I let Antigravity's Background Agent locate, upload, and verify 1,847 dSYMs across six repositories until the symbolication rate was back above 96% for every app. The SPM-migration dSYM gap is a problem several solo developers I know have hit, so I've tried to write the recovery pipeline in a way you can copy and adjust.
Why SPM Migration Drops dSYMs
In the CocoaPods era, framework dSYMs lived inside Pods.xcodeproj and ended up in the same Xcode Archive directory as the app's own dSYM. Uploading the archive to App Store Connect from Organizer dragged framework dSYMs along for the ride, and Crashlytics' upload-symbols script only needed to look at ${DWARF_DSYM_FOLDER_PATH} to find everything.
SPM changes the shape of this. Frameworks resolved through Swift Package Manager respect DEBUG_INFORMATION_FORMAT=dwarf-with-dsym, but in release builds with bitcode disabled the resulting dSYMs do not consistently land inside the archive's dSYMs/ folder. Across the six apps I inspected, FirebaseCrashlytics, FirebaseRemoteConfig, FirebaseAnalytics, GoogleUtilities, and nanopb were present, but FirebaseCore, FirebaseInstallations, and GoogleAppMeasurementIdentitySupport were missing.
The culprit is the combination of Xcode 16.1's dSYM generation timing and SPM's package cache. When the cache is fresh at archive time, Xcode skips a rebuild and therefore does not regenerate the dSYMs either. The older dSYMs sit in DerivedData/{project}/Build/Products/... but xcodebuild archive does not pull them in, so upload-symbols never sees them either.
Why I Trust the 4.2% Number
The trigger was not the Firebase console — it was a 6 AM JST report that Antigravity's Background Agent had been emitting for me for months. That agent queries Crashlytics API v1 for the past seven days of crashes and symbolication rate across the six apps and drops a summary into Slack. The 22 March report contained the following lines:
Because every app had been stable around 95%+ symbolication for as long as I can remember, the drop stood out. The Background Agent is configured with a rule: any 30% week-over-week drop should get a red flag plus a root-cause hypothesis. The 22 March report duly attached the hypothesis "drop coincides with FirebaseCrashlytics SDK migration to SPM on 14 March."
To weigh acting on the hypothesis against the risk of being wrong, I reproduced the failure on a single device first. Running xcrun dwarfdump --uuid against the TestFlight build's dSYMs and diffing against the UUIDs Crashlytics already knew about, the six apps averaged 41% missing UUIDs. The gap between 4.2% symbolication and 41% missing UUIDs comes from partially-symbolicated crashes, but either way the dSYM gap was clearly the cause.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Root-cause the SPM migration's dSYM fragmentation by inspecting Build Settings across six iOS apps, then reproduce the 4.2% Crashlytics symbolication failure deterministically
✦Have Antigravity's Background Agent run xcrun dwarfdump and upload-symbols in parallel to recover 1,847 dSYMs across six repositories in four weeks
✦Design monitoring jobs that respect Apple Silicon plus Xcode 16's 8-to-36-hour TestFlight dSYM availability window, and reconcile recovery against AdMob monthly revenue
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
If I tried to hunt down and re-upload dSYMs across six apps by hand it ran me about an hour per app. With my art residencies pulling me into different time zones, that approach was not sustainable. I drew a three-layer boundary: tasks the Background Agent handles autonomously, tasks it queues for approval, and tasks I always make myself.
Antigravity's Background Agent holds its session longer than most agents from the Anthropic/Google side, which is precisely why being deliberate about scope matters. Let it touch destructive operations and it will happily nuke things in the night.
# .antigravity/agents/dsym-recovery.yamlboundaries: auto: - Enumerate dSYMs from DerivedData / Archives / TestFlight-shipped builds - Extract UUIDs via xcrun dwarfdump --uuid - Query Crashlytics missing-dsyms API - Re-upload via upload-symbols - Verify UUID match after upload approve: - Re-download dSYMs through the App Store Connect API (consumes paid quota) - Delete archives older than 90 days (suggestion only) human_only: - Toggle bitcode on/off - Force Package.resolved updates - Bump Firebase SDK versions
Each item in the approve layer becomes a Slack Block Kit message. Tapping OK or Reject gets routed back to the Background Agent, which only continues if the human said yes.
The dSYM Locator Pipeline
The Background Agent's first job is a local dSYM hunt. I keep one dedicated Apple Silicon Mac mini M2 Pro that has access to DerivedData for all six repositories.
#!/usr/bin/env bash# scripts/dsym-locate.sh# Background Agent runs this at the auto tierset -euo pipefailSEARCH_ROOTS=( "${HOME}/Library/Developer/Xcode/DerivedData" "${HOME}/Library/Developer/Xcode/Archives")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 donedonecat "${OUTPUT_JSON}"
The seven-day cutoff (10,080 minutes) reflects the reality that Crashlytics only needs dSYMs near the current release. With six apps averaging 87.3 MB of dSYMs each, going further back would push transfer to half a gigabyte per week without much benefit.
The JSON output is stashed as dsym_inventory.json in the Background Agent's working memory so subsequent jobs can diff against it. Treating it as the agent's own prior output keeps the diffing fast.
Matching Against Crashlytics' Missing-dSYM API
Firebase does not expose a public REST endpoint for missing-dsyms, but firebase crashlytics:symbols:check works. The Background Agent uses firebase-tools@13, pinned, with a service-account key decrypted on demand from KMS.
The 32 MiB maxBuffer matters because at peak a single app surfaces 600+ missing UUIDs. Running six apps in parallel via Promise.all gives the Background Agent a six-app diff in a single job; in practice the whole pass runs in 47 seconds.
Joining missing_uuids.json against dsym_inventory.json produces the list of dSYMs that should be re-uploaded. On the first run on 1 April the join produced 1,847 dSYM paths queued for upload.
Parallelising upload-symbols Safely
The upload-symbols tool ships as a serial uploader. Sending 1,847 files at concurrency 1 would have taken just over five hours. The Background Agent caps concurrency at 4 per app and 24 globally — a number derived from Firebase's rate limit (roughly 30 requests per minute before 429s start arriving).
#!/usr/bin/env bash# scripts/upload-symbols-parallel.shset -euo pipefailINVENTORY="$1" # dsym_inventory.jsonMISSING="$2" # missing_uuids.jsonGOOGLE_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=4JOBS=()while IFS= read -r DSYM; do APP_NAME=$(echo "${DSYM}" | sed -E 's|.*Archives/[0-9-]+/(.*)\.xcarchive.*|\1|') 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[@]/$!/}") fidone <<< "${CANDIDATES}"waitecho "All upload-symbols jobs completed"
In practice 1,847 files finished in 38 minutes, a 5-hour-to-38-minute reduction. upload-symbols returns exit code 1 on failure, but the Background Agent treats 429, 503, and EOF cases differently: each gets exponential backoff with up to three retries. Anything still failing after that lands in failed_uploads.json and is retried first in the next day's job.
TestFlight Builds: The 8-to-36-Hour Wait
The recovery so far retrieves everything still living on local disk. dSYMs that Apple has post-stripped (bitcode-recompiled TestFlight builds, plus anything you only uploaded as an archive from a different machine) have to be pulled back through App Store Connect API: /v1/builds/{id}/dSYMs returns a ZIP — once Apple is ready. Expect anywhere from 8 to 36 hours.
Skipping builds younger than 8 hours stops the Background Agent from wasting its retry budget on 404s while Apple is still in processingState=PROCESSING. Measured success rates: 78% at 8 hours, 96% at 24 hours, 100% at 36 hours.
Reconciling Against AdMob Monthly Revenue
Restoring symbolication is necessary but not sufficient. To check whether the crashes that had been hurting AdMob revenue were actually resolved, I have the Background Agent pull AdMob monthly reports on the 1st of every month — interstitial request count, impression rate, eCPM — and align them with the symbolicated crash count.
The 5.3-point dip in March impression rate maps to the same week as the Crashlytics symbolication collapse — specific devices were dying often enough that the affected users churned before a typical interstitial cycle. With dSYMs restored and the targeted fix shipped, April's impression rate returned to baseline and monthly AdMob revenue held at +4.2% year over year. Watching the technical metric and the revenue metric in the same table is what makes the Antigravity Background Agent's multi-app aggregation pay off.
What I Recommend After Four Weeks
For my workflow — solo developer, six apps, AdMob as the primary revenue engine — handing dSYM recovery to Antigravity's Background Agent paid off clearly. Measured outcomes:
Weekly time spent on the recovery loop: 6 hours manual → 18 minutes automated
AdMob monthly revenue: +4.2% year over year (recovering crash-driven churn)
Background Agent cost: about $24 per month at this workload
If you ship through TestFlight, do not skip the ASC API "wait 8 hours" guard. Without it the daily job buries itself in 404 noise and you stop noticing the actual diff jobs.
If you adopt this pipeline, put App Store Connect API quota consumption under the approve tier. In my first two weeks I overshot the expected quota by 1.4x; pulling the lever to require approval is what brought it back.
For one to three apps you probably do not need to automate yet — hand recovery still fits in a coffee break. What I would suggest doing now, before app count grows, is standardising your dSYM layout and the directory structure for GoogleService-Info.plist files. Reorganising those later cost me hours I cannot get back.
dSYMs are an unglamorous corner of iOS operations, and the agents I want here are exactly the ones that can keep a session alive across hours of patient checking. Six months ago this recovery would have eaten a weekend. Right now I can glance at the Slack summary between exhibition prep and call it done.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.