0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React Native の致命エラーの「一言」を次の起動で送る:Apple の記録に RCTFatal しか残らないときの受け手の入れ方

0
Posted at

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 のどこで落ちたか分からない」で困っている方の参考になればうれしいです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?