0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.jsでAsyncLocalStorageを用いたリクエストログ出力(その2:高階関数によるコンテキスト管理・ログ出力の共通化)

0
Posted at

京セラコミュニケーションシステム株式会社 先端技術統括部 開発イノベーション部 開発イノベーション2課の今村です。
前回から大分期間が空いてしまいましたが、Next.jsを用いたWebシステム開発に関するTipsの続きを執筆いたします。

はじめに

前回の記事(その1)では、AsyncLocalStorage(以下、ALS)を利用してリクエストごとにユニークな requestId をログへ割り当てる基礎的な仕組みを構築しました。

しかし、前回の実装のままでは ハンドラー(GET/POSTなどのAPIルート)ごとにALSのコンテキスト生成・run メソッドの呼び出し・UUID採番などのボイラープレートコードを書く必要 がありました。これでは記述漏れが発生しやすく、保守性も低下してしまいます。

本記事では、ALS周りのロジックを安全にコンテキスト化し、高階関数(Higher-Order Function: HOF)を用いて、ボイラープレートコード部分をラッピングすることで、 各エンドポイントでわずか数行呼び出すだけで自動的にリクエストログコンテキストが有効化される仕組み へリファクタリングします。

さらに記事の後半では、高階関数内部での処理フローや context 引数の役割といったメカニズム についても解説します。

次回予告  次回(その3)では、初心者にはわかりにくい高階関数から、TypeScriptの デコレーター(Decorator) にリファクタリングすることで、メソッドに付与するだけでコンテキストとログを自動挿入するスマートな記述方法を紹介する予定です。

対象読者

  • Node.js / Next.js (TypeScript) でリクエストログの共通化を行いたい方
  • AsyncLocalStorage の実務的なラッパー構成・高階関数の使い方を知りたい方
  • Route Handler における高階関数の内部動作を理解したい方

前提条件

今回の記事にのせているコードは、以下の環境で動作確認しています。

バージョン
Node 26.10.0
Next.js 16.3.6
pino 10.1.0

課題の整理

前回の構成では、APIハンドラーごとに以下のようなコードを書く必要がありました。

【改善したいコード】

export async function GET(req: Request) {
  const requestId = crypto.randomUUID();
  const store = { requestId, method: req.method, url: req.url };

  return requestStorage.run(store, async () => {
    logger.info("処理開始");
    // 実際のロジック...
  });
}

改善したいポイント

重複コードの排除: ID生成や storage.run() の呼び出しを1箇所に隠蔽したい。
アクセスログの自動化: リクエストの「開始」と「終了(レスポンスステータス / 処理時間)」のログ出力を共通処理として挟み込みたい。
動的ルーティングへの対応: /api/users/[id] などのパラメータ(context)も破棄せずに安全に扱いたい。


共通ラッパーの実装

まずはコンテキスト管理、ロガー、高階関数(HOF)を定義します。

1. リクエストコンテキストモジュール (lib/request-context.ts)

ALSインスタンス本体と、それを扱うユーティリティ関数をまとめます。

lib/request-context.ts
import { AsyncLocalStorage } from "node:async_hooks";

// コンテキスト内で保持するデータ型
export interface RequestContextStore {
  requestId: string;
  method: string;
  path: string;
  startTime: number;
}

// ALSインスタンス(外部には直接露出させずカプセル化する)
const asyncLocalStorage = new AsyncLocalStorage<RequestContextStore>();

/**

現在のリクエストコンテキストを取得
 */
export function getRequestContext(): RequestContextStore | undefined {
  return asyncLocalStorage.getStore();
}

/**

リクエストコンテキストを生成して処理を実行する実行ラッパー
 */
export function runWithContext<T>(
  store: RequestContextStore,
  fn: () => Promise<T> | T,
): Promise<T> {
  return asyncLocalStorage.run(store, async () => await fn());
}

:bulb:Point:bulb:
getRequestContext 関数を用意することで、ロジック上で現在実行中のリクエストで使用するstoreをコンテキストとして取得するこをわかりやすくします。


2. ロガー側の共通化 (lib/logger.ts)

ロガー呼び出し時に、手動で requestId を指定しなくてもALSから自動取得して出力情報に含める処理を実装します。

lib/logger.ts
import pino from "pino";
import { getRequestContext } from "./request-context";

