秋の正式リリースが降ってきた翌朝、Crashlytics のダッシュボードを開いて手が止まりました。
前日まで静かだったクラッシュ一覧に、見覚えのないスタックトレースが4種類。しかも新しく出したビルドではなく、2週間前から配信していたバージョンで起きています。
原因を追う手掛かりは、本来なら夏のあいだに溜まっていたはずでした。私自身がその手掛かりを、自分の手で捨てていたのです。
ベータ端末を「除外」した理由は、当時は筋が通っていました
個人開発で6本のアプリを並行して面倒を見ていると、ダッシュボードを開ける時間は1日に数分しかありません。
そこにノイズが混ざると、判断が鈍ります。OS のパブリックベータを入れている端末は、母数こそ小さいのにクラッシュ率だけが目立って高くなります。全体のクラッシュフリー率がわずかに下がり、しきい値を割ってアラートが飛びます。開いてみればベータ端末だけの問題で、こちらのビルドは無傷でした。
この空振りが月に何度か続いたあと、私は計測とクラッシュレポートの初期化時にプレリリース端末を弾く分岐を入れました。数字は静かになりました。
その静けさが落とし穴でした。
除外は「見えない」を作るだけで、「起きない」は作りません
パブリックベータの利用者は、秋の正式リリース当日に消えるわけではありません。むしろ逆で、その日を境に一般ユーザーが同じメジャーバージョンへ一斉に上がってきます。
つまり夏のあいだにベータ端末で観測できていたはずの不具合は、秋になって初めて、全ユーザー規模で顕在化します。事前に数十件の手掛かりとして受け取れたものを、私は数千件の障害として受け取り直したことになります。
もうひとつ、あとから効いてきた事実があります。ベータ端末で起きていたクラッシュのうち、OS 側の不具合と言えるものは半分もありませんでした。多くは、こちらのコードがこれまで偶然通っていた箇所です。
破棄済みのビューへ非同期完了後に触っておりました。日付の書式解析が、特定ロケールの実装差に依存していました。オプショナルの強制アンラップが、以前は必ず値の入るパスに置かれていました。
OS が変わって初めて表に出ただけで、瑕疵はこちら側にありました。「ベータ由来だから様子見」という札を貼った瞬間に、自分のバグを一件ずつ見送っていたわけです。
iOS にはベータ判定の公開 API がない、という前提から始めます
作り直すにあたって最初にぶつかったのが、そもそも「この端末はプレリリース版か」を確実に知る手段がない点でした。
React Native の Platform.Version から取れるのは、iOS では "27.0" のようなバージョン文字列、Android では 36 のような API レベルの数値です。同じプロパティなのに型が違うところが最初のつまずきで、parseInt をかけずに比較していると、Android 側だけが気づかれないまま壊れます。
iOS のベータビルドはビルド番号の末尾に小文字が付く慣習が知られていますが、そのビルド番号自体を標準の公開 API から安定して取る方法がありません。ビルド番号に依存した判定は、取得手段ごと将来壊れる可能性を抱えます。
そこで判定を推定に切り替えました。「自分たちが知っている最新の正式リリース版メジャーバージョン」を基準値として持ち、それより大きいメジャーなら prerelease と見なします。推定ですので外れることがあります。外れ方を制御できる形にしておくことが、実装上の主題になりました。
// src/telemetry/osTrack.ts
import { Platform } from "react-native";
export type OsTrack = "ga" | "prerelease" | "unknown";
export type GaBaseline = { ios: number; android: number; updatedAt: string };
/**
* 端末 OS のメジャーバージョンを数値で返します。
* iOS : Platform.Version は "27.0" のような文字列
* Android: Platform.Version は 36 のような数値(API レベル)
* 同じプロパティで型が異なるため、必ず正規化してから比較します。
*/
export function parseOsMajor(): number | null {
const raw = Platform.Version;
if (typeof raw === "number") return raw;
const major = Number.parseInt(String(raw), 10);
return Number.isFinite(major) ? major : null;
}
export function resolveOsTrack(baseline: GaBaseline | null): OsTrack {
const major = parseOsMajor();
// 判定材料が欠けたときは prerelease に倒しません。
// 倒すと基準値の更新が遅れた瞬間に GA トラックが空になります。
if (major === null || baseline === null) return "unknown";
const knownGa = Platform.OS === "ios" ? baseline.ios : baseline.android;
return major > knownGa ? "prerelease" : "ga";
}
判定不能を unknown として独立させた点が、この関数でいちばん時間をかけたところです。真偽値の2値にすると、迷ったときの行き先がどちらかに寄ります。寄せた先が prerelease だと正式版の指標が痩せ、ga だとベータの異常が本番の数字に混ざります。3値目を置くほうが、あとから困りません。
基準値はリモートに置き、取れなければ前の値へ戻します
基準値をアプリに焼き込むと、秋の正式リリース当日にアップデートを出すまで、全ユーザーが prerelease 扱いのままになります。ストア審査を挟むと数日ずれます。その数日は、いちばん数字を見たい期間そのものです。
小さな JSON をひとつ配信し、起動時に取りに行く形にしました。取れなければキャッシュ、キャッシュもなければビルド同梱値へ落ちます。
// src/telemetry/gaBaseline.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { GaBaseline } from "./osTrack";
const CACHE_KEY = "telemetry.gaBaseline.v1";
const ENDPOINT = "https://config.example.com/ga-baseline.json";
const TIMEOUT_MS = 2500;
// ビルド時点で判明している正式リリース版のメジャー。
// リモートも キャッシュも駄目なときの最終地点です。
const BUNDLED: GaBaseline = { ios: 26, android: 36, updatedAt: "2026-07-01" };
function isValid(v: unknown): v is GaBaseline {
const b = v as Partial<GaBaseline> | null;
return (
!!b &&
typeof b.ios === "number" &&
typeof b.android === "number" &&
b.ios > 0 &&
b.android > 0
);
}
export async function loadGaBaseline(): Promise<GaBaseline> {
let fallback: GaBaseline = BUNDLED;
try {
const cached = await AsyncStorage.getItem(CACHE_KEY);
const parsed = cached ? JSON.parse(cached) : null;
if (isValid(parsed)) fallback = parsed;
} catch {
// キャッシュが壊れていても起動は止めません
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const res = await fetch(ENDPOINT, { signal: controller.signal });
if (!res.ok) throw new Error(`ga-baseline http ${res.status}`);
const next: unknown = await res.json();
if (!isValid(next)) throw new Error("ga-baseline shape mismatch");
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(next));
return next;
} catch {
return fallback;
} finally {
clearTimeout(timer);
}
}
タイムアウトを 2,500 ミリ秒に置いたのは、起動計測を汚さない範囲に収めたかったからです。この処理を起動のクリティカルパスに置くと、それ自体が起動時間の悪化として跳ね返ります。私は初期化を待たずに unknown で走らせ、解決後に属性を上書きする順序にしました。この辺りの兼ね合いはリリースを重ねるうちに起動が重くなっていたとき — TTI計測と起動バジェットのメモで扱った起動バジェットの考え方と同じ土俵にあります。
updatedAt を持たせているのは飾りではありません。基準値の更新忘れは、実際にいちばん起きやすい事故です。この値が90日以上古ければ運用側に警告を出すようにしています。
除外をやめ、すべてのイベントに os_track を載せます
作り直しの本体は拍子抜けするほど短いコードでした。落とすのをやめ、次元をひとつ足しただけです。
// src/telemetry/index.ts
import crashlytics from "@react-native-firebase/crashlytics";
import analytics from "@react-native-firebase/analytics";
import { loadGaBaseline } from "./gaBaseline";
import { parseOsMajor, resolveOsTrack, type OsTrack } from "./osTrack";
let currentTrack: OsTrack = "unknown";
export function getOsTrack(): OsTrack {
return currentTrack;
}
export async function initTelemetry(): Promise<void> {
// 解決前のイベントも捨てません。unknown のまま先に送り始めます。
await analytics().setUserProperty("os_track", currentTrack);
const baseline = await loadGaBaseline();
currentTrack = resolveOsTrack(baseline);
const staleDays =
(Date.now() - new Date(baseline.updatedAt).getTime()) / 86_400_000;
await crashlytics().setAttributes({
os_track: currentTrack,
os_major: String(parseOsMajor() ?? "unknown"),
ga_baseline: `${baseline.ios}/${baseline.android}`,
baseline_stale_days: String(Math.floor(staleDays)),
});
await analytics().setUserProperty("os_track", currentTrack);
}
baseline_stale_days をクラッシュ属性に入れておくと、あとで「この端末はどの基準値で判定されたのか」を事後に追えます。判定ロジックを疑うべき局面で、疑うための材料が残ります。
Analytics 側をユーザープロパティにしたのは、既存レポートを壊さずに済むからです。イベントパラメータとして足すと、過去に定義したファネルやオーディエンスの側を作り直す必要が出ます。ユーザープロパティなら、既存のレポートに絞り込みを一段重ねるだけで分離できます。
閾値とアラート先をトラックごとに分けます
ここまでで数字は分離されました。残るのは、分離した数字にどう反応するかです。
prerelease の閾値を 98.0% と緩めたのは、諦めたからではありません。母数が小さいときに 99.5% を当てると、1件のクラッシュで閾値を割ります。割った通知が続くと人は通知を無視するようになり、無視される監視は無いのと同じになります。
unknown に閾値を置かず比率だけを見るのは、ここが増えること自体が「判定が壊れている」という別の信号だからです。私の運用では 5% を目安にしています。ネットワークが不安定な地域の比率や、updatedAt の古さと合わせて見ると、原因の切り分けが早くなります。
リリースゲートでは、止める条件と直す条件を分けます
段階公開を次の段へ進めるかどうかの判定にも、同じ分離を持ち込みました。prerelease の数字で公開を止めることはしません。ただし、黙って通り過ぎることもさせません。
// scripts/releaseGate.ts
export type TrackStats = {
track: "ga" | "prerelease" | "unknown";
sessions: number;
crashFreeRate: number; // 0.0 〜 1.0
};
export type GateResult = {
pass: boolean;
blockers: string[];
warnings: string[];
};
const GA_MIN_SESSIONS = 500;
const GA_MIN_CRASH_FREE = 0.995;
const PRE_MIN_SESSIONS = 50;
const PRE_MIN_CRASH_FREE = 0.98;
const UNKNOWN_MAX_SHARE = 0.05;
const pct = (v: number) => `${(v * 100).toFixed(2)}%`;
export function evaluateGate(stats: TrackStats[]): GateResult {
const blockers: string[] = [];
const warnings: string[] = [];
const byTrack = (t: TrackStats["track"]) => stats.find((s) => s.track === t);
const total = stats.reduce((sum, s) => sum + s.sessions, 0);
const ga = byTrack("ga");
if (!ga || ga.sessions < GA_MIN_SESSIONS) {
blockers.push(
`GA トラックのセッションが ${ga?.sessions ?? 0} 件で、判断材料が足りません(${GA_MIN_SESSIONS} 件以上が必要)`
);
} else if (ga.crashFreeRate < GA_MIN_CRASH_FREE) {
blockers.push(
`GA クラッシュフリー率 ${pct(ga.crashFreeRate)} が閾値 ${pct(GA_MIN_CRASH_FREE)} を下回っています`
);
}
// プレリリースは公開を止める条件にしませんが、是正キューには必ず積みます
const pre = byTrack("prerelease");
if (pre && pre.sessions >= PRE_MIN_SESSIONS && pre.crashFreeRate < PRE_MIN_CRASH_FREE) {
warnings.push(
`prerelease クラッシュフリー率 ${pct(pre.crashFreeRate)} — 正式リリース前に是正対象へ`
);
}
const unknown = byTrack("unknown");
if (unknown && total > 0 && unknown.sessions / total > UNKNOWN_MAX_SHARE) {
warnings.push(
`unknown が全体の ${pct(unknown.sessions / total)} を占めています — 基準値の配信を確認してください`
);
}
return { pass: blockers.length === 0, blockers, warnings };
}
warnings を戻り値に含めながら pass の判定からは外している点が、この関数の設計意図そのものです。止める条件と直す条件を同じ配列に入れると、運用者は必ずどちらかに寄せます。多くの場合、緩いほうへ寄ります。
警告を消化する先も決めておく必要があります。私は次のビルドのマイルストーンに直接積む運用にしました。行き先の無い警告は、3回目までに読まれなくなります。
基準値の配信そのものを疑います
この設計をひと巡り運用したあと、自分の実装をもう一度読み返しました。分離は効いていました。ただ、分離の土台になっている基準値の JSON を、私はほとんど疑っていなかったのです。
判定ロジックだけを切り出して、配信が壊れたときに何が起きるかを測りました。手元の Node v22.23.2 で、iOS のメジャーが 24 から 27 まで分布する 10,000 端末ぶんの入力を作り、基準日を配信当日に固定して分類させています——母集団は当日の上がり方を模した想定値で、実利用者の分布ではありません。
B が起きるのは、isValid が ios と android の型しか見ておらず、updatedAt を検証していないからです。日付ごと欠けた JSON をそのまま採用し、new Date(undefined) から NaN が生まれ、baseline_stale_days には文字列の "NaN" が入ります。私がこの記事に書いた 90 日の警告は、更新を忘れた F には効く一方で、いちばん危ない「日付ごと欠けた配信」には反応しません。監視のために置いた属性が、監視の空白をいちばん上手に隠していました。
D は、この記事を書いた動機そのものが戻ってきた形です。prerelease 比率が 0.00%。除外の分岐は消したのに、基準値の数字ひとつで同じ盲点が再現します。しかも unknown は 0.00% のままですので、前節のゲートに入れた unknown 比率の監視も何も言いません。
C は逆向きで、GA 比率が 19.00% まで痩せます。本来の正式版利用者の8割が、prerelease 用に緩めておいた 98.0% の閾値で見られることになります。
ゲートに当ててみたら、片方は黙っていました
上の C と D を、前節の evaluateGate にそのまま食わせました。
D は止まります。ただ、止まる理由が違います。ベータ端末のクラッシュが GA へ合流して 99.71% が 99.19% まで下がり、当直に立つ人は存在しない GA の退行を探すことになります。原因は基準値の JSON にあるのに、画面のどこにもそう書かれていません。
C はひと言も出ません。GA セッションは 1,900 件で下限の 500 件を超え、率は 99.85% で閾値を上回り、prerelease 側の 99.25% も緩い 98.0% を上回ります。数字だけを見れば健康です。健康に見えるのは、8割のユーザーが間違った札を貼られているからでした。
基準値は、取れたかどうかではなく、前回からどう動いたかで受け入れます。 正式リリースに伴うメジャーの前進は、1サイクルにつき1つです。この当たり前の性質を検証に書き下すだけで、巻き戻しと桁違いの両方が入り口で止まります。
// src/telemetry/gaBaseline.ts(検証部分の差し替え)
export type BaselineLoad = {
baseline: GaBaseline;
rejected: "巻き戻し" | "飛躍" | "形式不正" | null;
};
function isValid(v: unknown): v is GaBaseline {
const b = v as Partial<GaBaseline> | null;
if (!b || typeof b.ios !== "number" || typeof b.android !== "number") return false;
if (b.ios <= 0 || b.android <= 0) return false;
// updatedAt を検証に含めます。ここを見ていないと staleDays が NaN になります
if (typeof b.updatedAt !== "string") return false;
return Number.isFinite(Date.parse(b.updatedAt));
}
function accept(next: GaBaseline, prev: GaBaseline): BaselineLoad["rejected"] {
if (next.ios < prev.ios || next.android < prev.android) return "巻き戻し";
// 正式リリースの前進は 1 メジャーずつです。2 以上は配信側の事故と見なします
if (next.ios > prev.ios + 1 || next.android > prev.android + 1) return "飛躍";
return null;
}
export function reconcile(remote: unknown, prev: GaBaseline): BaselineLoad {
if (!isValid(remote)) return { baseline: prev, rejected: "形式不正" };
const rejected = accept(remote, prev);
return rejected ? { baseline: prev, rejected } : { baseline: remote, rejected: null };
}
export function staleDays(baseline: GaBaseline): number | null {
const t = Date.parse(baseline.updatedAt);
return Number.isFinite(t) ? Math.floor((Date.now() - t) / 86_400_000) : null;
}
同じ 10,000 端末ぶんの入力を、この検証を通してから流し直しました。B・C・D はいずれも却下され、前回値の ios=26 が保たれ、prerelease 比率は 20.00%、baseline_stale_days は 75 と数えられます。A の正当な前進は却下されず、そのまま 27 を採用します。
却下は黙って行いません。rejected をクラッシュ属性とゲートの両方へ運びます。
// scripts/releaseGate.ts(追加分)
const GA_MIN_SHARE = 0.5;
export type GateContext = {
baselineRejected: string | null;
betaWindowOpen: boolean; // 次のメジャーのベータが配布されている期間か
};
export function evaluateTrackSanity(stats: TrackStats[], ctx: GateContext): string[] {
const warnings: string[] = [];
const total = stats.reduce((sum, s) => sum + s.sessions, 0);
const ga = stats.find((s) => s.track === "ga");
const pre = stats.find((s) => s.track === "prerelease");
if (ctx.baselineRejected) {
warnings.push(`基準値の配信を却下しました(${ctx.baselineRejected})— 前回値で判定しています`);
}
if (total > 0 && ga && ga.sessions / total < GA_MIN_SHARE) {
warnings.push(`GA トラックが全体の ${pct(ga.sessions / total)} しかありません — 基準値が進みすぎた疑い`);
}
if (ctx.betaWindowOpen && (!pre || pre.sessions === 0)) {
warnings.push("ベータ期間中に prerelease が 0 件です — 分離が効いていない疑い");
}
return warnings;
}
この3行を足した状態で、先ほど黙っていた C は GA トラックが全体の 19.00% しかありません を出し、却下が働いた D は 基準値の配信を却下しました(飛躍) を出しました。ベータ期間中に prerelease が 0 件という条件は、万一却下をすり抜けたときの最後の網です。
ここで迷ったのは、却下を厳しくしすぎると配信当日の正当な差し替えまで弾いてしまうのではないか、という点でした。折り合いとして、却下は「無視」ではなく「前回値のままで通知つきの保留」にしています。判定は動かさず、人には必ず届く。いま思えば、疑うべき相手は判定ロジックではなく、そこへ流れ込んでくる値だったのかもしれません。
運用してみて、予想と逆だったこと
3つあります。
1つ目。プレリリース端末で最初に噴き出したのは、OS の新機能に関わるコードではなく、何年も触っていない古い画面でした。新機能は身構えて書くぶん、想定の外へ出にくいのです。危ないのは、動いていることを疑わなくなったコードのほうでした。
2つ目。ノイズは、分離したら消えました。除外していた頃に感じていた煩わしさの正体は、ベータ端末の存在そのものではなく、「同じ通知先に混ざって届くこと」でした。宛先を分けただけで、同じ件数の情報が判断材料に変わりました。除外という手段は、実は問題の形と噛み合っていなかったのです。
3つ目。基準値の更新は、思っていたより忘れます。1回目の正式リリース当日、私は基準値の JSON を差し替えるのを半日ほど失念していました。その間、正式版へ上げたユーザーが prerelease として集計され続けます。unknown へのフォールバックと baseline_stale_days の記録を入れておいたおかげで、集計を後から補正できました。仕組みの側で人の忘却を吸収しておく判断は、入れておいて良かったと感じています。
どのビルドでいつ入った変更なのかを追う話は、どの生成でこの不具合が入ったのか — Rork アプリに生成来歴を刻む長期運用設計で扱った来歴スタンプと組み合わせると、切り分けがさらに短くなります。os_track と生成来歴の両方をクラッシュ属性に持たせておくと、「どの OS 世代で、どの生成のコードが壊れているか」を1回の絞り込みで出せます。
配信の当日までに、そして当日に
iOS 27 と iPadOS 27 は9月14日に配信されます。App Store にアプリを出している立場としては、次の順で手を入れておくと当日が落ち着きます。
os_track の判定と付与を実装し、まず unknown を含む3値がクラッシュ属性へ届いていることを実機で確かめます
- 基準値の JSON を配信し、値を1つ上げたときに端末側の判定が変わることを試します(更新経路の検証はここでしかできません)
- 却下条件——単調非減少・1メジャーまで・
updatedAt の検証——を入れ、巻き戻しと桁違いを配信側でわざと起こして弾かれることを確かめます
- アラートの宛先を ga と prerelease で分け、prerelease は日次サマリに落とします
- 既存の段階公開チェックへ
evaluateGate 相当の判定を差し込み、blockers と warnings を別々に表示します
- 配信当日は、基準値 JSON の差し替えと、差し替えたあとの GA 比率の確認までを1つのタスクにまとめます
除外の分岐を消すだけなら、作業は1時間もかかりません。時間がかかるのは、分離したあとの数字にどう反応するかを決める部分です。そこを決めずに次元だけ足すと、見えるノイズが増えただけで終わります。
最初の一手としては、手元の基準値 JSON から updatedAt をわざと1回消して、baseline_stale_days に何が入るかを確かめるところから始めていただければと思います。私は、そこで自分の監視の空白を見つけました。お読みいただきありがとうございました。