Skip to content

フロントエンドエラーハンドリング

Error Boundary 3層構造

React の Error Boundary を3層に配置し、エラーの影響範囲を最小化する:

配置場所捕捉範囲フォールバック UI
L1: アプリケーションルートレイアウト(各ポータルのエントリポイント)アプリ全体のクラッシュ「予期しないエラーが発生しました」画面 + TraceId 表示 + リロードボタン
L2: ルートTanStack Router の各ルート定義画面単位のエラー該当画面のみエラー表示。サイドバー・ナビゲーションは維持
L3: コンポーネント独立した機能ブロック(グラフ、ファイルアップロード等)コンポーネント単位該当ブロックのみ「表示できません」。画面の他の部分は正常動作

エラー報告:

  • 全層の Error Boundary で捕捉したエラーをフロントエンドエラー追跡サービスに自動送信(→ QA-003
  • L1(アプリケーション層)でのエラーには TraceId を画面に表示し、ユーザーがサポートに問い合わせる際の手がかりとする

各層のフォールバック UI に TraceId 表示を統合した詳細:

L1: アプリケーション層L2: ルート層L3: コンポーネント層自動送信自動送信自動送信L2で未捕捉のエラーL3で未捕捉のエラーエラー捕捉Sentryエラー追跡ルートレイアウト(各ポータルのエントリポイント)フォールバック:「予期しないエラーが発生しました」+ TraceId 表示+ EventId 表示+ リロードボタンTanStack Router 各ルート定義errorComponentフォールバック:該当画面のみエラー表示+ TraceId 表示サイドバー・ナビ維持独立した機能ブロック(グラフ, ファイルアップロード等)フォールバック:「表示できません」+ 再試行ボタン他のコンテンツは正常動作
L1: アプリケーション層L2: ルート層L3: コンポーネント層自動送信自動送信自動送信L2で未捕捉のエラーL3で未捕捉のエラーエラー捕捉Sentryエラー追跡ルートレイアウト(各ポータルのエントリポイント)フォールバック:「予期しないエラーが発生しました」+ TraceId 表示+ EventId 表示+ リロードボタンTanStack Router 各ルート定義errorComponentフォールバック:該当画面のみエラー表示+ TraceId 表示サイドバー・ナビ維持独立した機能ブロック(グラフ, ファイルアップロード等)フォールバック:「表示できません」+ 再試行ボタン他のコンテンツは正常動作

L1: アプリケーション層

各ポータル(/employee/, /admin/, /agency/, /system/)のルートレイアウトに配置。Sentry の ErrorBoundary コンポーネントを使用し、eventId をフォールバック UI に表示する:

typescript
import * as Sentry from "@sentry/react";

function AppErrorFallback({
  error,
  eventId,
  resetError,
}: {
  error: Error;
  eventId: string;
  resetError: () => void;
}) {
  const traceId =
    error instanceof ApiError ? error.traceId : null;

  return (
    <main role="alert" aria-live="assertive">
      <h1>予期しないエラーが発生しました</h1>
      <p>
        サポートにお問い合わせの際は以下のIDをお伝えください:
      </p>
      <dl>
        {traceId && (
          <>
            <dt>TraceId</dt>
            <dd><code>{traceId}</code></dd>
          </>
        )}
        <dt>EventId</dt>
        <dd><code>{eventId}</code></dd>
      </dl>
      <button onClick={resetError}>
        ページをリロード
      </button>
    </main>
  );
}

// ポータルのルートレイアウトで使用
function EmployeePortalLayout() {
  return (
    <Sentry.ErrorBoundary
      fallback={AppErrorFallback}
      showDialog={false}
    >
      <Outlet />
    </Sentry.ErrorBoundary>
  );
}

L2: ルート層

TanStack Router の errorComponent として配置。該当画面のみエラー表示し、サイドバー・ナビゲーションは維持:

typescript
export const declarationRoute = createRoute({
  getParentRoute: () => employeeLayout,
  path: "$taxYear/declaration",
  component: DeclarationPage,
  errorComponent: ({ error }) => {
    const traceId =
      error instanceof ApiError ? error.traceId : null;

    return (
      <section role="alert" aria-live="polite">
        <h2>この画面の表示中にエラーが発生しました</h2>
        {traceId && (
          <p>
            TraceId: <code>{traceId}</code>
          </p>
        )}
        <nav>
          <button onClick={() => window.location.reload()}>
            再読み込み
          </button>
          <Link to="..">前の画面に戻る</Link>
        </nav>
      </section>
    );
  },
});

L3: コンポーネント層

独立した機能ブロック(グラフ、ファイルアップロード等)に配置。周囲のコンテンツに影響を与えず、再試行ボタンを提供:

typescript
function WidgetErrorFallback({
  resetErrorBoundary,
}: {
  resetErrorBoundary: () => void;
}) {
  return (
    <div role="alert" aria-live="polite">
      <p>表示できません</p>
      <button onClick={resetErrorBoundary}>再試行</button>
    </div>
  );
}

TanStack Query エラーハンドリング

グローバル onError コールバック

すべてのクエリ/ミューテーションのエラーを Sentry に送信し、traceId をタグとして付与する:

typescript
import * as Sentry from "@sentry/react";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: (failureCount, error) => {
        if (error instanceof ApiError) {
          // 認証/認可エラーはリトライしない
          if (error.status === 401 || error.status === 403) {
            return false;
          }
          // システムエラーは最大3回リトライ
          if (error.status >= 500) {
            return failureCount < 3;
          }
        }
        return false;
      },
      retryDelay: (attemptIndex) =>
        Math.min(1000 * 2 ** attemptIndex, 8000) +
        Math.random() * 1000, // Exponential Backoff + Jitter
    },
    mutations: {
      onError: (error) => {
        if (error instanceof ApiError && error.traceId) {
          Sentry.withScope((scope) => {
            scope.setTag("traceId", error.traceId);
            scope.setTag("errorCode", error.errorCode ?? "unknown");
            scope.setTag("httpStatus", String(error.status));
            Sentry.captureException(error);
          });
        }
      },
    },
  },
});

