【React】error-boundaryでホワイトアウトを抑止しつつ、発生時にCloudwatchにログを残す


# はじめに

雑なイラストですがやりたいことの解説を。

## 現状

![images_before.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/6d83fbbb-6edf-7b3b-29ee-8aaf8f19ef01.png)



- Webアプリ上で、予期しないエラーが発生した場合に、画面が真っ白（ホワイトアウト）になるのを回避したい

  - Reactの`Error boundary`の機能を使ってエラー発生時に予め用意したエラー画面へ飛ばすようにします。

- 予期しないエラーが発生した場合に、なぜエラーになったのかを解析できるようにログを残したい
  - ReactなどのSPA Webアプリケーションだと基本的にクライアントの中だけでアプリが動くので、エラーが発生した時の情報は意図的にサーバーに投げない限りは検知できません。
  - そこで、エラーが発生した時にAWSのcloudwatchにログを出力するようにします

## 理想

![images_after.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/577e665e-da97-7f1c-ba64-5f2d82d483e3.png)


## error-boudary

Reactには`Error boundary`というエラーをいい感じにキャッチして捌いてくれる機能があります。
React 16以降の機能になります。

> error boundaryは自身の子コンポーネントツリーで発生したJavaScriptエラーをキャッチし、エラーを記録し、クラッシュしたコンポーネントツリーの代わりにフォールバック用のUIを表示するReactコンポーネントです。error boundaryは配下のツリー全体のレンダー中、ライフサイクルメソッド内、およびコンストラクタ内で発生したエラーをキャッチします。

