段階公開(staged rollout)の途中で「このまま 100% に上げていいのか、止めるべきか、巻き戻すべきか」を、毎朝 30 分ずつ 6 本のアプリで判断していた時期がありました。Beautiful HD Wallpapers と Ukiyo-e Wallpapers と Law of Attraction Everyday と Relaxing Healing と、社外秘で出している壁紙系の派生 2 本。2014 年から個人でアプリを作ってきて、累計 5,000 万 DL を超えたあたりから、リリース後の数時間で「この更新は安全か」を見極める判断コストが、開発そのものより重くなり始めました。
アーティスト・クリエイターの廣川政樹です。本記事は、Firebase Crashlytics のクラッシュフリー率と App Store / Google Play のレビュー文と AdMob の売上を 1 つの判定エンジンに集約し、Gemini Flash で「continue / pause / rollback」のどれを選ぶかを構造化出力させる仕組みを、個人開発 6 アプリで 2 ヶ月運用した実測ベースで書いています。
段階公開そのものは Google Play / App Store の機能ですが、3 指標を毎日横断で見るのは個人開発者には負荷が高い領域です。Gemini Flash の構造化出力と長コンテキストを組み合わせると、ここが現実的な運用コストに収まることが見えてきました。
段階公開で個人開発者が本当に困っているのは「指標が縦割り」なこと
段階公開の機能自体は便利です。Google Play なら 1% → 5% → 10% → 20% → 50% → 100%、App Store なら 1 日目 1% → 2 日目 2% → … → 7 日目 100% の自動増加。問題はリリース後に見るべき指標が複数のダッシュボードに散らばっていることでした。
私の場合は以下の 3 つでした。
Firebase Crashlytics : 新バージョンのクラッシュフリー率・新規 issue・regression(既知 issue の再発)
App Store Connect / Google Play Console のレビュー : 新バージョン以降の低評価レビュー・特定キーワード(クラッシュ・落ちる・課金・consent 等)の急増
AdMob : 新バージョンのインプレッション・eCPM・ARPU の前バージョン比
3 つを並べて毎朝チェックしていた頃は、ブラウザのタブを 18 個くらい開いて、Crashlytics → Reviews → AdMob と回す動線になっていました。1 アプリ 5 分でも 6 アプリで 30 分。週 7 日続けると週 3.5 時間、つまり半日が消えます。
個人開発で半日を毎週使うのは妥当ではありません。「ロールアウトを止めるか進めるか」という判断は、本当に難しいレアケース以外は数値で機械的に決まるはずです。そこを Gemini Flash に任せられないか、というのがこの仕組みの動機でした。
判定ロジックを「ルール」ではなく「LLM 構造化出力」にした理由
最初は素朴に if 文のルールベースで書きました。「クラッシュフリー率が前バージョン -0.5% 以下なら pause」「★1 レビューが新バージョン全体の 15% を超えたら rollback」「AdMob ARPU が前比 80% 以下かつ DAU が前比 90% 以上なら pause」のような閾値の組み合わせです。
これが上手くいかなかった理由は、指標の相互関係を if 文で書ききれない ことでした。例えば「クラッシュフリー率は -0.3% で閾値内だが、その下がり方が特定機種(Android 14 の Pixel 7)だけに集中していて、しかも該当機種のレビューに『起動できない』が出始めている」というケース。ルールベースだと「クラッシュフリー率が閾値内なので continue」になりますが、人間の運用者が見れば「該当機種で深刻な regression」と判断します。
Gemini Flash の構造化出力に 3 指標の生データを渡すと、こうした横串の判断を 1 回の API コールで取り出せました。判定理由(reason)を 100 字程度で返させるようにすると、人間が後からレビューしやすくなります。
ここで重要なのは、LLM に「最終決定」をさせるのではなく、「continue / pause / rollback」と「reason」と「confidence」を返させて、confidence が低い場合だけ私が手動レビューするフローにすることです。判断は LLM、責任は人間、という線引きを最初に決めました。
3 指標を 1 つの判定 JSON に集約するスキーマ設計
Gemini Flash に渡すスキーマは、最初は 200 行近くありましたが、運用しながら絞り込んで最終的にはこの形に落ち着きました。
from pydantic import BaseModel
from typing import Literal
class StagedRolloutDecision ( BaseModel ):
verdict: Literal[ "continue" , "pause" , "rollback" ]
confidence: Literal[ "high" , "medium" , "low" ]
primary_signal: Literal[ "crash" , "review" , "revenue" , "composite" ]
reason: str # 80〜200 字程度
next_action: str # 「2026-05-28 12:00 に再評価」など
flagged_devices: list[ str ] # ["Pixel 7 Pro (Android 14)", "iPhone 13 mini (iOS 17.5)"]
flagged_review_keywords: list[ str ] # ["起動しない", "consent ループ"]
判定 verdict は continue / pause / rollback の 3 値に絞りました。「より慎重な観察モード」みたいな中間値を増やすと、私自身が判断を後回しにしてしまうのが見えていたからです。
confidence は 3 値(high / medium / low)に絞り、low だけ手動レビューに回す運用にしました。当初は 0.0〜1.0 のスコアを返させていましたが、Gemini Flash は数値の校正が完璧ではないので、3 値の方が私の感覚に合っていました。
primary_signal は「主にどの指標で判断したか」を返させる項目です。後で振り返ったときに「ロールバックの何割がクラッシュ起因か」を集計できるようにするためのメタデータです。
Gemini Flash に渡すプロンプトの作り方
プロンプトは「役割」「3 指標の生データ」「判定基準」「出力例」の 4 ブロックに分けています。長さは最終的に約 1,200 トークン。出力は構造化出力で 300〜500 トークン程度です。
import google.generativeai as genai
from pydantic import BaseModel
SYSTEM_PROMPT = """ \
あなたは個人開発の iOS / Android アプリの段階公開を監視する SRE です。
新バージョンのリリース後 24〜72 時間における Crashlytics・レビュー・AdMob 売上の
3 指標を見て、ロールアウトを継続するか、一時停止するか、巻き戻すかを判断します。
判断方針:
- クラッシュフリー率が前バージョン比 -0.5% 未満なら原則 pause、-1.0% 未満なら rollback
- ただし、低下が特定機種・特定 OS バージョンに集中している場合は flagged_devices に記録
- レビューで「起動しない / 落ちる / 課金できない / consent ループ」等のキーワードが
新バージョン全体の 10% を超えたら原則 pause
- AdMob ARPU が前比 70% 以下かつ DAU が前比 90% 以上なら、収益構造の事故と判定して pause
- 上記の単独基準を満たさなくても、複数指標が同じ機種に集中していたら composite として pause
- 判断に迷う場合は confidence=low を返し、reason に「人間レビュー推奨」と書く
"""
def build_user_prompt (app_id: str , snapshot: dict ) -> str :
return f """ \
アプリ: { app_id }
新バージョン: { snapshot[ 'new_version' ] } (リリース { snapshot[ 'hours_since_release' ] } h 経過)
ロールアウト割合: { snapshot[ 'rollout_percent' ] } %
[Crashlytics]
- 新バージョンのクラッシュフリー率: { snapshot[ 'crash_free_new' ] } %
- 前バージョンのクラッシュフリー率: { snapshot[ 'crash_free_prev' ] } %
- 新規 issue 数: { snapshot[ 'new_issues' ] }
- 機種別クラッシュ分布: { snapshot[ 'crash_by_device' ] }
[Reviews]
- 新バージョン以降のレビュー件数: { snapshot[ 'review_count' ] }
- ★1〜★2 の比率: { snapshot[ 'low_rating_ratio' ] } %
- 抽出されたネガティブキーワード: { snapshot[ 'negative_keywords' ] }
- 主要レビュー本文(抜粋 5 件): { snapshot[ 'review_excerpts' ] }
[AdMob]
- 新バージョンの ARPU(24h): { snapshot[ 'arpu_new' ] }
- 前バージョンの ARPU(24h): { snapshot[ 'arpu_prev' ] }
- 新バージョンの DAU: { snapshot[ 'dau_new' ] } (前比 { snapshot[ 'dau_ratio' ] } %)
- 新バージョンの fill rate: { snapshot[ 'fill_rate_new' ] } %
上記から、verdict / confidence / primary_signal / reason / next_action /
flagged_devices / flagged_review_keywords を構造化 JSON で返してください。
"""
Gemini Flash 3.0 を使う場合、構造化出力は response_mime_type="application/json" と response_schema で型を渡すパターンに統一しています。
def evaluate (app_id: str , snapshot: dict ) -> StagedRolloutDecision:
model = genai.GenerativeModel(
"gemini-3.0-flash" ,
system_instruction = SYSTEM_PROMPT ,
generation_config = {
"response_mime_type" : "application/json" ,
"response_schema" : StagedRolloutDecision,
"temperature" : 0.2 , # 安定性を優先
},
)
response = model.generate_content(build_user_prompt(app_id, snapshot))
return StagedRolloutDecision.model_validate_json(response.text)
temperature=0.2 は同じ入力に対して再現性を出すためです。0.0 にすると稀に空応答や finish_reason=RECITATION が出るケースがあったので、私の運用では 0.2 が安定領域でした。
実装でハマった 3 つの落とし穴
1. クラッシュフリー率の「前バージョン」をどう取るか
最初は Crashlytics API で「前バージョン」を直前のリリースとして取得していました。これが上手くいかなかったのは、ホットフィックスを連続で出している期間に、判定基準となる「前バージョン」が毎回ホットフィックスになってしまい、ベースラインが下方修正されていく問題です。
対策として、Firestore に「安定版バージョン」を 1 つ手動で記録しておき、判定時はそれをベースラインに使うようにしました。新しい安定版に昇格させるのは、ロールアウトが 100% に到達して 7 日経過したタイミングです。
def get_baseline_version (app_id: str ) -> str :
doc = firestore.collection( "apps" ).document(app_id).get()
return doc.to_dict()[ "stable_baseline" ]
この変更だけでロールバック誤判定が体感で 4 割減りました。
2. レビューの「新バージョン以降」フィルタが言語別に揺れる
App Store Connect のレビュー API は version フィルタが効きますが、Google Play Console の出力 CSV は version 文字列が「2.4.1」「2.4.1 (build 89)」「2.4.1(β)」のように揺れることがありました。私の実機で確認した範囲では、β リリーサー向けのレビューが混入することが原因のようです。
対策はシンプルで、version 文字列を正規表現で ^v?\d+\.\d+\.\d+ だけ拾うように正規化しました。
import re
def normalize_version (s: str ) -> str :
m = re.match( r " ^ v ? (\d + \. \d + \. \d + ) " , s)
return m.group( 1 ) if m else s
これで Google Play の β レビューが本番リリース判定に混入する事故を回避できました。
3. AdMob の数値が「リリース直後の数時間」では信頼できない
リリース直後 6 時間以内の AdMob ARPU は揺れます。新バージョンが行き渡る前の母集団が小さいことと、AdMob の集計反映に 2〜4 時間のラグがあることが原因です。最初の頃、リリース直後の ARPU 低下で pause 判定が出てしまい、無駄に手動レビューに回されることが続きました。
対策として、hours_since_release < 8 の場合は AdMob 指標を判定根拠に使わない、というフラグを Gemini Flash に伝えるようにしました。プロンプト末尾に「リリース後 8 時間未満の場合は AdMob 指標を judgement に使わず、Crashlytics とレビューのみで判断してください」と追加しています。
この調整後、early-stage の誤判定がほぼゼロになりました。
6 アプリ並列で運用するスケジューリング
判定エンジンを動かすトリガーは、Cloud Scheduler から 4 時間ごとに Cloud Run Jobs を起動する形にしました。Pub/Sub を間に挟んで、各アプリの判定を並列で実行します。
1 アプリ 1 回の判定で消費するトークン数は、入力 1,200〜1,800・出力 300〜500 程度で、Gemini Flash 3.0 の単価でだいたい 1 回 0.3〜0.6 円。6 アプリ × 4 時間ごと × 30 日 = 月 1,080 回。月のコストは 320〜640 円に収まりました。
# Cloud Run Jobs の entrypoint
import os, json
from firebase_admin import firestore
def main ():
app_id = os.environ[ "APP_ID" ]
snapshot = collect_snapshot(app_id) # Crashlytics + Reviews + AdMob を集める
decision = evaluate(app_id, snapshot)
firestore.collection( "rollout_decisions" ).add({
"app_id" : app_id,
"ts" : firestore. SERVER_TIMESTAMP ,
"snapshot" : snapshot,
"decision" : decision.model_dump(),
})
if decision.verdict == "rollback" :
send_urgent_notification(app_id, decision)
elif decision.verdict == "pause" :
send_review_request(app_id, decision)
verdict が rollback の場合だけ Stand.fm の私の電話番号に通知が飛ぶようにしてあります。pause の場合は朝のレビュー時に Slack に貯まる運用です。rollback で叩き起こされた回数は 2 ヶ月で 3 回。すべて結果的に正しい判断でした。
2 ヶ月運用した結果の数値
6 アプリで 2 ヶ月(合計 12 アプリ月)運用した数値です。
判定総数: 2,160 回(1 アプリ × 1 日 6 回 × 60 日)
continue: 1,924 回(89.1%)
pause: 218 回(10.1%)
rollback: 18 回(0.8%)
うち confidence=low で手動レビューに回したケース: 73 回(3.4%)
人間の最終判断と Gemini Flash の判定が一致した率は、pause と rollback に絞ると 91% でした。残り 9% のうち半分は私が「もう少し見たい」と判断して continue に上書きしたケース、残り半分は私が「これは早めに rollback したい」と判断して rollback に格上げしたケースで、誤判定で実害が出たケースはゼロでした。
実害のある誤判定がゼロだった理由は、verdict=rollback でも実際の rollback はワンクリックで私が承認する設計にしているからです。LLM の判定を信用しすぎないラインを最初に引いたことが効きました。
個人開発で staged rollout 判定を AI に任せる線引き
私の運用では、判定の自動化はここまでで止めています。実際のロールアウト操作(continue / pause / rollback の API コール)までは自動化していません。理由は、App Store Connect API と Google Play Console API のレート制限・トークン期限・rollout の挙動が言語ごとに微妙に違うことで、ここまで自動化するとデバッグコストが見合わなくなるからです。
「判断は AI、操作は人間」という線引きは、個人開発者にとっては合理的だと感じています。離れて暮らす子どもたちに何を残せるかと考えるたび、自分の判断履歴がきちんと残る運用設計が、長く続けられるソフトウェアの条件なのだろうと思います。
次に試したい拡張
現状の判定エンジンに加えて、3 つほど試したい拡張があります。
長コンテキストでの「過去 30 日の判定履歴」を渡す : 同じアプリで似たパターンが過去にあったかを Gemini Flash に判断させる。長コンテキストの実用例として面白そうです
Gemini Embedding でレビュー文の意味検索 : 「起動しない」と「立ち上がらない」を別キーワードとして検出している現状の正規表現を、意味検索に置き換えたい
Vertex AI の Batch Prediction で日次の総括レポート : 毎日 0 時に、その日の判定履歴を 1 つの長文レポートにまとめる
このあたりは、別記事で改めて検証して書きたいと思います。
実装の参考になれば幸いです。同じ運用課題に取り組んでいる方の判断軸を、ぜひ stand.fm のリスナーレターで教えてください。