深夜1時、Play Console の段階公開を 20% まで広げた直後のことです。
クラッシュ率の数字が、前バージョンより少しだけ高い。0.31% に対して 0.44%。止めるほどではない、と思いながら、私は結局2時間ほど画面を眺めていました。
翌朝、数字は 0.29% に落ち着いていました。夜間に流入したのは特定の古い端末群で、母数が小さかっただけだったのです。
あの2時間で失われたのは睡眠だけではありません。「基準を決めていなかった」という事実そのものが、判断を感情に明け渡していました。
停止基準を後回しにすると、判断は疲労に依存する
個人開発で Google Play にアプリを出していると、段階公開の監視は誰も代わってくれません。1%、5%、20%、50% と広げるたびに、数字を見て「進めるか、止めるか」を決めます。
このとき厄介なのは、止める判断が常に「今の自分の状態」に引きずられる点です。眠い夜は楽観的になり、リリース直後の緊張が残っている朝は悲観的になります。
エージェントに監視を任せる話をすると、しばしば「では停止もエージェントに」という流れになります。私はそこを分けました。
監視と集計は委ね、halt(公開停止)の実行だけは手元に残す。 その代わり、停止に相当する条件を、エージェントが読める形で先に書き切る。
判断を自動化するのではなく、判断の基準を自動化する。この順序を守ると、深夜の2時間は「基準に照らして続行」の10秒に置き換わります。
何を測るか — 最初の壁は母数の少なさ
段階公開の初期は、そもそも数字が信用できません。1% の露出で得られるセッションは、私の壁紙アプリでは1日あたり 600〜900 程度でした。ここでクラッシュが3件出ると、クラッシュ率は跳ね上がって見えます。
クラッシュ率は絶対値でなくベースライン比で読む
「クラッシュ率 1% 未満なら健全」という一般則は、アプリごとの実態を無視しています。私が採ったのは、直前の安定版(100% 配信中)を分母に置く比率です。
前版が 0.28%、新版が 0.42% なら比は 1.5。前版が 0.9%、新版が 1.1% なら比は 1.22 で、絶対値は高くても悪化はしていません。
警戒(warn)を 1.5 倍、停止候補(halt_candidate)を 2.0 倍に置きました。この閾値は絶対的な正解ではなく、私のアプリ群での過去12リリース分の変動幅(比 0.8〜1.35)の外側に取っただけです。
ANR は遅れて立ち上がる
クラッシュはインストール直後に出ますが、ANR(応答なし)はユーザーが実際に操作を続けた後に現れます。壁紙アプリの場合、初回起動から画像を数十枚スクロールした先で顕在化しました。
そのため ANR の判定は、クラッシュより 12 時間ほど遅らせています。同時に見ると、序盤の「ANR ゼロ」を健全さの証拠と誤読してしまうためです。詳しくは ApplicationExitInfo で ANR の死因を拾い、Antigravity に一次分類させる に書いた分類器を、そのまま監視ループに接続しています。
母数が足りないときは「判断しない」を返す
もっとも効いた設計は、これでした。閾値を満たしていても、セッション数が 800 未満なら結論を出さない。
エージェントは insufficient_data を返し、次のポーリングまで沈黙します。0/1 の判定に逃げず、「まだ言えない」という第三の返り値を持たせたことで、偽陽性の停止がなくなりました。
監視ループを Antigravity のエージェントに渡す
Play Developer API の Reporting エンドポイントを叩き、指標を正規化してから判定関数に渡します。エージェント側にはトークンではなく、読み取り専用のサービスアカウント経由の資格情報だけを与えました。
# rollout_watch.py — 段階公開の指標を集めて三値判定に渡す
import time
from dataclasses import dataclass
MIN_SESSIONS = 800 # これ未満は判断しない
WARN_RATIO = 1.5 # ベースライン比
HALT_RATIO = 2.0
ANR_DELAY_HOURS = 12 # ANR は遅れて立ち上がる
@dataclass
class Snapshot :
sessions: int
crash_rate: float # 0.0042 = 0.42%
anr_rate: float
hours_since_start: float
def fetch_snapshot (client, package: str , version_code: int ) -> Snapshot:
"""Play Developer Reporting API から直近1時間の指標を取得する。
集計遅延があるため、呼び出し側は最短でも15分間隔で回す。"""
m = client.query_metrics(
package = package,
version_code = version_code,
metrics = [ "crashRate" , "anrRate" , "distinctUsers" ],
window = "PT1H" ,
)
return Snapshot(
sessions = int (m[ "distinctUsers" ]),
crash_rate = float (m[ "crashRate" ]),
anr_rate = float (m[ "anrRate" ]),
hours_since_start = m[ "elapsedHours" ],
)
window="PT1H" を選んだ理由は、Play 側の集計遅延が実測で 40〜70 分あったためです。5分窓では空の結果が返り、エージェントがそれを「クラッシュゼロ」と解釈しかけました。空データと健全は違う、という当たり前の区別が、コード上では抜け落ちやすいところです。
停止・保留・続行の三値を返す
def decide (now: Snapshot, baseline_crash: float , baseline_anr: float ) -> tuple[ str , str ]:
"""(decision, reason) を返す。decision は continue / hold / halt_candidate / insufficient_data。"""
if now.sessions < MIN_SESSIONS :
return "insufficient_data" , f "sessions= { now.sessions } < { MIN_SESSIONS } "
crash_ratio = now.crash_rate / max (baseline_crash, 1e-6 )
if crash_ratio >= HALT_RATIO :
return "halt_candidate" , f "crash x { crash_ratio :.2f } (>= { HALT_RATIO } )"
if now.hours_since_start >= ANR_DELAY_HOURS :
anr_ratio = now.anr_rate / max (baseline_anr, 1e-6 )
if anr_ratio >= HALT_RATIO :
return "halt_candidate" , f "anr x { anr_ratio :.2f } after { now.hours_since_start :.0f } h"
if anr_ratio >= WARN_RATIO :
return "hold" , f "anr x { anr_ratio :.2f } "
if crash_ratio >= WARN_RATIO :
return "hold" , f "crash x { crash_ratio :.2f } "
return "continue" , f "crash x { crash_ratio :.2f } , sessions= { now.sessions } "
halt_candidate という名前は意図的です。エージェントは停止を提案するだけで、実行しません。
私の手元に届くのは Slack の1通と、その reason 文字列だけです。深夜であっても、「crash x2.31」という7文字を見れば、眺めるべき数字は決まっています。
停止の実行権をエージェントから外す設計は、エージェントに承認を挟む — HITL パイプラインの本番設計 で書いた考え方の延長にあります。取り返しのつく操作は委ね、取り返しのつかない操作には人間の指を1本挟む。
待機時間は時間ではなく露出量で刻む
「各段階で24時間待つ」という運用を長らく続けていましたが、これは平日と週末で得られる情報量がまるで違います。土曜の 5% は、火曜の 5% の1.4倍ほどのセッションを生みました。
そこで、待機の単位を時間から推定セッション数へ移しました。
段階 露出 先へ進む条件 実測の所要時間(中央値)
1 1% 2,000 セッション到達 かつ continue 約 38 時間
2 5% 6,000 セッション到達 かつ continue 約 22 時間
3 20% 20,000 セッション到達 かつ ANR 判定済み 約 18 時間
4 50% 連続2回 continue約 9 時間
段階3で「ANR 判定済み」を条件に加えているのは、ここを超えると母数の問題が消え、ANR が意味のある信号になるからです。
段階4の「連続2回 continue」は、単発のスパイクで先へ進まないための保険です。1回のポーリングで判断せず、15分後にもう一度同じ結論が出ることを要求します。
4リリース分を回してわかったこと
2026年4月から7月にかけて、壁紙アプリ2本で計4回のリリースをこの仕組みに通しました。
halt_candidate が上がったのは1回です。Android 15 の端末群でのみ、テーマ切替直後にクラッシュ率が 2.4 倍になりました。通知から実際に停止操作を終えるまで、私が要した時間は 6 分でした。
以前の私なら、この事象を朝まで見つけられなかったはずです。
一方で insufficient_data は延べ 31 回返っています。これらはすべて、以前なら「数字を見て不安になっていた」時間帯でした。何も判断しないという返答が、いかに多くのノイズを吸収していたか。
hold は 3 回。うち2回はそのまま continue へ戻り、1回は手元で調べた結果、旧版から続く既知の ANR だと分かりました。
返り値 回数 そのあと何が起きたか
continue 142 そのまま段階を進行
insufficient_data 31 次のポーリングまで待機
hold 3 2回は自然回復、1回は既知 ANR と判明
halt_candidate 1 6分後に手動で公開停止
落とし穴として残っているもの
ベースラインの取り方には、まだ課題が残ります。前版が 100% 配信されている前提で比を取っているため、緊急修正版を短期間で重ねると分母が安定しません。
いまは「前版の配信が 72 時間未満なら、さらにその前の版をベースラインにする」という回避策を入れていますが、これは応急処置です。理想は、端末 OS バージョン別に層化した比較でしょう。
もうひとつ。エージェントが Play Developer API のレート制限に触れると、例外が握り潰されて continue を返しかけたことがありました。例外は必ず insufficient_data へ倒すよう修正しています。停止側に倒すのではなく、判断しない側に倒す。ここを間違えると、無停止で走り抜けてしまいます。
自動化の失敗モードを「安全な方向」へ倒す設計は、無人運用のカナリアゲート — メジャーアップデートを人がいない時間に通す でも同じ結論に至りました。
停止基準を書くという行為そのものについて
このコードを書いていて、いちばん時間がかかったのは実装ではありませんでした。「自分はどうなったら止めるのか」を言語化する部分です。
数字を決めるには、過去のリリースを遡って変動幅を数える必要がありました。私自身、この作業を面倒だと感じて何年も先延ばしにしてきました。その作業を通じて、私は自分のアプリの平常時をはじめて具体的に知りました。
エージェントに監視を委ねるための準備が、結果として自分の運用を初めて可視化した。この順番の逆転が、少し可笑しく、そして胸に残っています。
次に手を入れるなら、OS バージョン別の層化です。閾値をひとつ持つより、母集団を割ってから比を取るほうが、深夜の判断はさらに短くなるはずです。
拙い記録ですが、段階公開の停止基準をこれから決める方の手がかりになれば幸いです。お読みいただきありがとうございました。