- [Error Boundary](https://ja.reactjs.org/docs/error-boundaries.html)

## cloudwatch

AWSはCloudWatch Logsというロギングのサービスを提供しているので、こちらを利用します。

- [Amazon CloudWatch Logsとは](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/WhatIsCloudWatchLogs.html)

# 最終的な完成形

ログイン機能からログイン後の画面にて、わざとエラーを仕込んで落とした場合の挙動を確認します。

## before

### 画面

![before.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/090de8ef-9775-1fc8-c6ac-f646f3481b52.gif)


- `occure Error`ボタンを押したあと、エラーによって画面が真っ白（ホワイトアウト）になっているのがわかります。

## after

### 画面

![after.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/17769c0b-4adc-7f7a-0c1d-d6ca030c9bcd.gif)


- `occure Error`ボタンを押したあと、エラー用の画面に遷移させることができました。

### ログ

- ロググループ
  ![001_logGroup.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/5992252b-1218-7b0c-080e-d57dc5e4367f.png)


- ログストリーム
  ![002_logStream.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/9e4a85ac-14f7-360e-4c63-dc6c81541a03.png)


- ログ詳細
  ![003_logDetail.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/1c74c6fb-9197-c4dd-cb2c-7bb861740240.png)


エラーになった時のログがCloudwatchに出力できています。

# 使ったライブラリ・バージョン

viteで構築し、Typescriptベースで実装しました。

| ライブラリ              | バージョン |
| ----------------------- | ---------- |
| `@aws-amplify/ui-react` | `^4.3.3`   |
| `aws-amplify`           | `^4.3.10`  |
| `aws-amplify-react`     | `^5.1.9`   |
| `react-error-boundary`  | `^3.1.4`   |
| `vite`                  | `^4.0.0`   |

# 実装

## ベース構築

- viteでベースとなるReactを構築

```bash
yarn create vite

✔ Select a framework: › React
✔ Select a variant: › TypeScript
```

- package install

```bash
yarn add @aws-amplify/ui-react aws-amplify aws-amplify-react react-error-boundary
```

- ログイン画面とログイン後画面の実装

  - `App.tsx`の元々の画面を`pages/Home.tsx`として利用します

- App.tsx
  - Amplify + cognitoでログイン画面周りを作る実装の詳細は検索すれば色々と記事が出てくるのでそちらを参照（[このあたり](https://dev.classmethod.jp/articles/implement-amazon-cognito-authentication-in-react-apps-using-the-amplify-ui/)とか）

```tsx:App.tsx
import { Authenticator } from "@aws-amplify/ui-react";
import "@aws-amplify/ui-react/styles.css";
import { Amplify, Auth } from "aws-amplify";
import moment from "moment";
import { useEffect } from "react";
import Home from "./pages/home";

const App: React.FC = () => {
  useEffect(() => {
    Amplify.configure({
      Auth: {
        identityPoolId: // cognito identityId,
        region: // リージョン,
        userPoolId: // cognito ユーザープールID,
        userPoolWebClientId: // cognito クライアントID,
      },
    });
  }, []);

  return (
    <Authenticator hideSignUp={true}>
      <Home />
    </Authenticator>
  );
};

export default App;
```

- pages/home.tsx
  - `App.tsx`の元々の実装の使い回し

```tsx:home.tsx
import { useState } from "react";
import reactLogo from "../assets/react.svg";
import "../App.css";

const Home = () => {
  const [count, setCount] = useState(0);

  return (
    <div className="App">
      <div>
        <a href="https://vitejs.dev" target="_blank">
          <img src="/vite.svg" className="logo" alt="Vite logo" />
        </a>
        <a href="https://reactjs.org" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      <h1>Vite + React</h1>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>
        <p>
          Edit <code>src/App.tsx</code> and save to test HMR
        </p>
      </div>
      <p className="read-the-docs">
        Click on the Vite and React logos to learn more
      </p>
    </div>
  );
};

export default Home;
```

この時点でログイン画面と、ログイン後の画面が実装できました。

## Error-boundary

ErrorBoundaryの実装については、ErrorBoundary機能のラッパーライブラリでシンプルに実装できる[react-error-boundary](https://www.npmjs.com/package/react-error-boundary)を使います。

- pages/error.tsx
  - まずエラー時に遷移させる先の画面を作ります。

```tsx:error.tsx
import { FallbackProps } from "react-error-boundary";

const ErrorFallback = ({ error }: FallbackProps) => {
  return (
    <div>
      <h1>Error-boudanry</h1>
      <p>{error.message}</p>
      <button onClick={() => window.location.reload()}>reload</button>
    </div>
  );
};

export default ErrorFallback;
```

- pages/home.tsx
  - ErrorBoundaryの設定と、わざとErrorを発生させるボタンを追加実装します。

```diff_tsx:home.tsx
import { useState } from "react";
import reactLogo from "../assets/react.svg";
import "../App.css";
+ import { ErrorBoundary } from "react-error-boundary";
+ import ErrorFallback from "./error";

const Home = () => {
  const [count, setCount] = useState(0);
+  const [isError, setError] = useState(false);

+  const onError = (error: Error, info: { componentStack: string }) => {
+    console.log("Error boundary", error.message);
+    console.log("Error boundary", info.componentStack);
+  };


  return (
    <div className="App">
+      <ErrorBoundary FallbackComponent={ErrorFallback} onError={onError}>
        <div>
          <a href="https://vitejs.dev" target="_blank">
            <img src="/vite.svg" className="logo" alt="Vite logo" />
          </a>
          <a href="https://reactjs.org" target="_blank">
            <img src={reactLogo} className="logo react" alt="React logo" />
          </a>
        </div>
        <h1>Vite + React</h1>
        <div className="card">
          <button onClick={() => setCount((count) => count + 1)}>
            count is {count}
          </button>
          <p>
            Edit <code>src/App.tsx</code> and save to test HMR
          </p>
+          <button onClick={() => setError((preError) => !preError)}>
+            occure Error
+          </button>
+          {isError ? (
+            <>
+              <p>エラーを起こす</p>
+              <ThrowError />
+            </>
+          ) : (
+            <></>
+          )}
        </div>
        <p className="read-the-docs">
          Click on the Vite and React logos to learn more
        </p>
+      </ErrorBoundary>
    </div>
  );
};

export default Home;

+ function ThrowError(): JSX.Element {
+   throw new Error("manual throw Error");
+ }

```

修正後はエラー画面に遷移するようになります
![after.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/380901b6-af8d-17e7-c3f7-f13d579709cf.gif)


## cloudwatch へのログ出力

cloudwatchへのログの出力はAmplifyの機能を使って実装します。
cognitoに紐付いているIAMロールにcloudwatchへログを書き込む権限を与えるのも忘れないようにします。

### 実装

- pages/home.tsx
  - Error発生時(`onError`発火時)に、cloudwatchへログを書き込むように処理を追加します。

```diff_tsx:home.tsx
import { useState } from "react";
import reactLogo from "../assets/react.svg";
import "../App.css";
import { ErrorBoundary } from "react-error-boundary";
import ErrorFallback from "./error";
+ import {
+   Logger as AmplifyLogger,
+   AWSCloudWatchProvider,
+   Auth,
+   Amplify,
+ } from "aws-amplify";

const Home = () => {
  const [count, setCount] = useState(0);
  const [isError, setError] = useState(false);

  const onError = (error: Error, info: { componentStack: string }) => {
    console.log("Error boundary", error.message);
    console.log("Error boundary", info.componentStack);

+    const awsLogger = new AmplifyLogger("log-prefix", "INFO");
+    Amplify.register(awsLogger);
+    awsLogger.addPluggable(new AWSCloudWatchProvider());
+    awsLogger.error("Error boudary", error.message, info.componentStack);
  };

  return (
    <div className="App">
      <ErrorBoundary FallbackComponent={ErrorFallback} onError={onError}>
        <div>
          <a href="https://vitejs.dev" target="_blank">
            <img src="/vite.svg" className="logo" alt="Vite logo" />
          </a>
          <a href="https://reactjs.org" target="_blank">
            <img src={reactLogo} className="logo react" alt="React logo" />
          </a>
        </div>
        <h1>Vite + React</h1>
        <div className="card">
          <button onClick={() => setCount((count) => count + 1)}>
            count is {count}
          </button>
          <p>
            Edit <code>src/App.tsx</code> and save to test HMR
          </p>
          <button onClick={() => setError((preError) => !preError)}>
            occure Error
          </button>
          {isError ? (
            <>
              <p>エラーを起こす</p>
              <ThrowError />
            </>
          ) : (
            <></>
          )}
        </div>
        <p className="read-the-docs">
          Click on the Vite and React logos to learn more
        </p>
      </ErrorBoundary>
    </div>
  );
};

export default Home;

function ThrowError(): JSX.Element {
  throw new Error("manual throw Error");
}
```

`AmplifyLogger`の第2引数で、ログのレベル以降を Cloudwatchに出力するかを設定することができます。
今回の例だと`INFO`を設定しているので、`INFO`、`WARN`、`ERROR`のログがCloudwatch側にも出力されます。

- App.tsx
  - Cloudwatchへの出力のためのAmplifyの設定を追加します。

```diff_tsx:App.tsx
import { Authenticator } from "@aws-amplify/ui-react";
import "@aws-amplify/ui-react/styles.css";
import { Amplify, Auth } from "aws-amplify";
import moment from "moment";
import { useEffect } from "react";
import Home from "./pages/home";

const App: React.FC = () => {
  useEffect(() => {
    Amplify.configure({
      Auth: {
        identityPoolId: // cognito identityId,
        region: // リージョン,
        userPoolId: // cognito ユーザープールID,
        userPoolWebClientId: // cognito クライアントID,
      },
+      Logging: {
+        // ログの種類ごと
+        logGroupName: `/error-boundary/testlogging`,
+        // ログを出力するアプリケーションのインスタンスごと
+        logStreamName: '/hoge',
+        region: poolConf.region
+      },
    });
  }, []);

  return (
    <Authenticator hideSignUp={true}>
      <Home />
    </Authenticator>
  );
};

export default App;
```

- `logGroupName`で Cloudwatch上にログを出力する際のロググループを、`logStreamName`でログストリーム名を設定しています。
- Loggingの中に`region`を設定しないと以下のようなエラーが出て動作しませんでした。

  ```
  react_devtools_backend.js:4012 [ERROR] 06:03.501 AWSCloudWatch - error getting log group - Error: Region is missing
  react_devtools_backend.js:4012 [ERROR] 06:03.502 AWSCloudWatch - failure during log group search: Error: Region is missing
  react_devtools_backend.js:4012 [ERROR] 06:03.502 AWSCloudWatch - failure while getting next sequence token: Error: Region is missing
  react_devtools_backend.js:4012 [ERROR] 06:03.503 AWSCloudWatch - error during _safeUploadLogEvents: Error: Region is missing
  react_devtools_backend.js:4012 [ERROR] 06:03.503 AWSCloudWatch - error when calling _safeUploadLogEvents in the timer interval - Error: Region is missing
  ```

### AWS 側の設定

cognitoに紐付いているIAMロールにCroudwatchへの書き込み権限を与えます。

- cognito のユーザープールのアプリケーションクライアントの設定
  ![004_cognito.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/eac4aa77-5e43-db61-8efe-8e4cdc74f138.png)


- 設定されているアプリケーションクライアントと紐付いているIDプール
  ![005_identityPool.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/8789ab96-5fca-d592-e869-9293ad5395c2.png)


- `認証されたロール`で設定している IAM ロールに追加でポリシーをアタッチする
  ![006_iam_policy.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/7d353a18-d568-8204-5fb5-4e9f1e47c551.png)


  - アタッチするポリシー

    ```json
    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Sid": "VisualEditor0",
          "Effect": "Allow",
          "Action": [
            "logs:CreateLogStream",
            "logs:DescribeLogGroups",
            "logs:DescribeLogStreams",
            "logs:CreateLogGroup",
            "logs:PutLogEvents"
          ],
          "Resource": "*"
        }
      ]
    }
    ```

# まとめ

- Reactの中でエラーが発生した場合に自動的にキャッチしてエラー画面に遷移すること。
- エラー画面に遷移した場合にエラーの内容をCloudwatchへ連携すること。

が実装できました。

このCloudwatchに関する実装は、今回はエラーの部分だけに適用しましたが、
例えばユーザのボタンクリックなどの操作毎や、画面遷移毎といったユーザ操作のトラッキングを目的としてのCloudwatchへの出力にも応用できます。
`console.log`を使うように、簡単にSPAでユーザ操作のトラッキングが行うことができるので中々良いと思います。

# 参考

- [AWS を用いたフロントエンドモニタリング入門](https://speakerdeck.com/tamizuma/awswoyong-itahurontoentomonitarinkuru-men)
- [Amplify でウェブフロントエンドのログを CloudWatch に送る](https://zenn.dev/jaga/articles/amplify-logger-with-cwlogs)
- [AWS Amplify で CloudWatchLogs にカジュアルにログを送りたい](https://qiita.com/jacoyutorius/items/3cbed0075125905345a9)
- [Error: Region is missing の解決に参考になった issue](https://github.com/aws-amplify/amplify-js/pull/8309#issuecomment-946212409)