↓ メインコンテンツへスキップ

React Native Expo Sentry

SDKはSentry、サーバーは自前で:Djangoコードおよそ650行で作るReact Nativeアプリのクラッシュレポート

うちのアプリには、クラッシュを報告する仕組みが何もありませんでした。sentry.ioに登録する代わりに、Sentryのオープンソース SDK はそのまま使い、DSNを自前のDjango APIのエンドポイントに向けました。エンベロープ形式、ソースマップなしでの難読化されたクラッシュのグルーピング、Discordを埋め尽くさないアラート、データの除去、iOSのネイティブなSIGABRTが本当に届くことの確認方法、そして最後にプライバシーポリシーを書き直すことになった理由をまとめます。

今週まで、うちのアプリがクラッシュしたことを知る手段は、誰かが教えてくれることだけでした。ほとんどの人は教えてくれません。アプリを開き直し、また落ちたら、もう開かなくなるだけです。API側は把握できていました(ERRORログはすべてDiscordのチャンネルに流れます)。でも、スマートフォン上でのクラッシュは、私に見える場所には何の痕跡も残していませんでした。

実は、アプリにはすでに報告の仕組みがひとつありました。アプリが誰かの前にエラーメッセージを表示すると、そのメッセージを /client-errors/ に送り、サーバーがERRORでログに残すので、これもDiscordに届きます。きっかけは、削除が 成功していた のに行がリストに残り、もう一度タップすると「No Item matches the given query」と表示された件でした。サーバーはごく普通の404を2回記録しただけで、何も言いませんでした。この仕組みでカバーできるのは、アプリが捕まえて表示したエラーです。クラッシュは事情が違います。報告を送るはずのJavaScriptそのものが、たった今死んだのですから。

素直な解決策はSentryです。SentryのSDKはとても気に入っていて、React Nativeのクラッシュレポートが「解決済みの問題」になっているのはこのSDKのおかげです。ただ、ユーザーデータを預ける業者がまたひとつ増え、プライバシーポリシーの行がまたひとつ増え、うちの規模なら多くても1日数件のレポートのためにまた請求書が増えるのは避けたいところでした。そこでSDKはそのままに、サービスの方を置き換えました。アプリのDSNは自前のAPIを指していて、Djangoのおよそ650行が、送られてきたものを受け取り、グルーピングし、アラートを出し、保存します。

この記事では、その仕組みと、本物のネイティブクラッシュが実際に届くことをどう確かめたかを紹介します。

DSNのからくり
#

Sentry の DSN は https://<public-key>@<host>/<project-id> という形をしています。SDKはこれを https://<host>/api/<project-id>/envelope/ へのPOSTに変換し、キーは X-Sentry-Auth ヘッダーに入れます。sentry.io固有の要素はどこにもありません。なので、アプリに埋め込むDSNはこうなります。

https://<CRASH_REPORT_KEY>@api.example.com/1

そしてDjango側には、SDKが呼び出すパスに一致するルートを用意します。

path("api/<int:project_id>/envelope/", crash_envelope, name="crash_envelope"),

キーはすべてのバイナリに含まれるので、秘密ではありません。キーがある理由は二つで、エンドポイントにゴミが集まり始めたら変更できるようにするため、そしてサーバー側でキーが未設定なら、何でも受け入れるのではなくすべて拒否するためです。

def crash_envelope(request, project_id):
    expected = settings.CRASH_REPORT_KEY
    if not expected:
        return Response({"detail": "Crash reporting is not configured"}, status=503)
    if not hmac.compare_digest(_key(request).encode(), expected.encode()):
        return Response({"detail": "Unknown key"}, status=401)
    ...

_key は X-Sentry-Auth ヘッダーから sentry_key= を読み取り、なければクエリ文字列を見ます。SDKによってはそちらを使うためです。

SDKが送ってくるもの:エンベロープ
#

