リリース直後の本番で、Sentry に届いた最初のクラッシュが index.android.bundle:1:284913 という一行だけだったとき、私は静かに肩を落としました。再現手順も、どの画面で落ちたのかも、その数字からは何もわかりません。Rork が出してくれた React Native(Expo)アプリは Hermes で動いていて、本番のバンドルは1行に minify されています。つまり「クラッシュは取れているのに、中身が読めない」という、いちばん歯がゆい状態でした。
個人開発でアプリを長く運用していると、この「読めないスタックトレース」は必ず一度はぶつかる壁だと思います。Dolice で運用しているアプリ群でも、ネイティブ側は Crashlytics の dSYM で symbolicate していますが、Rork 由来の Expo アプリは仕組みが別物でした。鍵になるのは Hermes の ソースマップ と、それをビルドと結びつける debug ID です。ここを通せば、同じクラッシュ画面に関数名と元のファイルの行番号が戻ってきます。
なぜ Hermes だと数字しか届かないのか
Hermes は JavaScript をそのまま実行するのではなく、ビルド時に Hermes バイトコード(.hbc)へ事前コンパイルします。さらに本番ビルドでは Metro が全モジュールを1ファイルに連結し、変数名を短縮します。手元の PaywallScreen.tsx の 42 行目は、本番では1行に潰れた巨大なバンドルの「列 284913」になります。
クラッシュ時に端末から送られてくるのは、この潰れた後の位置情報だけです。元のファイル名・行番号へ戻す対応表が ソースマップ で、Hermes を使う場合は通常のソースマップに加えて Hermes 専用の合成ステップが必要になります。ここを省くと、ソースマップを上げているつもりでも symbolicate が効かない、という落とし穴にはまります。
debug ID が「同じビルドだ」と保証する
ソースマップを上げても symbolicate が崩れる原因のほとんどは、クラッシュを起こしたバンドルと、アップロードしたソースマップが別ビルドだった ことです。バージョン名(1.8.0 など)で紐づけようとすると、ホットフィックスを重ねた瞬間に取り違えが起きます。
これを根本的に解決するのが debug ID です。debug ID はビルドのたびに生成される一意の識別子で、バンドルとソースマップの両方に同じ値が焼き込まれます。Sentry はクラッシュに含まれる debug ID と、アップロード済みソースマップの debug ID を突き合わせるので、バージョン名のズレに依存しません。新しい @sentry/react-native はこの方式を標準にしています。私自身、バージョン照合からこの方式に切り替えてから、symbolicate の取りこぼしがほぼゼロになりました。
まず Sentry SDK を Expo に正しく組み込む
最初の一歩は SDK の導入と、Metro 設定を Sentry 経由でラップすることです。Metro をラップしないと debug ID がバンドルへ注入されないため、ここは必須です。
# Expo プロジェクトに Sentry を導入する
npx expo install @sentry/react-native
app.json(または app.config.js)に config plugin を追加します。organization と project は自分の Sentry の値に置き換えてください。
{
"expo" : {
"plugins" : [
[
"@sentry/react-native/expo" ,
{
"organization" : "your-sentry-org" ,
"project" : "your-rork-app"
}
]
]
}
}
metro.config.js を Sentry のヘルパーでラップします。これで本番バンドルへ debug ID が焼き込まれます。
// metro.config.js
// getSentryExpoConfig で包むと、ビルド時に debug ID が
// バンドルとソースマップへ自動で注入される(手動の付与は不要)
const { getSentryExpoConfig } = require ( '@sentry/react-native/metro' );
const config = getSentryExpoConfig (__dirname);
module . exports = config;
アプリのルートで Sentry.init を呼びます。dist と release は手で固定せず、SDK が EAS の値を拾うのに任せるのが安全です。
// App.tsx(エントリポイント)
import * as Sentry from '@sentry/react-native' ;
Sentry. init ({
dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' ,
// 本番のみ送る。開発ビルドのノイズで Issue が埋もれるのを防ぐ
enabled: ! __DEV__,
// 送信前にユーザー固有情報を落とすフックを必ず1枚かませる
beforeSend ( event ) {
if (event.user) delete event.user.email;
return event;
},
});
function App () {
// ...アプリ本体
return null ;
}
// Sentry.wrap でラップすると、起動直後の例外も取り逃さない
export default Sentry . wrap ( App ) ;
EAS Build にソースマップの自動アップロードを組み込む
native ビルド(eas build)では、config plugin が postBuild フックを差し込み、ソースマップを自動でアップロードします。動かす条件はただ一つ、認証トークンが環境変数として渡っていること です。
トークンはリポジトリに置かず、EAS のシークレットに登録します。
# Sentry の Auth Token を EAS シークレットに登録する
# トークンには project:releases と org:read のスコープを付ける
eas secret:create --scope project \
--name SENTRY_AUTH_TOKEN \
--value YOUR_SENTRY_AUTH_TOKEN \
--type string
eas.json の本番プロファイルで、このシークレットを参照させます。
{
"build" : {
"production" : {
"env" : {
"SENTRY_ALLOW_FAILURE" : "false"
},
"ios" : { "buildConfiguration" : "Release" },
"android" : { "buildType" : "app-bundle" }
}
}
}
SENTRY_ALLOW_FAILURE を false にしておくと、ソースマップのアップロードに失敗したビルドはそこで止まります。「リリースは通ったのにソースマップだけ上がっていなかった」という最悪の取りこぼしを防げるので、私はここを必ず明示します。
OTA(EAS Update)配信ぶんも取りこぼさない
ここが、Rork 製アプリで特にハマりやすい点です。eas build のソースマップは自動で上がりますが、eas update で配る OTA バンドルは別物 です。OTA で UI を差し替えた後にクラッシュが起きると、ストアビルド時のソースマップとは debug ID が一致せず、また数字だけのスタックに戻ります。
対策は、eas update の直後にその更新ぶんのソースマップを必ず上げることです。更新と同じコミットから生成された成果物を、同じ debug ID 付きでアップロードします。
# 1) 本番チャンネルへ OTA を配信
eas update --branch production --message "paywall fix"
# 2) 直後に、その更新ぶんのソースマップをアップロードする
# sentry-cli が debug ID を読み取り、対応するバンドルへ紐づける
npx sentry-cli sourcemaps upload \
--org your-sentry-org \
--project your-rork-app \
./dist
運用としては、この2コマンドを1つのスクリプトにまとめ、OTA を手作業で打たないようにするのが安全です。私自身、OTA のソースマップ忘れで半日分のクラッシュを読めなくした経験があり、それ以来「update とアップロードは必ずワンセット」を徹底しています。
本当に symbolicate されているか検証する
「上げたつもり」で終わらせないために、テスト用の例外をわざと投げて、Sentry 上で関数名まで戻るかを確認します。
// 検証用ボタン(リリース前に1度だけ踏んで、すぐ消す)
import * as Sentry from '@sentry/react-native' ;
function triggerTestCrash () {
// ネイティブ側ではなく JS 例外を投げる。
// Hermes のソースマップが効いていれば、この関数名が Sentry に出る
throw new Error ( 'Sourcemap verification - safe to ignore' );
}
Sentry の Issue で、triggerTestCrash という関数名と元の行番号が出ていれば成功です。もし数字のままなら、その Issue の debug ID が解決できていません。原因を機械的に切り分けるには、次のコマンドが効きます。
# あるイベントの debug ID が、どのソースマップと解決されたか/されなかったかを説明する
npx sentry-cli sourcemaps explain < event-i d >
explain は「該当する debug ID のソースマップが見つからない」「マップは見つかったが対象行を含まない」といった原因を具体的に教えてくれます。私はトラブル時、まずこれを叩いてから設定を疑うようにしています。当て推量で metro.config.js をいじり始めると、かえって時間を溶かします。
落とし穴と、私が決めている運用ルール
実運用でつまずいた点を、対処とあわせて挙げておきます。
第一に、metro.config.js を独自にカスタムしているプロジェクトで getSentryExpoConfig を通し忘れるケースです。Rork が出した初期コードに自分で手を入れていると起こりがちで、このときバンドルに debug ID が入らず、何を上げても symbolicate できません。Metro 設定は必ず Sentry のラッパー経由にします。
第二に、Android のネイティブクラッシュは別系統だという点です。本記事のソースマップは JS 例外(Hermes)向けで、Java/Kotlin 側で落ちた場合は R8 の mapping.txt を別途アップロードする必要があります。私は AdMob の SDK 更新後に native 側のクラッシュが出たことがあり、JS のソースマップを上げ直しても読めず、原因の切り分けに遠回りしました。「どちらのレイヤーで落ちたか」を最初に見極めるのが近道です。
第三に、リリースを重ねるうちにソースマップのアップロードが静かに失敗していても、SENTRY_ALLOW_FAILURE を true のままにしていると気づけません。本番プロファイルでは false を明示し、ビルドを止める側に倒すのが安全だと考えています。
運用ルールとしては、(1) Metro は必ず Sentry でラップする、(2) eas update とソースマップ上げは1スクリプトに束ねる、(3) リリース前に1回だけテスト例外で symbolicate を確認する、の3点を毎回のチェックに入れています。地味ですが、本番でクラッシュが届いた瞬間に「読める」状態であることは、個人開発で障害対応の速度を決める一番の差になります。
リリース前に踏む3手順
毎リリースで私が必ず通している順番を、再現できる形で残しておきます。
metro.config.js が getSentryExpoConfig を通っているかを確認する(ここが抜けると debug ID が入らず、後段がすべて無駄になります)。
テスト例外を1度だけ投げ、Sentry 上で関数名が戻ることを確認してから検証ボタンを削除する。
eas update を打ったら、同じスクリプト内で続けてソースマップを上げる(手作業の OTA は禁止にしています)。
この3手順を入れてから、本番でクラッシュが届いたときに中身を読み始めるまでの時間が、体感で数十分から5分以内に縮みました。あわせて段階公開では Crash-free users を 99.7% 以上で見張り、symbolicate できたスタックから原因のあたりを付けています。読めるスタックと監視のしきい値がそろって初めて、障害対応は「当て推量」から「観測」に変わります。
次の一歩として、まずは手元の Rork プロジェクトで metro.config.js が getSentryExpoConfig を通っているかだけ確認してみてください。多くの「読めないクラッシュ」は、ここが抜けている一点に集約されます。