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伝播や分散トレーシングへ拡張できます。