Article

🧵 Node.jsのAsyncLocalStorageでリクエストIDを伝播する:非同期ログ追跡の基本

nodejs, javascript, backend, logging

Node.jsのAPIでリクエストが同時に来ると、「DB取得」「レスポンス完了」といったログが混ざり、どのログが同じリクエストのものか追いにくくなります。

そこで役立つのがNode.js標準のAsyncLocalStorageです。リクエストID(HTTPリクエストを識別する値)を、awaitをまたいだ処理にも伝播できます。外部パッケージなしの実装例と、実務上の注意点を紹介します。

仕様確認日:2026年10月15日。Node.js公式の非同期コンテキスト、HTTP、crypto.randomUUIDを参照。実際に使うNode.jsの版に対応するドキュメントも確認してください。

グローバル変数でIDを持つと何が危険?

let currentId;

async function handleRequest(id) {
  currentId = id;
  await readDatabase();
  console.log(currentId);
}

リクエストAがcurrentId = "A"を設定して待機した後、リクエストBが"B"に書き換えると、Aのログにも"B"が出かねません。

AsyncLocalStorageが管理する非同期コンテキストは、非同期処理に紐づく値の入れ物です。処理AとBを別々の入れ物にすれば、awaitやPromiseをまたいでもそれぞれの値を取得できます。

基本のAPI:run()とgetStore()

import { AsyncLocalStorage } from "node:async_hooks";

const storage = new AsyncLocalStorage();

await storage.run({ requestId: "A" }, async () => {
  await Promise.resolve();
  console.log(storage.getStore().requestId); // A
});

console.log(storage.getStore()); // undefined
  • new AsyncLocalStorage():コンテキストを管理するインスタンスを作成します。
  • run(store, callback):callbackを指定したstore内で実行し、そこから作られた非同期処理にも値を関連付けます。
  • getStore():現在の非同期処理に紐づくstoreを取得します。コンテキストの外では通常undefinedです。

run()が同期的に戻っても、コールバック内から開始された対応する非同期処理では、値を引き続き取り出せます。

HTTPサーバーでの実装例

次のコードをserver.mjsとして保存します。.mjsはES Modules形式のJavaScriptファイルです。

import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";

const context = new AsyncLocalStorage();

function log(message, extra = {}) {
  console.log(JSON.stringify({
    time: new Date().toISOString(),
    requestId: context.getStore()?.requestId ?? null,
    message,
    ...extra,
  }));
}

async function loadUser() {
  // 非同期DBアクセスを想定した待機
  await new Promise((resolve) => setTimeout(resolve, 30));
  log("user loaded");
  return { id: "u1", name: "Sample" };
}

async function handle(req, res) {
  // クエリ文字列をそのまま記録しない
  const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
  log("request started", { method: req.method, pathname });

  if (req.method !== "GET" || pathname !== "/users/1") {
    res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
    res.end(JSON.stringify({ error: "not_found" }));
    return;
  }

  const user = await loadUser();
  log("request completed");
  res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
  res.end(JSON.stringify(user));
}

createServer((req, res) => {
  const requestId = randomUUID();
  context.run({ requestId }, () => {
    res.setHeader("x-request-id", requestId);
    void handle(req, res).catch((error) => {
      log("request failed", {
        error: error instanceof Error ? error.message : String(error),
      });
      if (res.headersSent) {
        res.destroy();
        return;
      }
      res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
      res.end(JSON.stringify({ error: "internal_error" }));
    });
  });
}).listen(3000, () => {
  console.log("listening on http://localhost:3000");
});

処理の要点は3つです。HTTPリクエストごとにrandomUUID()でIDを発行し、context.run()の中で処理を開始し、共通ロガーからgetStore()でIDを読み出します。loadUser()のawait後も同じIDを取得できます。

レスポンスヘッダーのx-request-idにも同じ値が入るので、問い合わせ内容とサーバーログを照合できます。void handle(...).catch(...)はPromiseの失敗を捕捉するための構文です。res.headersSentはヘッダー送信済みかを表し、送信後に別のHTTPステータスを送れないため、例では接続を閉じます。

コマンドで動作を確認

node --check server.mjs
node server.mjs

node --checkは実行せずにJavaScriptの構文を検査し、node server.mjsはサーバーを起動します。別ターミナルで以下を実行します。

curl -i http://localhost:3000/users/1
curl -i http://localhost:3000/not-found

curlはHTTPリクエストを送るコマンド、-iはレスポンスのヘッダーも表示する指定です。各応答には異なるx-request-idが付き、/users/1へのアクセス時に出る3つのログには同じIDが記録されます。

{"requestId":"a1b2...","message":"request started","method":"GET","pathname":"/users/1"}
{"requestId":"a1b2...","message":"user loaded"}
{"requestId":"a1b2...","message":"request completed"}

これはログの一部だけを抜き出した表示例です。実際のJSONにはtimeも含まれます。JSON(キーと値で表すテキスト形式)ならログ管理サービスでもフィールド単位で検索しやすくなります。

実務でハマりやすい4つの点

1. getStore()がundefinedになる

run()内から始まっていない処理(起動処理、独立したバッチなど)にはコンテキストがない場合があります。例の?.(オプショナルチェーン)と??(null・undefinedのときの代替値)で、IDが無くてもログを出せるようにしています。

2. enterWith()よりrun()を優先する

enterWith()は現在の同期実行の残りにもコンテキストを設定します。イベントハンドラーで予期しない処理に影響する可能性があるため、公式ドキュメントは、特別な事情がなければrun()を推奨しています。

3. 別サービス・キューには自動で伝わらない

AsyncLocalStorageは同じNode.jsプロセス内の仕組みです。外部HTTPサービスやキューへは、ヘッダーやメッセージのメタデータにIDを明示して送ります。外部から受信したIDは無条件に信用せず、形式と長さを検証するか、サーバー自身で新しいIDを発行してください。サービス横断で処理時間も可視化したい場合は、分散トレーシング(複数サービスの処理経路を追う仕組み)を検討します。

4. 特殊なコールバックではコンテキストを失う場合がある

通常のPromise・async/awaitでは動きますが、独自Thenable(Promiseに似たオブジェクト)や特殊なコールバック実装では失われることがあります。Node.js公式のトラブルシューティングではutil.promisify()によるPromise化やAsyncResourceを案内しています。

使い分け

方法 適したケース 注意
引数でIDを渡す 小規模な関数群 各関数へIDを渡す必要がある
AsyncLocalStorage 単一Node.jsプロセス内のログ相関 コンテキスト外はundefined
分散トレーシング マイクロサービス、HTTP・キューの横断 計測とデータ管理の設計が必要

AsyncLocalStorageは認証や権限検証の代わりにはなりません。パスワード・アクセストークン・個人情報を無条件にログやコンテキストへ保持しないようにし、負荷が高い環境では処理性能とログ量を測定してください。

まとめ

非同期ログを追いやすくする第一歩は、リクエスト入口でrun()を呼び、共通ロガーでgetStore()を使うことです。小さく始め、必要になったら別サービスへのID伝播や分散トレーシングへ拡張できます。

参考資料(公式一次情報)