1
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?

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

1
Posted at

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

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

1
0
1

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
1
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?