booklista tech blog

booklista のエンジニアリングに関する情報を公開しています。

Next.jsのブラウザーエラーログを収集するためのNew Relic導入方法

アイキャッチ

株式会社ブックリスタ プロダクト開発部エンジニアの土屋です。 現在、「Reader Store(運営:株式会社ソニー・ミュージックエンタテインメント)」におけるフロントエンド領域の刷新に取り組んでいます。 今回、表題の通りブラウザーエラーログ収集を目的とし、Next.jsにNew Relicを導入したため、その経緯と導入方法についてまとめました。

New Relicについて

公式サイトから引用。

New Relicは、モバイルやブラウザのエンドユーザーモニタリングや、外形監視、バックエンドのアプリケーションとインフラモニタリングなど、オンプレやクラウド、コンテナからサーバレスまであらゆるシステム環境での性能管理を実現するプラットフォームです。

New Relicは上記の通りアプリケーション監視の多岐にわたる機能を備えているサービスです。 Reader Storeではサーバーのトラフィックや性能監視のために以前から利用しています。

導入経緯

現在、Reader Storeではレガシーなフロントエンド環境(PHP/Java+JQuery)からNext.jsへ置き換えを進めています。その中でブラウザーのエラーログを収集したいという要件がありました。 ブラウザーエラーを収集することで、不具合の早期発見や、特定のOS/ブラウザーでのみ発生するような不具合の解析に役立ちます。 以上の理由から今回、ブラウザーエラー収集の機能を持つNew Relicブラウザーモニタリングを導入しました。 Datadog, Sentry等ブラウザーエラーの収集ができるサービスは他にもいくつかあります。New RelicはAPIサーバー等の監視で既に導入済みのため、管理を一元化できることから選択しています。

導入方法

New Relicブラウザーモニタリングの導入方法は、コピー&ペースト方式とAPMエージェントを利用する方式があります。
https://newrelic.com/jp/blog/how-to-relic/what-is-the-difference-between-apm-agent-and-copy-paste

  • コピー&ペースト方式

    名前の通りNew Relicが提供するjsコードスニペットをそのままコピー&ペーストする方式です。

  • APMエージェント利用方式

    サーバーサイドでAPMエージェントを利用するためのスクリプトを生成し、それをhtmlに埋め込みます。APMエージェントの接続先を環境変数等で指定できるメリットがあります。

コピー&ペースト方式はデプロイする環境ごとにコードスニペットを用意しなければならず、管理が煩雑になるため、APMエージェントを利用する方式を選びました。 すこし前までは、APMエージェントを利用する方式だとSSGで生成したページに適用できず、静的ページには別途コピー&ペースト方式で導入する必要がありましたが、 v9.8.0からSSGにも対応しました。 そのため初期導入のしやすさを除いてコードスニペット埋め込み方式を選ぶメリットは無くなっています。 https://docs.newrelic.com/jp/docs/release-notes/agent-release-notes/nodejs-release-notes/node-agent-9-8-0/

この記事ではnode.js向けパッケージ(newrelic)を利用してAPMエージェントをインストールします。 @newrelic/nextというパッケージがありますが、こちらはNext.jsサーバーのパフォーマンスを監視するものになり、この記事では触れません。

前提条件

  • New Relicのアカウントが作成してある
  • newrelicパッケージのv9.12時点での導入方法