Sentryの通信フォーマットはエンベロープです。JSONのヘッダー行が1行あり、その後に任意の数のアイテムが続きます。各アイテムはJSONのヘッダー行とペイロードの組です。アイテムヘッダーに length があればペイロードはちょうどそのバイト数、なければ次の改行までです。

{"event_id":"9ec79c33...","dsn":"https://[email protected]/1"}
{"type":"event","length":4123}
{ ...the event JSON, 4123 bytes... }
{"type":"client_report"}
{"timestamp":1791400000.5,"discarded_events":[]}

引っかかった点が二つあります。

  • ネイティブSDKは本文をgzipしますが、JavaScriptのSDKはしません。 @sentry/react-native が捕捉したJSエラーはプレーンテキストで届き、sentry-cocoaやsentry-androidからのネイティブクラッシュは Content-Encoding: gzip 付きで届きます。デコーダーはgzip、zlibでラップされたdeflate、生のdeflateに対応し、展開は1MBで打ち切ります。クラッシュイベントは数KBなので、それより大きいものはクラッシュイベントではありません。
  • 長さ指定のペイロードは改行を含むことがあります。 \n で分割すると、ネイティブSDKが送るイベントがすべて壊れます。length があるときは必ずそれに従う必要があります。

パーサーは40行ほどです。残すのは event アイテムだけで、セッション、トランザクション、添付ファイル、クライアントレポートは受け取ったうえで捨てます。

SDKが理解できる形で応答する
#

これは思っていたより大事でした。SDKはディスク上にキューを持ち、届いたか確信が持てないものはすべて再送します。なので、サーバーの応答次第で、SDKが送り続けるかどうかが決まります。

  • 200:保存できたとき、 または意図的に捨てたとき 。セッションアイテムに4xxを返すと、SDKはそれを保持したまま永遠に再送します。
  • 429と Retry-After :スロットリングしたとき。エンドポイントにはIPごとの制限(1時間に120件)があります。クラッシュループに入ったスマートフォンは起動のたびに1件送ってくるからです。SDKは429を受けると、期待どおりに間隔を空けてくれます。
  • 400:壊れたエンベロープ。ログはエラーではなく警告にしています。壊れたレポートが教えてくれるのは「どこかのクライアントがおかしい」ということで、次のまともなレポートが何がクラッシュしたかを教えてくれます。

SDKは最初の送信が届いたか確信が持てないと同じイベントを再送するので、event_id にはユニーク制約を付けてあり、重複は何も言わずに無視されます。

ソースマップなしでのグルーピング
#

ここは、自作の受信側がSentryなら代わりにやってくれる判断を自分でしなければならない部分です。Sentryはスタックトレースでクラッシュをグルーピングします。でも、リリース版のHermesバンドルは難読化されているので、フレーム名は1文字になり、行や列の位置はビルドのたびに変わります。フレームでグルーピングすると、同じバグがリリースのたびに新しいissueになってしまいます。

ソースマップがない状態(最後に詳しく書きます)では、JavaScriptのエラーで一番安定しているのは型とメッセージです。そこでフィンガープリントは、例外の型とメッセージを組み合わせ、発生ごとに変わる部分をマスクしたものにしました。