throwOnError 戦略

条件throwOnError理由
5xx エラー(リトライ後も失敗)trueError Boundary で包括的にハンドリング
401 エラーfalseグローバル Interceptor でリフレッシュ/リダイレクト処理
400 / 422 エラー(フォーム送信時)falseフィールドレベルエラーとしてインライン表示
409 エラー(競合)false競合解決 UI をインライン表示

Sentry 統合

初期化設定

typescript
import * as Sentry from "@sentry/react";

Sentry.init({
  dsn: "https://examplePublicKey@o0.ingest.sentry.io/0",
  environment: import.meta.env.MODE,
  release: import.meta.env.VITE_RELEASE_VERSION,

  // トレース伝播対象: TASHIKA API のみ
  tracePropagationTargets: [
    /^https:\/\/api\.tashika\.example/,
  ],

  // パフォーマンストレースのサンプリングレート
  tracesSampleRate: import.meta.env.PROD ? 0.1 : 1.0,

  // エラー発生セッションの Session Replay キャプチャ率
  replaysOnErrorSampleRate: 1.0, // エラー時は100%キャプチャ
  replaysSessionSampleRate: 0.0, // 通常セッションはキャプチャしない

  integrations: [
    Sentry.replayIntegration({
      // マイナンバー関連フィールドのマスキング
      maskAllText: false,
      maskAllInputs: false,
      mask: [
        "[data-sentry-mask]",        // 明示的にマスク指定された要素
        "[name*='mynumber']",         // マイナンバー入力フィールド
        "[name*='my_number']",        // マイナンバー入力フィールド(別名)
        "[data-sensitive='true']",    // 機密データフィールド
      ],
    }),
    Sentry.browserTracingIntegration(),
  ],

  // エラー送信前のデータスクラビング
  beforeSend(event) {
    // マイナンバーパターン(12桁数字)をスクラブ
    const mynumberPattern = /\b\d{12}\b/g;
    if (event.message) {
      event.message = event.message.replace(
        mynumberPattern,
        "[REDACTED]",
      );
    }
    return event;
  },
});

ユーザーコンテキスト設定

ログイン後にユーザーコンテキストを設定する。機密情報は含めない:

typescript
function setUserContext(user: AuthenticatedUser) {
  Sentry.setUser({
    id: user.userId,
  });
  Sentry.setTag("tenantId", user.tenantId);
  Sentry.setTag("portal", user.portal);
  Sentry.setTag("role", user.role);
  // 注意: マイナンバー、メールアドレス、氏名は設定しない
}

チャンク読み込みエラー対策

SPA のデプロイ後に、ユーザーのブラウザが古いチャンクを参照して ChunkLoadError が発生するケースに対応する:

対策説明
自動リロードChunkLoadError を検出した場合、1回だけ自動リロードを試行する(無限リロードループを防止するため、SessionStorage でリロード済みフラグを管理)
旧チャンク保持デプロイ時に旧バージョンの静的アセットを一定期間(最低1時間)CDN 上に保持する
ユーザー通知自動リロードでも解消しない場合、「新しいバージョンが利用可能です。ページを更新してください」のバナーを表示する

→ 満たす要件: PE-004 ユーザビリティQA-003 可観測性