export const logger = pino({
  level: process.env.LOG_LEVEL || "info",
  timestamp: pino.stdTimeFunctions.isoTime,
  mixin() {
    // ログ出力時に自動実行され、ALSから現在のコンテキスト情報を付与する
    const ctx = getRequestContext();
    if (!ctx) return {};

    return {
      requestId: ctx.requestId,
      method: ctx.method,
      path: ctx.path,
    };
  },
  formatters: {
    level: (label: string) => {
      return {
        level: label,
      };
    },
  },
});

:bulb:Point:bulb:
Pinoの mixin オプションを使用することで、logger.info(...) を呼ぶたびに暗黙的に現在の requestId や path がログのJSONフィールドに追加されます。


3. APIハンドラー用高階関数 (HOF) の作成 (lib/with-request-context.ts)

Route Handler を包み込み、コンテキスト設定・ログ出力・処理時間計測を行うラッパー関数を作成します。

lib/with-request-context.ts
import { runWithContext } from "./request-context";
import { logger } from "./logger";
import { NextRequest } from "next/server";

type RouteHandler<TContext = unknown> = (
  req: NextRequest,
  context: TContext,
) => Promise<Response> | Response;

/**

Route Handlerをリクエストコンテキストで包む高階関数
 */
export function withRequestContext<TContext>(
  handler: RouteHandler<TContext>,
): RouteHandler<TContext> {
  return async (req: NextRequest, context: TContext) => {
    const url = new URL(req.url);
    const requestId = crypto.randomUUID();

    const store = {
      requestId,
      method: req.method,
      path: url.pathname,
      startTime: Date.now(),
    };

    return runWithContext(store, async () => {
      logger.info({ msg: "HTTP Request Handling Started" });

      try {
        // 元のハンドラーに req と context(params等の情報)をそのまま渡す
        const response = await handler(req, context);
        const duration = Date.now() - store.startTime;

        logger.info({
          msg: "HTTP Request Handling Finished",
          status: response.status,
          durationMs: duration,
        });

        return response;
      } catch (error) {
        const duration = Date.now() - store.startTime;
        logger.error({
          msg: "HTTP Request Handling Failed",
          error: error instanceof Error ? error.message : String(error),
          durationMs: duration,
        });
        throw error;
      }
    });
  };
}

:bulb:Point:bulb:
handler(req, context) として、高階関数に受け渡された引数をそのまま渡すことで、実装ロジックは、高階関数の処理を意識することなく機能付加することができます。


使い方(APIルートでの利用例)

準備が整ったので、実際の API ルート(Route Handler)で使用してみます。

app/api/users/[id]/route.ts
import { NextResponse } from "next/server";
import { withRequestContext } from "@/lib/with-request-context";
import { logger } from "@/lib/logger";

async function fetchUserData(userId: string) {
  // 深い階層の関数内でも requestId を意識せずにログを出力可能
  logger.info(`DBからユーザー情報を取得中... ID: ${userId}`);
  return { id: userId, name: "Alice" };
}

export const GET = withRequestContext(
  async (req: Request, context: RouteContext<"/api/users/[id]">) => {
    const { id } = await context.params;
    logger.info(`ユーザー取得APIが呼び出されました (Target ID: ${id})`);

    const user = await fetchUserData(id);

    return NextResponse.json({ success: true, data: user });
  },
);

:bulb:Point:bulb:
公開するGETメソッドに、 高階関数からの戻り関数を代入することで、ラッパー関数込みの処理を実行させます。

実行結果(ログ出力例)

リクエストを送信すると、すべてのログに requestId・method・path が統一フォーマットで付与され、リクエストの開始・完了(処理時間)が自動的に記録されます。

{"level":"info","time":"2026-09-29T00:45:39.178Z","pid":3912,"hostname":"d3e708e840aa","requestId":"b04eff2d-d5ef-49c2-b2b1-275ecc6de07b","method":"GET","path":"/api/users/xyz","msg":"HTTP Request Handling Started"}
{"level":"info","time":"2026-09-29T00:45:39.180Z","pid":3912,"hostname":"d3e708e840aa","requestId":"b04eff2d-d5ef-49c2-b2b1-275ecc6de07b","method":"GET","path":"/api/users/xyz","msg":"ユーザー取得APIが呼び出されました (Target ID:xyz)"}
{"level":"info","time":"2026-09-29T00:45:39.180Z","pid":3912,"hostname":"d3e708e840aa","requestId":"b04eff2d-d5ef-49c2-b2b1-275ecc6de07b","method":"GET","path":"/api/users/xyz","msg":"DBからユーザー情報を取得中... ID: xyz"}
{"level":"info","time":"2026-09-29T00:45:39.182Z","pid":3912,"hostname":"d3e708e840aa","requestId":"b04eff2d-d5ef-49c2-b2b1-275ecc6de07b","method":"GET","path":"/api/users/xyz","msg":"HTTP Request Handling Finished","status":200,"durationMs":5}

