リリース直後の72時間がいかに危ういか、個人開発で12年ほどアプリを出してきて、今でも背筋が伸びる場面です。アップデートを App Store と Google Play に同時に投げたあと、最初の一晩で何が起きるかは、どれだけテストしても分かり切らない領域があります。
ここ数年は壁紙アプリ6本を並行運用していて、リリース直後の指標を眺めるためにダッシュボードを何度もリロードしていた時期がありました。Firebase の Crashlytics を開き、AdMob の管理画面に移り、App Store Connect の解析を見て、Google Play Console に戻る——同じ動作を6アプリ分繰り返すうち、自分の集中が薄くなって肝心な兆候を見逃すこともあります。
そこで Antigravity の Background Agent に「監視役」を任せる組み立てを試したところ、72時間の張り付きから解放されました。今回はその設計を、6アプリ並行運用で固まった閾値と実際のクエリごと残しておきます。
リリース後72時間が「危険地帯」になる構造
App Store と Google Play のローリングリリースは、技術的には同じ「段階公開」でも挙動が大きく違います。5,000万DL規模の壁紙アプリで両方を運用していると、最初の72時間に4種類の指標が独立に動き出す感覚があります。
クラッシュ率は最も速く動きます。新しいビルドがリリースされて15分後にはCrashlyticsへ最初のスタックトレースが上がってくることが珍しくありません。ANR(Android の応答なし)はその数時間遅れで顕在化します。eCPM は AdMob のメディエーション計算が落ち着くのに6〜12時間ほどかかり、リテンション系の指標(特にD1)は当然24時間後にしか確定しません。
つまり、リリース後72時間の監視は単一画面では成立しません。指標ごとに「いつ・どの程度の崩れが許容範囲か」を分けて読み取る必要があり、これを人間が連続して見続けるのは現実的ではないと、ある時期から強く感じていました。
ここに Background Agent を据えると、いくつもの画面を行き来する必要がなくなります。サンドボックスVMの中で各APIを叩き、結果を1通のMarkdownレポートに統合させれば、見るべき場所は1箇所で済みます。
なぜ Background Agent をモニタに据えるのか
Antigravity の Background Agent は、開発作業とは別ウィンドウで動く非同期エージェントです。コード生成やレビューに使う印象が強いものの、運用フェーズの「常駐監視」にも向いています。理由は3つあります。
まず、隔離されたサンドボックスVMで動くため、ローカル環境を圧迫しません。私の開発機は Mac mini 1台で6アプリの開発を兼ねているので、メインのIDEを動かしたまま監視ジョブを別レーンで走らせられるのは助かります。
次に、ネットワークが許可されているのでFirebase REST API や AdMob Reporting API への到達が問題ありません。Google Cloud のサービスアカウント鍵を Background Agent に渡しておけば、認証も自動化できます。
最後に、長時間タスクに向いた設計になっています。インラインエージェントだとセッションが切れがちですが、Background Agent は「1時間ごとに走らせる」「6時間ごとに集計する」といった粒度のタスクと相性が良く、72時間に渡る連続監視に耐えます。
私の場合は両家の祖父が宮大工で、子どもの頃に「梁を打ったあとは雨が降っても風が吹いても、しばらく現場に通って様子を見る」と聞いていました。リリース後の監視はそれに近いと感じています。完成して終わりではなく、構造が落ち着くまで通うこと自体が仕事の一部です。Background Agent は、その「通う」役割を自動化する手段として一番しっくりきました。
4指標同時監視の設計図
最初の試行錯誤で学んだのは、「全部を見ようとして全部見えなくなる」ことです。新しいビルドが出ると、つい20指標くらい眺めたくなりますが、Background Agent に渡す指標は4つに絞るのが結局一番強いと分かりました。
選んだのは、crash-free users・ANR rate・eCPM・D1 retention の4つです。それぞれの役割と警告ラインを以下に整理します。
指標 データソース 確定時間 警告ライン
crash-free users Crashlytics リリース後1時間〜 99.5%を下回ったら通知
ANR rate Play Console Vitals リリース後3時間〜 0.47%を上回ったら通知
eCPM (rewarded) AdMob Reporting API リリース後12時間〜 直前7日平均から20%以上のdip
D1 retention Firebase Analytics リリース後24時間〜 直前7日平均から5pt以上の下落
5,000万DL規模で運用していると、これらの閾値はかなり保守的な数字に落ち着きます。例えば crash-free users は通常99.7〜99.9%で推移しているので、99.5%は「明確な異常」のラインです。ANR の0.47%は Google Play の「Bad Behavior Threshold」に基づいた値で、これを超えるとストア露出に影響が出ます。
Background Agent には、これら4指標を1時間ごとに引き抜き、警告ラインを越えたものだけをハイライトする役割を持たせます。実装の概略を Python で表すと次のようになります。
# release_monitor/runner.py
from datetime import datetime, timedelta
from typing import TypedDict
class MetricResult ( TypedDict ):
name: str
value: float
threshold: float
breached: bool
source: str
def collect_metrics (app_id: str , build: str ) -> list[MetricResult]:
return [
crashlytics_crash_free(app_id, build),
play_vitals_anr(app_id, build),
admob_ecpm_dip(app_id, build),
ga4_d1_retention(app_id, build),
]
def build_report (app_id: str , build: str ) -> str :
metrics = collect_metrics(app_id, build)
breached = [m for m in metrics if m[ "breached" ]]
header = f "# { app_id } / build { build } / { datetime.utcnow().isoformat() } Z \n\n "
if not breached:
return header + "全4指標は警告ライン内です。次の集計まで待機します。 \n "
body = " \n " .join(
f "- ⚠️ ** { m[ 'name' ] } ** = { m[ 'value' ] :.3f } (threshold { m[ 'threshold' ] :.3f } , source: { m[ 'source' ] } )"
for m in breached
)
return header + "## 警告ライン超過 \n\n " + body + " \n "
このスクリプト自体は Background Agent の中で実行され、結果が Markdown ファイルとして出てきます。Background Agent はファイルシステムへの書き込みがVM内で完結するので、reports/2026-05-20T03Z.md のようなパスにレポートを残し、後でPRに添付して受け取る使い方になります。
Firebase Analytics クエリの自動化
D1 retention を Firebase Analytics から引き抜くのは、BigQuery エクスポートを有効化していれば一番素直です。私は壁紙アプリ全てを BigQuery 連携にしており、デイリーパーティションの events_* テーブルから直接クエリを書いています。
-- bigquery/d1_retention.sql
WITH first_open AS (
SELECT
user_pseudo_id,
MIN (event_date) AS first_date
FROM `dolice-wallpapers.analytics_xxxxx.events_*`
WHERE event_name = 'first_open'
AND _TABLE_SUFFIX BETWEEN @start_date AND @end_date
GROUP BY user_pseudo_id
),
returned AS (
SELECT DISTINCT user_pseudo_id
FROM `dolice-wallpapers.analytics_xxxxx.events_*`
WHERE event_name = 'session_start'
AND _TABLE_SUFFIX BETWEEN @start_date AND @end_date
)
SELECT
fo . first_date ,
COUNT ( DISTINCT fo . user_pseudo_id ) AS new_users,
COUNT ( DISTINCT r . user_pseudo_id ) AS retained_d1,
SAFE_DIVIDE( COUNT ( DISTINCT r . user_pseudo_id ), COUNT ( DISTINCT fo . user_pseudo_id )) AS d1_rate
FROM first_open fo
LEFT JOIN returned r
ON fo . user_pseudo_id = r . user_pseudo_id
WHERE r . user_pseudo_id IN (
SELECT user_pseudo_id FROM `dolice-wallpapers.analytics_xxxxx.events_*`
WHERE event_name = 'session_start'
AND PARSE_DATE( '%Y%m%d' , event_date) = DATE_ADD(PARSE_DATE( '%Y%m%d' , fo . first_date ), INTERVAL 1 DAY )
)
GROUP BY fo . first_date
ORDER BY fo . first_date DESC ;
Background Agent には bq CLI のインストール手順と、サービスアカウントの認証情報を渡す AGENTS.md を整備しておきます。私は次のような最小構成で動かしています。
# AGENTS.md(抜粋・release-monitor リポジトリ)
このリポジトリは Antigravity Background Agent によるリリース後監視ジョブを動かす場所です。
## 認証
- `GOOGLE_APPLICATION_CREDENTIALS` に bq 用サービスアカウント鍵のパスをセット
- AdMob は `ADMOB_OAUTH_REFRESH_TOKEN` を環境変数で渡す(Reporting API スコープ付き)
- Crashlytics は Firebase REST API 経由なので、上記サービスアカウント鍵で兼用可能
## 実行
- `python release_monitor/runner.py --app=<bundle_id> --build=<version>`
- 1時間ごとに走らせる場合は `cron/release-monitor.cron` を参照
- 出力は `reports/<utc-timestamp>.md`
## 触ってはいけない場所
- `secrets/` (鍵類)。手動配置・手動更新のみ。
- `reports/archive/` 配下の過去レポート(追記禁止・新規作成のみ)
AGENTS.md を整備しておくと、Background Agent が「何をしていいか」を勝手に判断してくれるので、運用の手戻りが減ります。これは Antigravity を本格的に使い始めて以降、最も効いた習慣のひとつです。
Crashlytics スタックトレース要約パイプライン
Crashlytics で新しいクラッシュが出ても、人間が読むには時間がかかります。Background Agent の本領は、スタックトレースを集約して「同じ原因の塊を1グループとして提示する」点にあると感じています。
Firebase Crashlytics には Issue API がないので、私は BigQuery エクスポート(firebase_crashlytics.events_*)から取得しています。クエリは次の通りです。
-- bigquery/crashlytics_top_issues.sql
SELECT
issue_id,
ANY_VALUE(issue_title) AS title,
ANY_VALUE(issue_subtitle) AS subtitle,
COUNT ( DISTINCT installation_uuid) AS affected_installs,
COUNT ( * ) AS crash_count,
MIN (event_timestamp) AS first_seen,
MAX (event_timestamp) AS last_seen,
ANY_VALUE( application . display_version ) AS version
FROM `dolice-wallpapers.firebase_crashlytics.<app_id>_ANDROID_REALTIME`
WHERE _PARTITIONTIME >= TIMESTAMP_SUB( CURRENT_TIMESTAMP (), INTERVAL 72 HOUR )
AND application . display_version = @target_version
AND event_type = 'crash'
GROUP BY issue_id
ORDER BY affected_installs DESC
LIMIT 10 ;
Background Agent に渡すときは、結果をJSONに変換してから「上位3件のスタックトレース要約と疑わしいコミットの推定」を出力させると実用的になります。
# release_monitor/crashlytics_summary.py
import json, subprocess, textwrap
def fetch_top_issues (app_id: str , version: str ) -> list[ dict ]:
out = subprocess.check_output([
"bq" , "query" , "--use_legacy_sql=false" , "--format=json" ,
f "--parameter=target_version:: { version } " ,
open ( "bigquery/crashlytics_top_issues.sql" ).read(),
])
return json.loads(out)
def summarize_for_agent (issues: list[ dict ]) -> str :
if not issues:
return "クラッシュなし。"
lines = []
for i, issue in enumerate (issues[: 3 ], 1 ):
lines.append(textwrap.dedent( f """
### Issue { i } : { issue[ "title" ] }
- 影響インストール数: { issue[ "affected_installs" ] }
- クラッシュ件数: { issue[ "crash_count" ] }
- 初回検出: { issue[ "first_seen" ] }
- 推定要因: { issue[ "subtitle" ] }
""" ).strip())
return " \n\n " .join(lines)
ここで重要なのは「上位3件で打ち切る」ことです。Background Agent に全件読ませると、コンテキストを使い切ってしまい、本来の判断が遅くなります。経験的に、リリース後72時間で本当に対処すべきなのは上位2〜3件で、それ以下のものはたいてい既知のバックグラウンドノイズに収まります。
AdMob eCPM の dip 検知
AdMob は管理画面では見やすいのですが、API での取得は意外と癖があります。Reporting API v1 が安定版で、OAuth リフレッシュトークンを発行しておくと Background Agent から呼び出せます。私のように複数アプリを持っている場合、Network Report を AD_UNIT 粒度で取得して、リワード広告だけに絞ると現実的な dip 検知ができます。
# release_monitor/admob_ecpm.py
import os, requests, statistics
from datetime import date, timedelta
ADMOB_REPORT_URL = (
"https://admob.googleapis.com/v1/accounts/ {publisher_id} /networkReport:generate"
)
def fetch_ecpm (publisher_id: str , ad_unit_id: str , days_back: int ) -> list[ float ]:
end = date.today()
start = end - timedelta( days = days_back)
body = {
"report_spec" : {
"date_range" : {
"start_date" : { "year" : start.year, "month" : start.month, "day" : start.day},
"end_date" : { "year" : end.year, "month" : end.month, "day" : end.day},
},
"dimensions" : [ "DATE" , "AD_UNIT" ],
"metrics" : [ "AD_REQUESTS" , "MATCHED_REQUESTS" , "IMPRESSIONS" , "ESTIMATED_EARNINGS" ],
"dimension_filters" : [{
"dimension" : "AD_UNIT" ,
"matches_any" : { "values" : [ad_unit_id]},
}],
}
}
headers = { "Authorization" : f "Bearer { os.environ[ 'ADMOB_ACCESS_TOKEN' ] } " }
res = requests.post( ADMOB_REPORT_URL .format( publisher_id = publisher_id), json = body, headers = headers)
res.raise_for_status()
rows = res.json().get( "row" , [])
return [
( int (r[ "metricValues" ][ "ESTIMATED_EARNINGS" ][ "microsValue" ]) / 1_000_000 )
/ max ( int (r[ "metricValues" ][ "IMPRESSIONS" ][ "integerValue" ]), 1 ) * 1000
for r in rows if "metricValues" in r
]
def detect_dip (ecpm_series: list[ float ], window: int = 7 , threshold: float = 0.20 ) -> bool :
if len (ecpm_series) <= window:
return False
baseline = statistics.median(ecpm_series[: - 1 ][ - window:])
latest = ecpm_series[ - 1 ]
return baseline > 0 and (baseline - latest) / baseline >= threshold
私の壁紙アプリでは、リリース日に eCPM が普段の20%以上下がる場合、ほぼ確実に何か起きています。経験的にはこのうち、3割が「メディエーションの新しい順序が悪い方向に動いた」、2割が「広告表示頻度の調整ミス」、残り5割が「アプリ側の挙動変化(ユーザーが報酬広告を見なくなった等)」でした。
Background Agent には dip 検知だけを任せ、原因の特定は人間が引き取る形にしています。ここを完全自律にすると、推測が暴走する確率が高くなる印象があります。境界線を引いておくと、Background Agent の出力を信頼できる材料として読めるようになります。
統合レポートを Markdown で受け取る設計
4指標を別々に集めると、結局自分で整列させる手間が残ってしまいます。私は Background Agent の最終ステップを「1通のMarkdownレポートに統合する」役割に固定しました。
# release_monitor/compose_report.py
from pathlib import Path
def compose (app_id: str , build: str ) -> Path:
crash_free = crashlytics_crash_free(app_id, build)
anr = play_vitals_anr(app_id, build)
ecpm = admob_ecpm_dip(app_id, build)
d1 = ga4_d1_retention(app_id, build)
md = [
f "# Release Monitor / { app_id } / build { build } " ,
"" ,
"## サマリー(72h 集計)" ,
f "| 指標 | 現在値 | 閾値 | 判定 |" ,
f "| --- | --- | --- | --- |" ,
f "| crash-free users | { crash_free[ 'value' ] :.3% } | 99.5% | { '⚠️ 超過' if crash_free[ 'breached' ] else '✅ 安全' } |" ,
f "| ANR rate | { anr[ 'value' ] :.3% } | 0.47% | { '⚠️ 超過' if anr[ 'breached' ] else '✅ 安全' } |" ,
f "| eCPM dip | { ecpm[ 'value' ] :.1% } | 20% | { '⚠️ 超過' if ecpm[ 'breached' ] else '✅ 安全' } |" ,
f "| D1 retention | { d1[ 'value' ] :.3% } | base-5pt | { '⚠️ 超過' if d1[ 'breached' ] else '✅ 安全' } |" ,
"" ,
"## 推奨アクション" ,
recommended_action(crash_free, anr, ecpm, d1),
]
out = Path( f "reports/ { app_id } - { build } .md" )
out.write_text( " \n " .join(md), encoding = "utf-8" )
return out
Markdown にしておくとPRに添付しやすく、GitHub のCommit comment に貼っても読めるので、後から振り返るのも楽になります。私はリリースタグごとに release-monitor リポジトリの reports/ に72時間分のレポートを残しています。3年経った今、これが過去のリリースを比較する一次資料として相当役立っています。
段階公開と組み合わせる
Background Agent が出すレポートは、そのままでは「気づき」にしかなりません。段階公開と組み合わせることで、初めて意思決定に直結します。
私は壁紙アプリの Google Play 段階公開を、5% → 20% → 50% → 100% の4ステップに固定しています。各ステップで Background Agent が3時間ごとにレポートを出し、警告ラインを下回らなければ次のステップに進めます。
# .github/workflows/staged-rollout.yml
name : Staged Rollout Check
on :
schedule :
- cron : "0 */3 * * *" # 3時間ごと
workflow_dispatch :
jobs :
check :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- name : Run release monitor
env :
GOOGLE_APPLICATION_CREDENTIALS : ${{ secrets.GCP_SA_KEY_PATH }}
ADMOB_ACCESS_TOKEN : ${{ secrets.ADMOB_TOKEN }}
run : |
python release_monitor/runner.py \
--app=jp.dolice.wallpaper.beautifulhd \
--build=${{ vars.CURRENT_BUILD }}
- name : Post report as commit comment
run : |
gh api repos/${{ github.repository }}/commits/${{ github.sha }}/comments \
-f body="$(cat reports/jp.dolice.wallpaper.beautifulhd-${{ vars.CURRENT_BUILD }}.md)"
このワークフローは GitHub Actions が実行しますが、レポートを生成する release_monitor/runner.py の中身は Background Agent が継続的に改善している、というのが現状の運用です。新しい指標を足したくなったら Background Agent にリポジトリを任せ、PRが上がってきたら私がレビューしてマージします。
App Store Connect 側はローリングリリースの粒度が荒く(基本は1日刻みの段階公開)、Google Play ほど細かい制御はできません。それでも crash-free が99.5%を割っていれば、Phased Release を一時停止する判断材料になります。
6アプリ並行運用で固まったワークフロー
6本の壁紙アプリを並行運用するなかで、Background Agent との付き合い方も少しずつ変わりました。最も大きな学びは、「アプリごとに別の Background Agent セッションを持つ」ことです。
最初は全アプリを1セッションで監視させていましたが、コンテキストが混ざる場面が増えました。例えば「Beautiful HD Wallpapers」と「Ukiyo-e Wallpapers」では、想定するユーザー層も収益化の仕組みも違うため、同じ閾値を当てても意味のある判断にならないことがあります。
今は次のような配置に落ち着いています。
アプリごとに独立した release-monitor-<app> リポジトリを作る
それぞれに専用の Background Agent を割り当てる
共通の release-monitor-core リポジトリにロジックを切り出し、各アプリのリポジトリは設定のみを持つ
1日1回、6アプリ分のレポートを release-monitor-dashboard リポジトリに集約させる
この構成だと、特定のアプリで監視を強化したくなった時に他に影響しません。Background Agent も自分の担当範囲だけ覚えていればいいので、コンテキスト効率が良くなります。お勧めする構成です。
30日運用して見えた境界線
30日ほどこの体制で運用して、Background Agent に任せていい範囲とそうでない範囲がはっきりしてきました。境界線は次の通りです。
任せてよいこと:4指標の継続取得、警告ライン超過の通知、過去のレポートとの差分提示、上位クラッシュ要因の要約、release_monitor リポジトリ内のロジック改善PR作成。
任せないほうがよいこと:原因の最終特定(推測が暴走することがあるため)、Rollout の停止判断(人間の責任で行うため)、ストア担当への返信文の起草(ユーザーへの言葉は手作業で考えたいため)、Crashlytics の Issue を Resolved にする操作(誤判定の取り返しが効かないため)。
この境界線は、両家の祖父が宮大工だったことを思い出すと、感覚として理解しやすくなります。手を動かす作業は道具に任せられても、最終的に「ここで筆を止める」と判断する瞬間は職人の手元に残るべきです。Background Agent は驚くほど賢く動きますが、リリース停止のような不可逆な判断は私自身が引き受けるようにしています。
72時間の張り付き監視から解放されたあとも、運用の本質は変わりません。リリースした建物の梁が落ち着くまで様子を見続ける、その時間を Background Agent が代わりに過ごしてくれるようになっただけです。手元に届くのは1通のMarkdownレポートで、それを読むのは私の仕事として残します。お読みいただきありがとうございました。