導入手順

  1. New Relicでブラウザーモニタリングの機能を有効にします

    https://docs.newrelic.com/jp/docs/browser/browser-monitoring/installation/install-browser-monitoring-agent/

  2. Next.jsプロジェクトにnewrelicパッケージをインストール

     npm i newrelic
    
  3. _document.tsxでnewrelicのスクリプトを埋め込む

    _document.tsxは主に共通のメタタグを埋め込むために利用します。以下はnewrelicのスクリプトを埋め込むサンプルです。

     const newrelic = require("newrelic");
    
     type ExtendedDocumentProps = DocumentInitialProps & {
       browserTimingHeader: string;
     };
    
     class MyDocument extends Document<ExtendedDocumentProps> {
       static getInitialProps = async (
         ctx: DocumentContext
       ): Promise<ExtendedDocumentProps> => {
         const initialProps = await Document.getInitialProps(ctx);
    
         // see https://github.com/newrelic/newrelic-node-nextjs#client-side-instrumentation
         if (!newrelic.agent.collector.isConnected()) {
           await new Promise((resolve) => {
             newrelic.agent.on("connected", resolve);
           });
         }
    
         const browserTimingHeader = newrelic.getBrowserTimingHeader({
           hasToRemoveScriptWrapper: true,
           allowTransactionlessInjection: true,
         });
    
         return {
           ...initialProps,
           browserTimingHeader,
         };
       };
    
       render = () => {
         const { browserTimingHeader } = this.props;
    
         return (
           <Html lang="ja">
             <Head>
               <script
                 type="text/javascript"
                 dangerouslySetInnerHTML={{ __html: browserTimingHeader }}
               />
             </Head>
             <body>
               <Main />
               <NextScript />
             </body>
           </Html>
         );
       };
     }
    
     export default MyDocument;
    

    以下の部分でAPMエージェントを読み込むためのスクリプトを取得しています。

     const browserTimingHeader = newrelic.getBrowserTimingHeader({
       hasToRemoveScriptWrapper: true,
       allowTransactionlessInjection: true,
     });
    
    • hasToRemoveScriptWrapperをtrueにすることで、scriptタグを除いた状態で取得できるのでhead内のscriptタグの中に埋め込む
    • allowTransactionlessInjectionはv9.8.0から追加された新しいオプションで、これをtrueにすることでSSGのタイミングでもスクリプトが生成される

    allowTransactionlessInjectionオプションを有効にする場合はnewrelic.getBrowserTimingHeaderを呼び出す前に、以下のようにNew Relicエージェントとnode.jsのコネクションが確立するまで待つ必要があります。

     if (!newrelic.agent.collector.isConnected()) {
       await new Promise((resolve) => {
         newrelic.agent.on("connected", resolve);
       });
     }
    
  4. 環境変数設定

    newrelicに必要な環境変数を.envに定義します。

    アプリ名は1の手順で有効にしたアプリの名前、ライセンスキーはこちらのライセンスキーになります。

     NEW_RELIC_APP_NAME={アプリ名}
     NEW_RELIC_LICENSE_KEY={ライセンスキー}
    

以上でAPMエージェントが利用できるようになり、コアウェブバイタルなどの測定が可能になりました。

しかし、これだけではjsエラーログを収集するには不十分です。実際にブラウザー側に配信されるjsはバンドルされたものであるため、トレースログを表示するにはsourcemapをNew Relicに連携する必要があります。

sourcemapの連携

Next.jsの場合、next buildを行うと、通常.next/static/chunks配下にsourcemapが生成されるため、それをNew Relicにアップロードします。

以下がNew Relicにsourcemapをアップロードする際に利用しているスクリプトのサンプルです。

const fs = require("fs");
const path = require("path");

const {
  publishSourcemap,
  deleteSourcemap,
  listSourcemaps,
} = require("@newrelic/publish-sourcemap");
const recursive = require("recursive-readdir");

// 以下に記載のブラウザーアプリIDを設定
// https://docs.newrelic.com/jp/docs/apis/rest-api-v2/get-started/get-app-other-ids-new-relic-one/
const NR_APP_ID = /* ブラウザーアプリID */;

// 以下に記載のブラウザーキーを設定
// https://docs.newrelic.com/jp/docs/apis/intro-apis/new-relic-api-keys/
const NR_API_KEY = /* APIキー */;

// srcmapの配置ディレクトリー
const SRC_MAP_DIR = "path/to/.next/static/chunks";
// jsファイルを配信する際のベースURL
const STATIC_FILE_BASE_URL = "https://yourdomain/_next/static/chunks";

const SRC_MAP_CHUNK_SIZE = 50;

// newrelic上のソースマップを全件取得
const listSrcMap = (list = [], offset = 0) => {
  return new Promise((resolve, reject) => {
    listSourcemaps(
      {
        applicationId: NR_APP_ID,
        apiKey: NR_API_KEY,
        limit: SRC_MAP_CHUNK_SIZE,
        offset: offset * SRC_MAP_CHUNK_SIZE,
      },
      (err, res) => {
        if (err) return reject(err);

        const allMaps = [...list, ...res.sourcemaps];

        // 取得した件数がlimit未満なら抜ける
        if (res.sourcemaps.length < SRC_MAP_CHUNK_SIZE) {
          return resolve(allMaps);
        }

        // limitと同値ならこのメソッドを再起的に呼びだす
        listSrcMap(allMaps, offset + 1)
          .then((list) => resolve(list))
          .catch((e) => reject(e));
      }
    );
  });
};

// newrelic上のソースマップ削除
const deleteSrcMap = (srcMaps) => {
  return Promise.all(
    srcMaps.map((srcMap) => {
      return new Promise((resolve, reject) => {
        deleteSourcemap(
          {
            sourcemapId: srcMap.id,
            applicationId: NR_APP_ID,
            apiKey: NR_API_KEY,
          },
          (err, res) => {
            console.log(err || `Sourcemap ${srcMap.javascriptUrl} deleted.`);
            if (err) return reject(err);
            resolve();
          }
        );
      });
    })
  );
};

