minulo は、インスタや YouTube を DM や登録チャンネルを残したまま開き、リールやショートだけを隠す iPhone アプリです(隠すのは無料、Pro は有料)。この記事は minulo を1人で作っている北林が書いています。今回は、アプリが JS の致命エラーで落ちたときに、エラーの文を端末に一言だけ残し、次に開いたときに1件だけ送る仕組みを、実際のコード(src/crash-note.ts)から紹介します。
なぜ要ったか
Apple のクラッシュの記録では、React Native の JS で投げられたエラーは RCTFatal のところまでしか読めません。どの文で落ちたのかが分からないので、直しようがありませんでした。
そこで「落ちる直前に、エラーの文と最初の場所だけを書いておき、次の起動で送る」ことにしました。動きは変えず、記録だけ足します。
受け手は index.js の最初の import で入れる
import './src/crash-note-boot';
import 'expo-router/entry';
import は書いた順に評価されるので、こうしておけば expo-router や画面、データの層より先に受け手が入ります。app/_layout.tsx から入れると、store などを読み込んだ後でしか効かず、起動の途中で落ちた分を受けられません。
同じ理由で、crash-note.ts は store を import しません。store 自体が起動時に投げた場合も受けたいからです。
受け手は2か所に入れる
1つ目は、よく知られている ErrorUtils です。致命のときだけ書いて、元の受け手(RN の既定。アプリを止める)にそのまま渡します。
const prev = EU.getGlobalHandler();
EU.setGlobalHandler((e, fatal) => { if (fatal) note(e, 'global'); prev(e, fatal); });
これだけだと足りませんでした。描画中に起きた未処理のエラーは、React の onUncaughtError から ExceptionsManager.handleException が直接呼ばれ、ErrorUtils を通りません。なのでこちらも包みます。
const EM = require('react-native/Libraries/Core/ExceptionsManager').default as { handleException(e: unknown, fatal: boolean): void };
const orig = EM.handleException;
EM.handleException = (e: unknown, fatal: boolean) => {
if (fatal) {
note(e, 'render');
描画中の致命エラーは PostHog の自動記録にも乗らないので、PostHog が起きていればここから captureException を1件送るようにしています(src/posthog.ts が globalThis.__minuloFatal に口を差しておく形)。
同じ落ち方を2つの道で受けても1回だけ書くよう、note() の頭で印を見ています。
let wrote = false;
function note(e: unknown, via: string) {
if (wrote) return; // 同じ落ち方を2つの道で受けても1回だけ
wrote = true;
via に global か render が入るので、どちらの道で落ちたかも後で分かります。
書き先は SQLite ではなく App Group の UserDefaults
アプリの記録は expo-sqlite に置いていますが、落ちた原因が SQLite のこともあります。そのとき同じ所に書こうとしても書けません。なので書き先は App Group の UserDefaults にしました。直後にプロセスが止まっても残ります。
UserDefaults には、Screen Time 用にすでに入れている react-native-device-activity の userDefaultsSet を使っています。新しいネイティブモジュールを足さずに済みました。require は try で包み、読めない環境でも落ちないようにしています。
function da(): DA | null {
try { return require('react-native-device-activity') as DA; } catch { return null; }
}
送るのは文と最初の場所と OS の版だけ
エラーの文には URL やメールアドレスが混ざることがあります。minulo はアプリの中で SNS のウェブ版を開くので、ページの URL や @ で始まる名前が入る場面もあり得ます。送る前に伏せて、160字で切ります。
const clean = (s: string) => unwrap(s).replace(/\b[a-z][\w+.-]*:\/\/\S+/gi, '<url>').replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, '<email>').replace(/@[\w.]{2,}/g, '@<name>').replace(/\s+/g, ' ').slice(0, 160);
http 以外のスキーム(アプリ自身の feedminus:// など)も伏せるため、https?:// ではなく [a-z][\w+.-]*:// で拾っています。メールを先に置き換えているのは、先に @名前 を伏せるとメールの後ろ半分だけが残るからです。
場所はスタックの中から「:数字」を含む最初の行を取り、端末の中のフォルダ名を落として「ファイル名:行:列」だけにします。
const place = (s: string) => clean(s.replace(/\/[^\s()]*\/([^/\s()]+:\d+:\d+)/, '$1'));
160字が前置きで埋まっていた
10/27 に落ちた記録を読んでいて気づいたのが、Expo のネイティブ関数が失敗したときの決まった前置きです。FunctionCallException: Calling the 'X' function has failed (at ...) → Caused by: が160字の大半を使い、肝心の原因(このときは SQLite の例外の中身)が切れていました。いまは前置きを「関数名 → 」に縮めてから切っています。
const unwrap = (s: string) => s.replace(/FunctionCallException: Calling the '([\w.]+)' function has failed \(at [^)]*\)\s*→\s*Caused by:\s*/g, '$1 → ');
字数で切るときは、先頭に何が並ぶかを一度実物で見ておいた方がいいです。
次の起動で1件だけ送る
読んだらすぐ消すので、同じ記録を2回送ることはありません。
const raw = d?.userDefaultsGet<string>(KEY);
if (!raw) return null;
d?.userDefaultsRemove(KEY);
app/_layout.tsx の起動処理で takeCrashNote() を呼び、記録があれば app_crashed というイベントで送ります。画面のエラー境界(Boundary)で受け止めたエラーも、同じ describeError() を通して同じ形で送っています。
守っていること
- 受け手は動きを変えない。書けても書けなくても、元の受け手に渡して落ちる。
- 受け手を入れられなかったときも起動は続ける(全部 try/catch で握る)。
- 送るのは、伏せた文・ファイル名と行・OS の版・どちらの道かだけ。本人の入力やページの中身は載せない。
プライバシーポリシーにも、落ちたときに送る中身を書いています。
まとめ
-
ErrorUtilsだけでは描画中の致命エラーを取りこぼすので、ExceptionsManager.handleExceptionも包む。 - 受け手は index.js の最初の import で入れ、store や DB に頼らない。
- 書き先は落ちる原因と別の所(App Group の UserDefaults)にする。
- 字数で切る前に、決まった前置きを縮める。
同じように「Apple の記録では JS のどこで落ちたか分からない」で困っている方の参考になればうれしいです。