処理フローと内部メカニズム徹底解説

ここからは、「withRequestContext の中で一体何が起きているのか?」「なぜ関数の引数で渡さなくても requestId が引き継がれるのか?」 という仕組みについて解説します。

全体シーケンス図

クライアントからのリクエスト受信からレスポンス返却までの全体像は以下のようになります。

[クライアント] 
    │ HTTP Request
    ▼
[withRequestContext (高階関数)] ─────── ① コンテキスト生成・ALSの初期化
    │
    ├─► Pino Logger ────────────────── ② 開始ログ出力 (requestIdを自動付与)
    │
    ├─► AsyncLocalStorage (Context)
    │      │
    │      ├─► GET ハンドラー ───────── ③ メイン処理実行 (req, context を受領)
    │      │      │
    │      │      └─► fetchUserData() ── ④ 深い階層でのログ出力 (ALSから暗黙取得)
    │      │
    │      └─► Response 生成
    │
    ├─► Pino Logger ────────────────── ⑤ 終了ログ出力 (ステータス・処理時間記録)
    ▼
[クライアント] ◄────────────────────── レスポンス返却

1. なぜ引数を渡さなくても requestId が追跡できるのか?

Node.js の AsyncLocalStorage は、非同期の実行コンテキスト(Promise のチェーンや async/await の呼び出しツリー)にデータを紐付けて保持します。

withRequestContext 内で runWithContext(store, ...) を呼び出すと、そのコールバック関数の中で実行されるすべての非同期処理(Service層やRepository層など深い関数も含む)が 同じコンテキスト空間 で動作します。

ロガー(Pino)呼び出し時に mixin 内で getRequestContext() を呼ぶことで、引数で requestId をバケツリレーすることなく、現在実行中のリクエストの store で保持されている withRequestContext 内で生成した requestId へアクセスできます。


2. withRequestContext が戻り値として返すラッパー関数の第2引数 context の役割

ラッパー関数のシグネチャに存在する context 引数は、非常に重要な役割を果たしています。

export function withRequestContext<TContext>(
  handler: RouteHandler<TContext>,
): RouteHandler<TContext> {
  return async (req: NextRequest, context: TContext) => { // ← ここで受け取る
    // ...
    return runWithContext(store, async () => {
      return await handler(req, context); // ← 元のハンドラーにスルーパスする
    });
  };
}

Next.js App Router の動的ルーティング(/api/users/[id] など)では、第2引数(context.params)経由で URL パラメータが渡されます。

高階関数(HOF)でラップする際、この context を受け取って handler(req, context) のように横流し(スルーパス)しないと、元のハンドラー側で params が受け取れなくなってしまいます。

withRequestContext 自体が直接 context を加工していなくても、Next.js と各ハンドラー間の「リレー役」としてこの受け渡し処理が必須となります。


まとめ

今回の改善によって、以下のメリットが得られました。

APIハンドラーの記述を簡略化: withRequestContext(...) で囲むだけでコンテキスト生成・ログ自動挿入が完了。
共通アクセスログの集約: 開始・終了ログや処理時間の記録を1箇所で集中管理。
パラメータの安全な透過: context 引数をリレーすることで動的ルート(params)にもそのまま対応。


次回予告

高階関数でラップすることで大幅にスッキリしましたが、クラスベースの設計やオブジェクト指向アプローチをとる場合、「関数の実行前後で自動的にコンテキストを操作したい」「メソッドにアノテーションを1行貼るだけで済ませたい」 というニーズが出てきます。

次回の記事(その3)では、TypeScriptの デコレーター(Decorator)機能 を活用し、よりスマートにリクエストログ追跡を実現する方法を解説します!


所属部署について

隼人事業所

隼人事業所は、工場の中に事務所があるという利点を活かし、ものづくりの現場に貢献できるシステム開発に取り組んでいます。
IT技術を活用して、新しい価値を創出をしていくことを目指しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?