ReactRouter v7 SSR でバラバラになる CloudWatch ログを pino で一本化した

に公開

はじめに

こんにちは、ツクリンクでソフトウェアエンジニアをしているてつです。
今回はReactRouterで記録するCloudWatchログの改善についてのお話です。

ツクリンクの一部フロントエンドはReactRouter v7で稼働しています。そして本番環境のログは AWS CloudWatchで確認しています。
あるときエラー調査のためにログを見に行くと、複数行にまたがる1件のエラーが何十ものログエントリに分割されて記録されており、非常に見づらい状態になっていました。
この記事ではその解決方法を紹介します。

何が起きていたか

console.error(error) でエラーを出力すると、CloudWatch では次のようにスタックトレースが1行ずつ別のLogEventとして記録されます。

[ERROR] Error: Connection failed
[ERROR]   at Database.connect (db.js:42)
[ERROR]   at async loader (route.js:15)
[ERROR]   at async Promise.all (index 0)
...(残り 16 行も別エントリ)

20行のスタックトレース = 20個の独立したLogEventです。
CloudWatch Logs Insights でフィルタリングしても、エラーの前後の文脈がつかめず「このエラーはどのリクエストで発生したのか」が追えない状態でした。

なぜこうなるのか

CloudWatch Logs は改行ごとに1つのLogEventとして扱います。
Node.js の console.error はスタックトレースを複数行テキストで出力するため、CloudWatch 側でバラバラに記録されてしまいます。
また、ReactRouter v7 の SSR 構成では、ログの発生源が複数あります。

  • 自前コード — loader/actionの中で弊社で管理しているconsole.log
  • ReactRouter内部 — SSR 処理中のエラー出力
  • サードパーティライブラリ — 各種ライブラリが内部で呼ぶ console

これらすべてが改行区切りのテキストで出力されるため、CloudWatchでのログが分散してしまいました。

解決策の選定

解決策として、ECSで解決する方法とロギングライブラリpinoで解決する案が出ました。
pinoはNode.js向けのロギングライブラリです。色々と機能や特徴がありますが今回重要なのは、複数行にわたるログもデフォルトでJSONオブジェクトにまとめて出力してくれる点です。
ただし、 console.log() ではなく独自の関数 logger.xxx() を使う必要があります。
そこで以下3つの案を検討しました。

概要 判断
ECS の awslogs-multiline-pattern 正規表現でログをグルーピング ログフォーマットの統一が必要で改修コスト大 → 却下 ❌
全箇所を logger.xxx() に書き換え 自前コードを pino に統一 ライブラリ内部のログは制御できない → 効果が限定的 → 却下 ❌
console を pino で上書き entry.server.tsx の 1 カ所で初期化 ライブラリのログも含めてすべて JSON 化できる → 採用 ✅

entry.server.tsxはSSRのサーバーエントリポイントであり、すべてのサーバーサイド処理の前に1度だけ実行される場所です。ここでconsoleをグローバル上書きすれば、自前コード・フレームワーク内部・ライブラリのログをまとめて JSON 化できます。

実装

1. pino の導入

導入のコードは以下のような形です。

app/libs/logger/index.ts
import pino from "pino";

const isProduction = import.meta.env.MODE === "production";

export const logger = pino({
  level: process.env.LOG_LEVEL ?? "info",
  ...(isProduction
    ? {
        // 本番環境: JSON 形式(CloudWatch 向け)
        formatters: {
          level: (label) => ({ level: label }),
        },
        timestamp: pino.stdTimeFunctions.isoTime,
      }
    : {
        // 開発環境: 人間が読みやすい形式
        transport: {
          target: "pino-pretty",
          options: {
            colorize: true,
            translateTime: "SYS:standard",
            ignore: "pid,hostname",
          },
        },
      }),
});

開発環境ではpino-prettyでカラー付きの読みやすい形式を維持し、本番環境のみ JSON 出力に切り替えます。

2. console 引数の変換

pinoはlogger.info(bindings, message)の形式(オブジェクトが先)ですが、console.logはconsole.log("message", { key: val })と逆順で書きますので、変換用の関数を挟みます。

app/libs/logger/index.ts
export const formatConsoleArgs = (
  args: unknown[],
): [Record<string, unknown>, string] => {
  const bindings: Record<string, unknown> = {};
  const messages: string[] = [];

  for (const arg of args) {
    if (arg instanceof Error) {
      bindings.err = arg;           // Errorオブジェクトはerrキーに代入
      messages.push(arg.message);
    } else if (arg !== null && typeof arg === "object") {
      Object.assign(bindings, arg); // オブジェクトはbindingsにマージする
    } else {
      messages.push(String(arg));   // それ以外は文字列にする
    }
  }

  return [bindings, messages.join(" ")];
};

3. console の上書き

app/libs/logger/index.ts
export const initLogger = (): void => {
  if (!isProduction) {
    return; // 開発環境は何もしない
  }

  console.log = (...args: unknown[]) => {
    const [bindings, message] = formatConsoleArgs(args);
    logger.info(bindings, message);
  };

  console.info = (...args: unknown[]) => {
    const [bindings, message] = formatConsoleArgs(args);
    logger.info(bindings, message);
  };

  console.warn = (...args: unknown[]) => {
    const [bindings, message] = formatConsoleArgs(args);
    logger.warn(bindings, message);
  };

  console.error = (...args: unknown[]) => {
    const [bindings, message] = formatConsoleArgs(args);
    logger.error(bindings, message);
  };

  console.debug = (...args: unknown[]) => {
    const [bindings, message] = formatConsoleArgs(args);
    logger.debug(bindings, message);
  };
};

4. entry.server.tsx で初期化

app/entry.server.tsx
import { initLogger } from "~/libs/logger";

// サーバー起動時に 1 度だけ呼ぶ
// 本番環境では console.log/error/warn を pino にリダイレクトする
initLogger();

ここでinitLogger() を呼んだ後は、ReactRouter内部・ライブラリ・自前コードのすべてのconsole呼び出しが自動的にpino経由になります。

Before / After

Before: スタックトレースが行ごとに分割される

[ERROR] Error: Connection failed
[ERROR]   at Database.connect (db.js:42)
[ERROR]   at async loader (route.js:15)
...(各行が別の LogEvent)

After: エラー 1 件 = JSON 1 エントリ

{
  "level": "error",
  "time": "2026-03-04T10:00:00.000Z",
  "err": {
    "type": "Error",
    "message": "Connection failed",
    "stack": "Error: Connection failed\n  at Database.connect (db.js:42)\n  at async loader..."
  },
  "msg": "Connection failed"
}

CloudWatch Logs Insights でのクエリもシンプルに書けるようになりました。

-- エラーログの一覧
fields @timestamp, level, msg, err.message
| filter level = "error"
| sort @timestamp desc

-- JSON ログのみ抽出(pino 以外のログを除外したい場合)
fields @timestamp, level, msg
| filter ispresent(level)
| sort @timestamp desc

まとめ

以上のような形でCloudWatchでもログがどのリクエストによるものかを追えるようになりました。
調査していく中でECS側でハンドリングする案も出ましたが、結果としてプロジェクトのコード内でカスタム内容を把握できる形で改善できています。
いまのところログ調査の面でも困ることはないので、同様の悩みを持つ方の参考になれば嬉しいです。

Discussion