const IGNORE_FILES = ["*.js"];
// newrelic上にソースマップ登録
const uploadSrcMap = () => {
  recursive(SRC_MAP_DIR, IGNORE_FILES, (err, files) => {
    // mapファイル以外のものは無視
    const mapFiles = files.filter((file) => file.endsWith(".js.map"));

    mapFiles.forEach((file) => {
      // scriptタグのsrc属性に設定されるjsリソースパス
      const jsFileSrcUrl = `${STATIC_FILE_BASE_URL}/${path.relative(
        SRC_MAP_DIR,
        file
      )}`.slice(0, -4);

      publishSourcemap(
        {
          sourcemapPath: file,
          javascriptUrl: jsFileSrcUrl,
          applicationId: NR_APP_ID,
          apiKey: NR_API_KEY,
        },
        (err) => {
          console.log(err || `Sourcemap ${jsFileSrcUrl} upload done.`);

          // mapファイルは公開しないため削除
          fs.unlinkSync(file);
        }
      );
    });
  });
};

listSrcMap().then(deleteSrcMap).then(uploadSrcMap);
  • @newrelic/publish-sourcemapパッケージを利用し、sourcemapをアップロードしている
  • アップロード最大容量の制限(50MB)があるため、事前に古いsourcemapを削除している

Reader StoreではAWSのcodebuildを使ってNext.jsアプリのビルドを行う際、上記スクリプトを実行してsourcemapを更新するようにしています。 アップロード手段についてはcurlでapiエンドポイントを直接叩いたりnpmスクリプトを利用する方法が提供されているため、運用に合わせてどうアップロードするかを選んでください。
https://docs.newrelic.com/jp/docs/browser/new-relic-browser/browser-pro-features/upload-source-maps-api/

以上でNew Relicのブラウザーエラー収集の準備は完了です。 ブラウザーでエラーが発生した際は、New Relic管理画面 > Browser > JS Errorsにエラーが表示され、Error Instancesタブから以下のように発生箇所がトレースできるようになります。

トレースログ

苦労したところ

  • 必要のない大量のエラーを拾ってしまう

    本番環境で運用したところ、Google Tag Managerで読み込んでいるサードパーティjsなどで大量のエラーが発生しており、Next.jsアプリのエラーを見つけにくい状態になっていました。

    そのためNext.jsアプリで発生したエラーのみ一覧できるよう、以下のnrqlを作成し外部スクリプト起因のエラーをフィルターした状態のダッシュボードを作成しました。

    ダッシュボードであれば無料のアカウントでも参照できるため、まずはこのダッシュボードでエラーが発生していないか確認する運用をしています。

      SELECT
          * 
      FROM
          JavaScriptError 
      WHERE 
          errorMessage != 'Script error.' 
          AND 
          stackTrace != ''
      SINCE 7 day ago
    
    • ErrorBoundaryでエラーをキャッチし、newrelicのnoticeError APIを使ってエラーを送付することでアプリのエラーだけ拾う方法も考えられます。ErrorBoundaryで拾えるのはレンダリング時のエラーだけでイベントハンドラのエラーは拾えないという問題がありReader Storeでは採用しませんでした
  • エラーを拾えているのかわからない

    現在Reader StoreでNext.jsを利用しているページはごく僅かで、開発規模もそこまで大きくないため、現状プロダクトコード起因のエラーが発生していません。それによりエラーが発生していないのか拾えていないのかわからない状態になってしまいました。 対策として意図的にエラーを発生させるhooksを作成し、エラーが拾えるか各環境で確認しています。

      // 特定のコマンドを入力した際にエラーを発生させるhookの例
      const COMMAND = [
        "ArrowUp",
        "ArrowUp",
        "ArrowDown",
        "ArrowDown",
        "ArrowLeft",
        "ArrowRight",
        "ArrowLeft",
        "ArrowRight",
        "a",
        "b",
      ];
      export const useTestError = () => {
        useEffect(() => {
          let typedKeys: string[] = [];
          const clear = debounce(() => {
            typedKeys = [];
          }, 10000);
          const handler = (e: KeyboardEvent) => {
            typedKeys.push(e.key);
            if (
              JSON.stringify(typedKeys.slice(-COMMAND.length)) ===
              JSON.stringify(COMMAND)
            ) {
              typedKeys = [];
              throw new Error("TEST ERROR!!");
            }
            clear();
          };
          window.addEventListener("keyup", handler);
          return () => {
            window.removeEventListener("keyup", handler);
          };
        }, []);
      };
    

今後やっていきたいこと

  • 現状はダッシュボードを日々確認する運用だが、有意にエラーの数が増えたらslackに通知するような仕組みも入れて、不具合の早期発見に役立てていきたい
  • 今回はほとんど触れてないがNew RelicはAPMとしての機能が豊富なため、アプリのパフォーマンス測定と改善にも取り組んでいきたい