_MASKS = [
    (re.compile(r"0x[0-9a-fA-F]+"), "<addr>"),
    (re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"), "<uuid>"),
    (re.compile(r"\b[0-9a-fA-F]{16,}\b"), "<hex>"),
    (re.compile(r"\d+"), "<n>"),
]

Cannot read property 'title' of undefined (reading index 3) と (reading index 17) は同じissueになります。SDKが {{ default }} を含まない独自の fingerprint を送ってきたらそちらを優先するので、デフォルトのグルーピングが合わないときはクライアント側から強制できます。例外を持たないイベントはマスクしたログメッセージで、どちらもないイベントはアプリ内の一番上のフレームでグルーピングします。ネイティブのシグナルクラッシュではメッセージが Signal 6, Code 0 のようになるので、シグナル単位でまとまります。粗いですが、シンボリケーションができるまでは正直なやり方です。

チャンネルを埋め尽くさないアラート
#

フィンガープリントごとに CrashIssue の行があり、各レポートはその下の CrashEvent になります。issueがERRORでログを出す(Discordのハンドラーが待ち受けているのはこれです)のは、次の場合だけです。

  • 新しい issue
  • 解決済み のissueが 再発 したとき(issueは再オープンされます)
  • 10回目、100回目、1,000回目、10,000回目 の発生

通常の繰り返しはカウンターを増やすだけです。 無視 にしたissueはカウントは続けますが、アラートは一切出しません。その上にDiscordハンドラー自体のレート制限(同じメッセージの繰り返しは10分に1回、アラートは1時間に30件まで)があるので、ダメなリリースでクラッシュループが起きても、届くのは壁のような大量のメッセージではなく、数件で済みます。

保存量も同じように上限を設けています。各issueは 直近50件 のイベントだけを残し、新しいものが来るたびに残りを削除します。これで管理画面のグラフの最初のバージョンが壊れました。50件のイベントでは、先週どれくらいの頻度で起きていたかは分からないからです。そこでissueの行に小さな daily_counts の辞書 {"2026-10-09": 3, ...} も持たせ、90日分に切り詰めたうえで、カウンターを増やすのと同じ行ロックの中で更新するようにしました。グラフはこれを元に描き、イベントは読むためだけのものです。

小さな競合状態も二つ対処が必要でした。まったく新しいクラッシュが同時に何台ものスマートフォンで起きると、二つのレポートが両方ともissueを作ろうとすることがあります。負けた方はユニークなフィンガープリントで IntegrityError になり、select_for_update で行を読み直します。また、90日の範囲は辞書の一番新しいキーからではなく、サーバーの今日から数えます。時計がずれたスマートフォンは、来年の日付も含めてどんな日付のレポートでも送ってくる可能性があるからです。

保存するペイロードは128KBが上限です。超えた場合はまずパンくずリストと debug_meta を削り、それでも超えるなら例外、メッセージ、リリース、プラットフォームだけを残し、削ったことが分かるように _truncated フラグを付けます。

データの除去
#

SDKは sendDefaultPii: false で動いているので、原理的にはIPアドレスもCookieもリクエストボディも送られません。それでもサーバーはこれに頼りません。保存する前に user からは id と username だけを残し、request は丸ごと捨てます。将来のSDKのバージョンや設定でこれらが送られ始めたときのためです。

実機でテストしたところ、除去すべきものがさらに二つ見つかりました。

  • パンくずリストはクエリ文字列も含めたURL全体を記録します。 アプリの検索は /api/v1/items/?search=... のようなリクエストなので、検索直後にクラッシュすると、その人が何を検索したかまで保存されていたことになります。今はサーバーがパンくずリストのURLをすべて ? で切っています。
  • コンソールのパンくずリストには console.log に渡された生の引数が入っています。 アプリがたまたまログに出した任意のオブジェクトが入りうるということです。メッセージ本文は役に立つので残し、引数は捨てます。

どちらも実際のレポートを見て初めて気づいたもので、これが次の節の一番の動機です。

クライアント側
#

アプリでは、すべてが services/crashReporting.ts という1ファイルを通ります。OTAのSDKを1ファイル経由でしか呼ばないのと同じ理由で、OTAバンドルはネイティブモジュールが存在する前にビルドされたバイナリの上でも動くからです。RNSentry を持たないバイナリでトップレベルに import * as Sentry from "@sentry/react-native" と書くとimportの時点で例外が投げられ、画面が真っ白になります。なので、SDKは遅延して require し、それもネイティブモジュールが本当にあることを確認してからにしています。

function nativeModulePresent(): boolean {
  try {
    return Boolean(TurboModuleRegistry.get("RNSentry") ?? NativeModules.RNSentry);
  } catch {
    return false;
  }
}

export function initCrashReporting(): boolean {
  if (!DSN || Platform.OS === "web" || !nativeModulePresent()) return false;
  const Sentry = require("@sentry/react-native");
  Sentry.init({
    dsn: DSN,
    release: Constants.expoConfig?.version ?? "unknown",
    sendDefaultPii: false,
    tracesSampleRate: 0,
    enableAutoSessionTracking: false,
    enableAutoPerformanceTracing: false,
    enableAppStartTracking: false,
    enableNativeFramesTracking: false,
    enableStallTracking: false,
    enableUserInteractionTracing: false,
    maxBreadcrumbs: 50,
    beforeSend(event) { /* tag the OTA bundle */ return event; },
  });
  return true;
}

扱うのはエラーだけです。サーバーはトレース、セッション、プロファイル、リプレイを保存しないので、どれも有効にしていません。DSNがなければ(開発時やE2Eのビルド)SDKは起動すらしません。EASのビルドプロファイルがDSNと環境(staging か production)を設定するので、どちらのビルドも同じエンドポイントに報告しつつ、管理画面では区別できます。ログイン中のユーザーはFirebaseのUIDで設定し、名前やメールアドレスは一切使いません。

エラーバウンダリには明示的な呼び出しが必要でした。Expo Routerのルートの ErrorBoundary はレンダリングエラーを捕まえて独自の画面を表示するので、そのエラーはSDKがフックしているグローバルハンドラーまで届きません。そこでレイアウトからは小さなラッパーをエクスポートし、useEffect の中で captureException を呼んでから、これまでどおりルーターのバウンダリを描画するようにしました。ルートごとのバウンダリも同じです。レンダリング中ではなくエフェクトの中で呼んでいるのは、再レンダリングのたびにではなく1回だけ報告するためです。ルートコンポーネントは Sentry.wrap でもラップしていて、それが例外を投げたら素のコンポーネントに戻ります。

Web向けには、隣に全関数を無効にした crashReporting.web.ts を置いています。Metroがプラットフォーム別のファイルを選ぶので、SDKがWebのバンドルに入ることはありません。Webアプリには動かすべきネイティブモジュールがないからです。

以前からある /client-errors/ の仕組みは残します。2.5.9より古いバイナリでは今でも唯一の報告手段ですし、クラッシュではないエラーもカバーしてくれます。

sentry-cli も、ビルドフェーズも、expo prebuild もありません。このプロジェクトの ios/ と android/ ディレクトリは手で編集してコミットしているもので、ネイティブモジュールはautolinkingが勝手に拾ってくれました。リリースは2.5.9です。

ネイティブクラッシュが届くことを証明する
#

ユニットテストは手書きのエンベロープをPOSTするもので、証明できるのは、SDKが送ってくると 私が思っている ものをパーサーが処理できる、ということだけです。リリースから2日後、本番のクラッシュレポートはゼロでした。2.5.9を使っているのはまだ3人だけだったので、ゼロでもおかしくはありません。でも「誰もクラッシュしていない」と「クラッシュが届いていない」は、サーバーから見るとまったく同じに見えます。本物を見たいと思いました。

そこで、DSNをポート8002のローカルDjangoと使い捨てのキーに向けた、Releaseビルドのシミュレーター用バイナリを作りました。

EXPO_PUBLIC_CRASH_DSN=http://localcrashtestkey0000@localhost:8002/1 \
EXPO_PUBLIC_CRASH_ENVIRONMENT=local-verify \
xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Release \
  -destination 'platform=iOS Simulator,id=<udid>' -derivedDataPath /tmp/iosbuild

(Debugではなく Release です。Debugビルドはdev launcherをリンクするので挙動が違いますし、ユーザーが使っているのはReleaseです。)それからインストールして起動し、数秒待ってから、本物の障害と同じやり方で強制終了させました。

xcrun simctl install booted /tmp/iosbuild/Build/Products/Release-iphonesimulator/MyApp.app
xcrun simctl launch booted com.example.myapp      # prints the pid
kill -ABRT <pid>

シミュレーターのアプリは普通のmacOSのプロセスなので、シグナルは実機とまったく同じようにSentryCrashのハンドラーに届きます。ハンドラーはレポートをディスクに書き込むだけで、プロセスが死にかけている間は何も送りません。 次の起動時 にSDKがそれを見つけて送信します。再起動すると、1秒後にシミュレーターのネットワークログに :8002 への接続が出ました。

ところがDjangoのログには何も出ず、少しの間混乱しました。開発サーバーは、私が設定していたログレベルでは2xx以外の応答しかログに出さないのです。データベースにはちゃんと入っていました。

SIGABRT: Signal 6, Code 0 | cocoa 2.5.9 iOS 27.1 iPhone19,4 handled=False
received 09:19:53, crashed 09:19:38
contexts: app, device, os, ota_updates, trace · 24 breadcrumbs

クラッシュから保存まで15秒で、そのほとんどは私が再起動していた時間です。そして本物のペイロードを見たことで、テストでは分からなかったことがいくつも分かりました。

  • ユーザーIDがFirebaseのUIDではありませんでした。 テスト用ビルドは実際にはログインしていないので、sentry-cocoaはインストールごとの独自IDを使っていました。知っておく価値があり、プライバシーの説明にも書くべきことです。レポートには、ログインしていればアカウントのID、していなければランダムなインストールIDが入ります。
  • タイムゾーンがありません。 cultureコンテキストがあるものと思い込んでいました。deviceコンテキストには locale、メモリ、画面サイズ、省電力モード、カーネルのバージョンはありますが、タイムゾーンはありません。
  • ネイティブクラッシュは beforeSend を通りません。 JSの beforeSend で実行中のOTAバンドルを示す ota タグを付けていますが、ネイティブクラッシュは次の起動時にネイティブSDKが組み立てるので、JSのフックを一度も通りません。ネイティブクラッシュにタグを付けるには、バンドルが分かった時点で setTag を使ってスコープに設定し、ネイティブ層に同期させる必要があります。
  • パンくずリストからアプリの動きが見えました。 コンソールの警告、ビューコントローラーのライフサイクル、HTTPの呼び出し。その中にはFirebase Analytics自身による app-analytics-services.com へのリクエストも含まれていて、アプリが他にどこと通信しているかを思い出させてくれます。そしてURLにはクエリ文字列が付いていて、これで先ほどの検索語の問題に気づきました。

レポートを読む
#

Djangoの管理画面には、Sentryを真似たissueページを作りました。件数、保持しているイベントの中のユニークユーザー数、Resolve / Ignore / Reopenボタンが並ぶヘッダー、daily_counts から描く30日分の日ごとの棒グラフ、保持しているイベントをリリース、OS、端末、OTAバンドル、環境、fatalかhandledかで分けた割合(それぞれ上位4つの値を出し、残りは「other」にまとめます)、そして1件ずつのイベント表示です。イベントには例外、フレーム(アプリのフレームは強調し、ライブラリのフレームは折りたたみます)、パンくずリストのタイムライン、端末のコンテキストがあり、一番下に生のJSONがあります。issue一覧には行ごとに14日分のスパークラインがあります。テンプレート1つと、保存済みペイロードを読むモジュール1つだけで、新しい依存関係は何も要りませんでした。

作っている途中で一つ修正が出ました。ネイティブクラッシュでは、保存された「原因(culprit)」フレームが ? (/private/var/containers/Bundle/Application/<uuid>/MyApp.app/MyApp) になっていました。これは端末上のバイナリのパスで、インストールごとのUUIDを含みます。今は 0x1000a1b2c (MyApp) と表示されます。個々のレポートの管理画面URLは、そのイベントを選択した状態でissueページにリダイレクトするので、クラッシュを読む場所は2か所ではなく1か所です。

プライバシーポリシーを、まるごと
#

クラッシュレポートは、ほぼ個人情報です。アカウントID、スマートフォンの機種、そしてアプリが死ぬ直前の1分間にその人が何をしていたか。なので、プライバシーポリシーに1段落必要でした。きちんと書くにはレポートに何が入っているかを正確に書く必要があったので、さっき見たばかりのペイロードから列挙しました。エラー、アプリのバージョンとOTAアップデート、機種、OSと言語、空きメモリと画面サイズ、直前の数十ステップ(開いた画面、 ? 以降を取り除いたWebアドレス、アプリ自身のログ)、そしてアカウントIDかランダムなインストールID。ポリシーには、レポートが自社のサーバーに届き、Sentryやその他のクラッシュレポート会社には送られないことも書いています。保存期間は表に専用の行を設けました。問題ごとに直近50件のレポートと、90日分の日ごとの件数です。

一つの段落をコードと突き合わせ始めると、残りを確認しないわけにはいきませんでした。既存のポリシーは耐えられませんでした。アカウント削除ページには、アカウントを閉じるとプッシュ通知のトークンとパスキーは即座に削除され、サーバーログは90日以内に消える、と書かれていました。どれも事実ではありませんでした。アカウントを閉じるとサインイン方法の紐付けが外れてプロフィールは匿名化されますが、トークンの行とパスキーの記録は誰かが手で消すまで残りますし、ログのアーカイブにはまだ有効期限がありません。そこでポリシー全体を、コードが実際に何をしているかの棚卸しから書き直し、それらの行は今は「まだ自動では削除されません。メールでご連絡ください」となっています。安心感は減りますが、正確です。

リポジトリには今、そのためのルールがあります。ポリシーは私たちの意図ではなくコードが何をするかを書くこと、そして個人データを新たに収集・送信・保存する変更は、同じコミットでポリシーも更新すること。対象にはアプリ内の要約画面、アカウント削除ページ、そしてストアのコンソールにしか存在しないApp StoreとPlayのプライバシーフォームも含まれます。これを書き留めるきっかけになった変更が、クラッシュレポートでした。

足りないもの
#

シンボリケーション。 これが本当の穴で、sentry.ioがやっていて私がまだ置き換えていない唯一のものです。スタックはJavaScriptでは難読化され、ネイティブコードではシンボル化されていません。

  • JavaScript: フレームはHermesバンドルの中を指しています。直すには、ストア向けビルドとOTAバンドルのすべてについて合成済みのHermesソースマップをリリースとバンドルをキーに保存しておき、ページを描画するときにJSのフレームに適用する必要があります。うちのクラッシュの大半はJavaScriptになるはずなので、次に取りかかるのはこれです。
  • iOSのネイティブ: 命令アドレスにはビルドのdSYMが必要です。
  • Android: 難読化(minification)を有効にするとJavaのフレームはR8で難読化され(mapping.txt が必要)、C++のフレームにはNDKのシンボルが必要です。

先ほど見つけた、もう少し小さな穴もあります。OTAタグは beforeSend でしか付けていないので、ネイティブクラッシュにはまだ付きません。実行中のバンドルが確定した時点でスコープに設定するのは数行ですが、まだ書いていません。

今のところ、issueから分かるのは、 何が クラッシュしたか、どのリリース、OTAバンドル、端末でか、そしてその直前に何をしていたかです。ソースの どこで かは分かりません。週に数回リリースする小さなアプリなら、これまでは型とメッセージとパンくずリストで十分に原因を見つけられています。

受信側はおよそ650行(パーサー、グルーピング、アラート、モデル、ビュー)、管理画面がさらに700行ほど、テストが約400行で、すでに動いていたAPIの上に追加のコストはかかっていません。難しい部分はSDKがやってくれます。スマートフォン上でクラッシュを捕まえ、次の起動時にレポートを送り出すことです。