アプリを1本でも本番リリースすると、「ローカルでは再現しないのに、特定のユーザーだけがクラッシュする」という壁に必ずぶつかります。2014年から個人でアプリを運営してきた経験から、この壁を越えるための実装パターンを順にお伝えします。
取り組みの背景 — なぜエラーハンドリングと監視が重要なのか
アプリ開発において、機能の実装だけでは不十分です。本番環境にリリースしたアプリがクラッシュすれば、ユーザーの信頼を失い、App Store のレビュー評価も下がります。Rork Max を使えばアプリの構築は驚くほど速くなりますが、堅牢性を確保するためのエラーハンドリングと監視体制は開発者自身が設計する必要があります。
Rork Max で構築するアプリに適用できるエラーハンドリング・デバッグ・本番監視の実践パターンを、実際の運用で効いた工夫とあわせて取り上げます。React Native(Expo)のエコシステムを前提に、初期開発からリリース後の運用まで、一貫したエラー対策を構築する方法を学べます。
対象読者 : Rork でアプリを1本以上リリースした経験がある方、または本番運用を見据えた設計に取り組みたい中上級者の方
前提知識 : React Native / Expo の基礎、TypeScript の基本構文、Rork Max の基本操作
Error Boundary による UI レベルのエラー防御
React のコンポーネントツリーで発生するレンダリングエラーは、アプリ全体をクラッシュさせる原因になります。Error Boundary を正しく配置することで、障害の影響範囲を最小限に抑えられます。
基本的な Error Boundary の実装
// components/ErrorBoundary.tsx
import React, { Component, ReactNode } from 'react' ;
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native' ;
interface Props {
children : ReactNode ;
fallback ?: ReactNode ;
onError ?: ( error : Error , errorInfo : React . ErrorInfo ) => void ;
}
interface State {
hasError : boolean ;
error : Error | null ;
}
class ErrorBoundary extends Component < Props , State > {
constructor ( props : Props ) {
super (props);
this .state = { hasError: false , error: null };
}
static getDerivedStateFromError ( error : Error ) : State {
return { hasError: true , error };
}
componentDidCatch ( error : Error , errorInfo : React . ErrorInfo ) {
// エラーログをリモートに送信
this .props. onError ?.(error, errorInfo);
console. error ( '[ErrorBoundary]' , error.message, errorInfo.componentStack);
}
handleRetry = () => {
this . setState ({ hasError: false , error: null });
};
render () {
if ( this .state.hasError) {
if ( this .props.fallback) {
return this .props.fallback;
}
return (
< View style = { styles.container } >
< Text style = { styles.title } >問題が発生しました</ Text >
< Text style = { styles.message } >
{ this .state.error?.message || '予期しないエラーが発生しました' }
</ Text >
< TouchableOpacity style = { styles.button } onPress = { this .handleRetry } >
< Text style = { styles.buttonText } >もう一度試す</ Text >
</ TouchableOpacity >
</ View >
);
}
return this .props.children;
}
}
const styles = StyleSheet. create ({
container: { flex: 1 , justifyContent: 'center' , alignItems: 'center' , padding: 24 },
title: { fontSize: 20 , fontWeight: 'bold' , marginBottom: 12 },
message: { fontSize: 14 , color: '#666' , textAlign: 'center' , marginBottom: 24 },
button: { backgroundColor: '#007AFF' , paddingHorizontal: 24 , paddingVertical: 12 , borderRadius: 8 },
buttonText: { color: '#fff' , fontSize: 16 , fontWeight: '600' },
});
export default ErrorBoundary;
多層 Error Boundary 戦略
単一の Error Boundary ですべてをカバーするのではなく、アプリのレイヤーごとに配置するのが上級パターンです。
// app/_layout.tsx — ルートレベルの Error Boundary
import ErrorBoundary from '@/components/ErrorBoundary' ;
import { reportToSentry } from '@/utils/errorReporter' ;
export default function RootLayout () {
return (
< ErrorBoundary onError = { reportToSentry } fallback = { < CriticalErrorScreen /> } >
< NavigationContainer >
< ErrorBoundary onError = { reportToSentry } >
{ /* 画面レベルのエラーは個別にキャッチ */ }
< MainStack />
</ ErrorBoundary >
</ NavigationContainer >
</ ErrorBoundary >
);
}
この設計では、ルートレベルの Error Boundary がナビゲーション自体の障害をキャッチし、画面レベルの Error Boundary が個別画面のレンダリングエラーを処理します。ユーザーは画面単位でリトライでき、アプリ全体のクラッシュを回避できます。
グローバルエラーハンドラーの設計
React の Error Boundary はレンダリングフェーズのエラーしかキャッチできません。非同期処理、イベントハンドラー、ネイティブモジュールのエラーには別のアプローチが必要です。
未処理の Promise 拒否をキャッチする
// utils/globalErrorHandler.ts
import { logger } from './logger' ;
export function setupGlobalErrorHandlers () {
// 未処理の Promise rejection
const originalHandler = global.ErrorUtils?. getGlobalHandler ();
global.ErrorUtils?. setGlobalHandler (( error : Error , isFatal : boolean ) => {
logger. error ( 'GlobalHandler' , {
message: error.message,
stack: error.stack,
isFatal,
});
// 致命的エラーの場合はユーザーに通知
if (isFatal) {
// クラッシュレポートを送信してからデフォルトハンドラーに委譲
sendCrashReport (error). finally (() => {
originalHandler ?.(error, isFatal);
});
}
});
// unhandledrejection は React Native では
// tracking-promise-rejection ポリフィルが必要な場合がある
if ( typeof global.addEventListener === 'function' ) {
global. addEventListener ( 'unhandledrejection' , ( event : any ) => {
logger. warn ( 'UnhandledRejection' , {
reason: event?.reason?.message || String (event?.reason),
});
});
}
}
async function sendCrashReport ( error : Error ) : Promise < void > {
try {
await fetch ( 'https://your-api.example.com/crash-report' , {
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify ({
message: error.message,
stack: error.stack,
timestamp: new Date (). toISOString (),
platform: require ( 'react-native' ).Platform. OS ,
}),
});
} catch {
// クラッシュレポート送信自体が失敗しても握りつぶす
}
}
アプリ起動時にハンドラーを登録
// App.tsx or app/_layout.tsx
import { setupGlobalErrorHandlers } from '@/utils/globalErrorHandler' ;
// アプリ起動直後に呼び出す
setupGlobalErrorHandlers ();
この仕組みにより、Error Boundary がカバーできない非同期エラーやネイティブレイヤーのエラーも確実に記録できます。
カスタムロガーの設計と実装
console.log だけに頼ったデバッグでは、本番環境の問題を追跡できません。構造化されたログを出力し、必要に応じてリモートに送信できるカスタムロガーを設計しましょう。
構造化ロガーの実装
// utils/logger.ts
type LogLevel = 'debug' | 'info' | 'warn' | 'error' ;
interface LogEntry {
level : LogLevel ;
tag : string ;
message : string ;
data ?: Record < string , unknown >;
timestamp : string ;
}
const LOG_LEVELS : Record < LogLevel , number > = {
debug: 0 ,
info: 1 ,
warn: 2 ,
error: 3 ,
};
class Logger {
private minLevel : LogLevel ;
private buffer : LogEntry [] = [];
private readonly MAX_BUFFER_SIZE = 100 ;
private remoteEndpoint ?: string ;
constructor ( minLevel : LogLevel = __DEV__ ? 'debug' : 'warn' ) {
this .minLevel = minLevel;
}
setRemoteEndpoint ( url : string ) {
this .remoteEndpoint = url;
}
debug ( tag : string , data ?: Record < string , unknown >) {
this . log ( 'debug' , tag, '' , data);
}
info ( tag : string , message : string , data ?: Record < string , unknown >) {
this . log ( 'info' , tag, message, data);
}
warn ( tag : string , data ?: Record < string , unknown >) {
this . log ( 'warn' , tag, '' , data);
}
error ( tag : string , data ?: Record < string , unknown >) {
this . log ( 'error' , tag, '' , data);
}
private log ( level : LogLevel , tag : string , message : string , data ?: Record < string , unknown >) {
if ( LOG_LEVELS [level] < LOG_LEVELS [ this .minLevel]) return ;
const entry : LogEntry = {
level,
tag,
message,
data,
timestamp: new Date (). toISOString (),
};
// 開発環境ではコンソールに出力
if (__DEV__) {
const prefix = `[${ level . toUpperCase () }][${ tag }]` ;
console[level === 'debug' ? 'log' : level](prefix, message, data || '' );
}
// バッファに追加(リモート送信用)
this .buffer. push (entry);
if ( this .buffer. length >= this . MAX_BUFFER_SIZE ) {
this . flush ();
}
}
async flush () : Promise < void > {
if ( ! this .remoteEndpoint || this .buffer. length === 0 ) return ;
const entries = [ ... this .buffer];
this .buffer = [];
try {
await fetch ( this .remoteEndpoint, {
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify ({ logs: entries }),
});
} catch {
// 送信失敗時はバッファに戻す(上限チェック付き)
if ( this .buffer. length + entries. length <= this . MAX_BUFFER_SIZE * 2 ) {
this .buffer = [ ... entries, ... this .buffer];
}
}
}
}
// シングルトンとしてエクスポート
export const logger = new Logger ();
このロガーは、開発時にはコンソールに詳細ログを表示し、本番ではエラーレベル以上のログだけをリモートに送信します。バッファリングにより、ネットワーク負荷も最小限に抑えられます。
Sentry 統合による本番エラー監視
本番環境のエラーを効率的に検知・分析するには、専用の監視サービスが不可欠です。Sentry は React Native / Expo との親和性が高く、Rork Max アプリにも簡単に統合できます。
Sentry のセットアップ
# Expo プロジェクトに Sentry をインストール
npx expo install @sentry/react-native
// utils/sentry.ts
import * as Sentry from '@sentry/react-native' ;
import Constants from 'expo-constants' ;
export function initSentry () {
Sentry. init ({
dsn: 'YOUR_SENTRY_DSN' , // Sentry ダッシュボードから取得
environment: __DEV__ ? 'development' : 'production' ,
release: Constants.expoConfig?.version || '1.0.0' ,
dist: Constants.expoConfig?.ios?.buildNumber
|| Constants.expoConfig?.android?.versionCode?. toString ()
|| '1' ,
// パフォーマンスモニタリング(サンプリングレート)
tracesSampleRate: __DEV__ ? 1.0 : 0.2 ,
// セッションリプレイ(エラー発生時のみ記録)
replaysSessionSampleRate: 0 ,
replaysOnErrorSampleRate: 1.0 ,
beforeSend ( event ) {
// 開発環境では送信しない
if (__DEV__) return null ;
// 機密情報をフィルタリング
if (event.request?.headers) {
delete event.request.headers[ 'Authorization' ];
}
return event;
},
});
}
// エラーレポート送信のヘルパー
export function reportToSentry ( error : Error , context ?: Record < string , unknown >) {
Sentry. withScope (( scope ) => {
if (context) {
Object. entries (context). forEach (([ key , value ]) => {
scope. setExtra (key, value);
});
}
Sentry. captureException (error);
});
}
// ユーザー情報を設定(ログイン時に呼び出す)
export function setSentryUser ( userId : string , email ?: string ) {
Sentry. setUser ({ id: userId, email });
}
// ナビゲーショントラッキング
export function trackNavigation ( routeName : string ) {
Sentry. addBreadcrumb ({
category: 'navigation' ,
message: `Navigated to ${ routeName }` ,
level: 'info' ,
});
}
Error Boundary との連携
// 先ほどの ErrorBoundary に Sentry を統合
< ErrorBoundary
onError = { ( error , errorInfo ) => {
Sentry. withScope (( scope ) => {
scope. setExtra ( 'componentStack' , errorInfo.componentStack);
scope. setTag ( 'errorBoundary' , 'screen-level' );
Sentry. captureException (error);
});
} }
>
< ScreenContent />
</ ErrorBoundary >
API 通信のエラーハンドリングパターン
ネットワーク通信はエラーが最も頻発する領域です。堅牢なエラーハンドリングを API クライアント層に組み込むことで、個別の画面コンポーネントの負担を減らせます。
リトライ付き API クライアント
// utils/apiClient.ts
import { logger } from './logger' ;
import { reportToSentry } from './sentry' ;
interface ApiConfig {
baseUrl : string ;
timeout : number ;
maxRetries : number ;
}
const DEFAULT_CONFIG : ApiConfig = {
baseUrl: 'https://api.example.com' ,
timeout: 10000 ,
maxRetries: 3 ,
};
class ApiError extends Error {
constructor (
message : string ,
public statusCode : number ,
public responseBody ?: unknown
) {
super (message);
this .name = 'ApiError' ;
}
}
async function fetchWithTimeout (
url : string ,
options : RequestInit ,
timeout : number
) : Promise < Response > {
const controller = new AbortController ();
const id = setTimeout (() => controller. abort (), timeout);
try {
const response = await fetch (url, {
... options,
signal: controller.signal,
});
return response;
} finally {
clearTimeout (id);
}
}
export async function apiRequest < T >(
endpoint : string ,
options : RequestInit = {},
config : Partial < ApiConfig > = {}
) : Promise < T > {
const { baseUrl , timeout , maxRetries } = { ... DEFAULT_CONFIG , ... config };
const url = `${ baseUrl }${ endpoint }` ;
let lastError : Error | null = null ;
for ( let attempt = 1 ; attempt <= maxRetries; attempt ++ ) {
try {
const response = await fetchWithTimeout (url, {
... options,
headers: {
'Content-Type' : 'application/json' ,
... options.headers,
},
}, timeout);
if ( ! response.ok) {
const body = await response. text (). catch (() => '' );
throw new ApiError (
`API error: ${ response . status } ${ response . statusText }` ,
response.status,
body
);
}
return await response. json ();
} catch (error) {
lastError = error as Error ;
// リトライ不要なエラー(4xx クライアントエラー)
if (error instanceof ApiError && error.statusCode >= 400 && error.statusCode < 500 ) {
logger. warn ( 'ApiClient' , {
endpoint,
status: error.statusCode,
attempt,
});
throw error;
}
// リトライ可能なエラー(タイムアウト、5xx サーバーエラー)
if (attempt < maxRetries) {
const delay = Math. min ( 1000 * Math. pow ( 2 , attempt - 1 ), 8000 );
logger. info ( 'ApiClient' , `Retrying in ${ delay }ms` , {
endpoint,
attempt,
error: (error as Error ).message,
});
await new Promise (( resolve ) => setTimeout (resolve, delay));
}
}
}
// すべてのリトライが失敗
logger. error ( 'ApiClient' , {
endpoint,
message: lastError?.message,
retriesExhausted: true ,
});
reportToSentry (lastError ! , { endpoint, maxRetries });
throw lastError;
}
このクライアントは、指数バックオフによるリトライ、タイムアウト制御、クライアントエラーとサーバーエラーの区別を備えています。各リトライでログを記録し、最終的に失敗した場合は Sentry にレポートします。
React Hook によるエラーステート管理
API 通信のエラーを UI に反映するための再利用可能な Hook を設計します。
// hooks/useAsyncOperation.ts
import { useState, useCallback, useRef } from 'react' ;
import { logger } from '@/utils/logger' ;
interface AsyncState < T > {
data : T | null ;
error : Error | null ;
isLoading : boolean ;
}
export function useAsyncOperation < T >(
operation : ( ... args : any []) => Promise < T >,
tag : string
) {
const [ state , setState ] = useState < AsyncState < T >>({
data: null ,
error: null ,
isLoading: false ,
});
const mountedRef = useRef ( true );
const execute = useCallback (
async ( ... args : any []) => {
setState (( prev ) => ({ ... prev, isLoading: true , error: null }));
try {
const result = await operation ( ... args);
if (mountedRef.current) {
setState ({ data: result, error: null , isLoading: false });
}
return result;
} catch (error) {
const err = error instanceof Error ? error : new Error ( String (error));
logger. error (tag, { message: err.message });
if (mountedRef.current) {
setState ({ data: null , error: err, isLoading: false });
}
throw err;
}
},
[operation, tag]
);
const reset = useCallback (() => {
setState ({ data: null , error: null , isLoading: false });
}, []);
return { ... state, execute, reset };
}
使用例:
// screens/ProductList.tsx
function ProductList () {
const { data , error , isLoading , execute } = useAsyncOperation (
() => apiRequest < Product []>( '/products' ),
'ProductList'
);
useEffect (() => {
execute ();
}, []);
if (isLoading) return < LoadingSpinner />;
if (error) return < ErrorView message = { error.message } onRetry = { execute } />;
return < FlatList data = { data } renderItem = { renderProduct } />;
}
開発環境でのデバッグテクニック
本番監視と並んで、開発段階での効率的なデバッグも重要です。Rork Max + Expo の開発環境で活用できるテクニックを紹介します。
Flipper / React Native Debugger の活用
React Native のデバッグツールとして、Flipper や React Native Debugger を使うと、ネットワークリクエスト、Redux ストアの状態変化、レイアウトの詳細をリアルタイムで確認できます。
// flipper-plugin.ts(開発時のみ有効化)
if (__DEV__) {
// Flipper のネットワークプラグインを有効化
require ( 'react-native-flipper' ). addPlugin ({
getId () { return 'CustomNetworkLogger' ; },
onConnect ( connection : any ) {
// API リクエストのインターセプトをセットアップ
logger. info ( 'Flipper' , 'Connected to Flipper' );
},
onDisconnect () {
logger. info ( 'Flipper' , 'Disconnected from Flipper' );
},
});
}
パフォーマンスプロファイリング
// utils/perfMonitor.ts
export function measureAsync < T >(
label : string ,
fn : () => Promise < T >
) : Promise < T > {
const start = performance. now ();
return fn (). then (
( result ) => {
const duration = performance. now () - start;
if (duration > 1000 ) {
logger. warn ( 'PerfMonitor' , {
label,
duration: `${ duration . toFixed ( 0 ) }ms` ,
status: 'slow' ,
});
}
return result;
},
( error ) => {
const duration = performance. now () - start;
logger. error ( 'PerfMonitor' , {
label,
duration: `${ duration . toFixed ( 0 ) }ms` ,
error: error.message,
});
throw error;
}
);
}
// 使用例
const products = await measureAsync ( 'fetchProducts' , () =>
apiRequest < Product []>( '/products' )
);
Firebase Crashlytics との併用パターン
Sentry と Firebase Crashlytics を併用することで、より広範なエラー情報を収集できます。Sentry は詳細なスタックトレースとコンテキスト情報に優れ、Crashlytics は Firebase エコシステムとの統合性に強みがあります。
// utils/crashReporter.ts
import * as Sentry from '@sentry/react-native' ;
import crashlytics from '@react-native-firebase/crashlytics' ;
import { logger } from './logger' ;
export const crashReporter = {
// 非致命的エラーを両方に送信
recordError ( error : Error , context ?: Record < string , string >) {
// Sentry
Sentry. withScope (( scope ) => {
if (context) {
Object. entries (context). forEach (([ k , v ]) => scope. setTag (k, v));
}
Sentry. captureException (error);
});
// Firebase Crashlytics
if (context) {
Object. entries (context). forEach (([ k , v ]) => {
crashlytics (). setAttribute (k, v);
});
}
crashlytics (). recordError (error);
logger. error ( 'CrashReporter' , {
message: error.message,
... context,
});
},
// カスタムログを両方に記録
log ( message : string ) {
Sentry. addBreadcrumb ({ message, level: 'info' });
crashlytics (). log (message);
},
// ユーザー識別を両方に設定
setUser ( userId : string ) {
Sentry. setUser ({ id: userId });
crashlytics (). setUserId (userId);
},
};
本番環境のヘルスチェックとアラート設計
エラー監視ツールを導入しただけでは不十分です。アラートの設計次第で、障害への対応速度が大きく変わります。
アラートルールの設計指針
Sentry でアラートを設定する際は、以下の基準で優先度を分けます。
P0(即時対応) : クラッシュ率が 1% を超えた場合、または新バージョンリリース後 1 時間以内にクラッシュが 10 件以上発生した場合
P1(4 時間以内に対応) : 特定の API エンドポイントでエラー率が 5% を超えた場合
P2(翌営業日対応) : 新しいタイプのエラーが初めて検出された場合
リリース後のカナリアチェック
// utils/canaryCheck.ts
import { logger } from './logger' ;
import Constants from 'expo-constants' ;
export async function runCanaryChecks () : Promise < void > {
const checks = [
{ name: 'API Health' , fn: checkApiHealth },
{ name: 'Auth Token Valid' , fn: checkAuthToken },
{ name: 'Storage Accessible' , fn: checkStorage },
];
for ( const check of checks) {
try {
await check. fn ();
logger. info ( 'Canary' , `${ check . name }: OK` );
} catch (error) {
logger. error ( 'Canary' , {
check: check.name,
error: (error as Error ).message,
appVersion: Constants.expoConfig?.version,
});
}
}
}
async function checkApiHealth () : Promise < void > {
const res = await fetch ( 'https://api.example.com/health' );
if ( ! res.ok) throw new Error ( `API returned ${ res . status }` );
}
async function checkAuthToken () : Promise < void > {
// AsyncStorage からトークンを読み出してフォーマット検証
// 有効期限切れの場合は早期にリフレッシュを試みる
}
async function checkStorage () : Promise < void > {
// AsyncStorage の読み書きテスト
}
アプリ起動時にこのカナリアチェックをバックグラウンドで実行し、異常があればログに記録します。ユーザーに影響が出る前に問題を検知できる仕組みです。
公式ドキュメントには書かれていない、本番運用で気づいたこと
ここからは、Sentry や Crashlytics の公式ドキュメントには載っていない、実際に複数のアプリを長く運用するなかで気づいた点をお伝えします。私は2014年からiOS・Android 向けのアプリ(壁紙系・癒し系・引き寄せ系の静かなユーティリティ)を個人で運営し、累計ダウンロードは5,000万を超えました。本数が増えるほど、「クラッシュを減らす」ことよりも「何が起きているかを正確に知る」ことのほうが運用を楽にしてくれる、と感じています。
宮大工だった私の祖父は、「手を動かすことそのものが一つの信心だ」とよく言っていました。エラー監視も同じで、派手な機能ではなく、地味なログを毎日眺める習慣こそが本番品質を支えます。以下は、その日々のなかで数値として手応えのあった工夫です。
1. クラッシュフリー率は「数字」ではなく「差分」で見る
Crashlytics のダッシュボードでクラッシュフリー率(crash-free users)を眺めるとき、絶対値だけを見ても判断を誤ります。私が運用する壁紙アプリでは、平常時のクラッシュフリー率は 99.7〜99.8% で安定していました。これを「99.7%もあるから優秀」と捉えるのは危険です。重要なのはリリース前後の差分で、新バージョン公開から24時間で 99.8% → 99.2% に落ちたとき、絶対値はまだ高くても、影響ユーザーは普段の約4倍に膨らんでいました。
そこで、Crashlytics の Velocity Alert(クラッシュ発生率の急騰アラート)に加えて、自前で「直近24時間と過去7日中央値の差分」をログから算出し、0.3ポイント以上の悪化で通知するようにしました。導入後、リリース起因の不具合の検知が平均で半日ほど早まり、App Store のレビュー評価が下がる前に修正版を出せるようになりました。
// utils/crashRateMonitor.ts
// 平常時の中央値と直近の差分でリリース起因の悪化を検知する
interface CrashSample {
date : string ; // YYYY-MM-DD
crashFreeRate : number ; // 0〜1 (例: 0.997)
}
export function detectRegression (
recent : CrashSample ,
baseline : CrashSample [] // 過去7日分
) : { regressed : boolean ; deltaPoint : number } {
const sorted = [ ... baseline]. map (( s ) => s.crashFreeRate). sort (( a , b ) => a - b);
const median = sorted[Math. floor (sorted. length / 2 )] ?? recent.crashFreeRate;
// ポイント差(パーセンテージポイント)
const deltaPoint = (median - recent.crashFreeRate) * 100 ;
return { regressed: deltaPoint >= 0.3 , deltaPoint };
}
絶対値の閾値(例: 99.5% を割ったら通知)だけだと、平常時の品質が高いアプリほど「気づいたときには手遅れ」になりがちです。差分ベースの監視は、自分のアプリの「いつもの状態」を基準にできる点で実用的でした。
2. ログのサンプリングは「エラー本文」ではなく「ユーザー単位」で行う
リモートログを全件送ると、ネットワーク負荷とログ保管コストが無視できません。当初、記事中の Logger のように全エラーをバッファして送っていましたが、人気アプリで同じバグが一斉に起きると、同一スタックトレースが数万件単位で押し寄せ、肝心の「珍しいが重大なエラー」が埋もれてしまいました。
改善後は、エラーの種類ごとに送信ではなく、ユーザー単位で 10% をサンプリングし、サンプリングから漏れたユーザーのエラーは「種類・件数」のみを集計して送る方式にしました。これで送信量は約 1/8 に減り、それでも新種のエラーは取りこぼさずに検知できています。
// utils/logSampler.ts
// 同一ユーザーは一貫してサンプリング対象に含めるか除外する
function hashToUnit ( userId : string ) : number {
let h = 0 ;
for ( let i = 0 ; i < userId. length ; i ++ ) {
h = (h * 31 + userId. charCodeAt (i)) | 0 ;
}
return (Math. abs (h) % 1000 ) / 1000 ; // 0〜1
}
export function shouldSendFullLog ( userId : string , sampleRate = 0.1 ) : boolean {
// ユーザー単位で一貫した判定(同じユーザーは常に同じ結果)
return hashToUnit (userId) < sampleRate;
}
ポイントは、エラー単位でランダムに間引くと「あるユーザーのエラーの流れ」が分断されて再現が難しくなる一方、ユーザー単位で間引けば、選ばれたユーザーについては完全なログが手元に残ることです。障害再現には後者が圧倒的に役立ちました。
3. beforeSend で「自分のせいではないエラー」を切り分ける
Sentry を本番投入して最初に面食らったのは、自分のコードとは無関係なエラーが大量に届くことでした。具体的には、ネットワーク切断による Network request failed、ユーザーのストレージ枯渇、古い OS 由来の WebView クラッシュなどです。これらを放置すると、ダッシュボードがノイズで埋まり、本当に直すべきエラーが見えなくなります。
私は beforeSend で、復旧不能・かつ自分の対処範囲外のエラーは別タグに振り分け、アラート対象から外すようにしました。月あたりの「対応すべきエラー」の通知件数が、フィルタ導入前の約 70 件から 12 件前後まで減り、1件あたりに割ける時間が増えたことで修正の質が上がりました。
// sentry の beforeSend に追加するフィルタ
const NON_ACTIONABLE_PATTERNS = [
/Network request failed/ i ,
/The request timed out/ i ,
/No space left on device/ i ,
];
beforeSend (event, hint) {
if (__DEV__) return null ;
const msg = hint?.originalException instanceof Error
? hint.originalException.message
: event.message ?? '' ;
if ( NON_ACTIONABLE_PATTERNS . some (( re ) => re. test (msg))) {
// 捨てるのではなく、集計用に別タグで残してアラートからは外す
event.tags = { ... event.tags, actionable: 'false' };
event.fingerprint = [ 'non-actionable' , msg. slice ( 0 , 40 )];
}
return event;
}
捨てずに別タグで残すのがコツです。後から「ネットワーク切断由来のエラーが急増した」と分かれば、それはそれでリトライ設計を見直す材料になります。
本番投入前の運用チェックリスト
最後に、私がアプリをストア公開する前に必ず確認している項目をまとめます。コードの完成度ではなく、「公開後に何が起きても気づける状態か」を点検するためのものです。
致命的エラーが global.ErrorUtils で捕捉され、クラッシュレポートが送信されることを実機で確認した
Sentry の release と dist(ビルド番号)が、ストアに出すビルドと一致している(ここがズレるとシンボリケーションが効かず、スタックトレースが読めません)
beforeSend で Authorization ヘッダーなど機密情報が確実に除去されている
クラッシュフリー率の「差分アラート」が有効で、通知先(メール/Slack)に実際に届くことをテスト送信で確認した
リリース直後の1時間は、能動的にダッシュボードを見る時間を確保してある
5番目は仕組みではなく心構えですが、これがいちばん効きます。リリースは出して終わりではなく、最初の1時間が運用の山場だと考えています。
まとめ
エラーハンドリングと本番監視は、アプリの品質と運用効率を左右する重要な領域です。この記事で解説した多層 Error Boundary、グローバルエラーハンドラー、構造化ロガー、Sentry 統合のパターンを組み合わせることで、Rork Max で構築するアプリに本番品質の堅牢性を持たせることができます。
まずは Error Boundary の配置とグローバルエラーハンドラーの設定から始め、アプリの成長に合わせて Sentry や Crashlytics を追加していくのが現実的なステップです。エラーが起きないアプリは存在しませんが、エラーに素早く気づき、的確に対処できるアプリは確実に作れます。