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?

JavaScriptのconsoleをデバッグの流れで使い分ける

0
Posted at

console.log を増やしたのに、画面がログで埋まるだけで原因が分からない。非同期処理のログが入り交じり、どの注文の値なのか追えない。JavaScriptを学び始めると、多くの人がこの状態を経験します。

結論から言えば、consoleは「値を表示する機能」ではなく、仮説を観測可能な形にするための道具として使うと効果を発揮します。処理の入口、分岐、外部I/Oの結果、出口という順番で観測点を置き、ラベルと識別子を添えるのが基本です。さらに、一覧は table、区間は group、時間は time、呼び出し経路は trace というように、調べたいことに合わせて表現を変えます。

この記事では、注文確定処理の不具合を追う例を少しずつ改善しながら、consoleの使い分け、本番ログとの境界、ログ以外の調査手段まで整理します。ブラウザーのDevToolsを想定しますが、大半の考え方はNode.jsでも共通です。

まず「何を知りたいか」を一文にする

次の関数で、在庫があるはずの商品まで「在庫不足」になるとします。最初から全変数を出すのではなく、「どの入力が、どの分岐を通り、どの結果になったか」を確認します。

async function checkout(order) {
  const stock = await fetchStock(order.itemId);

  if (stock < order.quantity) {
    throw new Error("在庫が不足しています");
  }

  const total = order.unitPrice * order.quantity;
  return { orderId: order.id, total };
}

悪いログは console.log(order)console.log(stock) だけを並べたものです。複数の注文が同時に処理されると、値と処理の対応が分かりません。そこで、安定したイベント名と注文IDをすべての観測点に含めます。

async function checkout(order) {
  console.info("[checkout:start]", {
    orderId: order.id,
    itemId: order.itemId,
    requested: order.quantity,
  });

  const stock = await fetchStock(order.itemId);
  console.debug("[checkout:stock-loaded]", {
    orderId: order.id,
    stock,
  });

  if (stock < order.quantity) {
    console.warn("[checkout:out-of-stock]", {
      orderId: order.id,
      stock,
      requested: order.quantity,
    });
    throw new Error("在庫が不足しています");
  }

  const total = order.unitPrice * order.quantity;
  console.info("[checkout:success]", { orderId: order.id, total });
  return { orderId: order.id, total };
}

この形なら、checkout や注文IDで絞り込めます。ログの目的はコードを実況することではありません。「在庫APIの返却値が誤っている」という仮説ならAPI直後を、「数量の型が違う」という仮説なら入口を観測します。仮説に関係しないログは追加しない方が、調査速度は上がります。

consoleメソッドは重要度ではなく用途で選ぶ

loginfodebugwarnerror は、ブラウザー側で表示やフィルターが分かれます。ただし、色や初期表示の有無は実装や設定によって異なります。レベル名だけに意味を預けず、イベント名とデータ自体でも意図が分かるようにします。

メソッド 主な用途 注文処理での例
console.debug 詳細な途中経過 APIの返却値、計算の中間値
console.info 正常な節目 処理開始、処理完了
console.warn 継続可能だが要注意 再試行、代替値の採用
console.error 失敗の記録 例外、外部I/Oの失敗

例外を記録するときは、メッセージだけに変換せず Error オブジェクトを渡します。スタックトレースや cause をDevToolsで確認できるためです。利用者向けメッセージと調査用情報も分離します。

try {
  await checkout(order);
} catch (error) {
  console.error("[checkout:failed]", {
    orderId: order.id,
    error,
  });

  showMessage("注文を確定できませんでした");
}

何層もの関数で同じ例外を記録すると、1回の失敗が大量のログになります。原則として、例外を回復する層か、外部との境界で一度記録します。下位層では必要な文脈を cause や独自エラーへ加え、上位層へ投げ直す方が追跡しやすくなります。

async function fetchStock(itemId) {
  try {
    const response = await fetch(`/api/stocks/${itemId}`);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return await response.json();
  } catch (error) {
    throw new Error(`在庫の取得に失敗しました: ${itemId}`, {
      cause: error,
    });
  }
}

オブジェクトは構造化して出す

文字列連結だけでログを作ると、数値と文字列の区別が消え、検索もしづらくなります。固定ラベルとオブジェクトを別の引数で渡せば、DevTools上でプロパティを展開できます。

// 情報が一つの文字列へ潰れる
console.log("order=" + order.id + " stock=" + stock);

// 値の型と構造を保てる
console.log("[checkout:decision]", {
  orderId: order.id,
  requested: order.quantity,
  stock,
  enough: stock >= order.quantity,
});

一方で、ブラウザーのconsoleに表示されたオブジェクトは、展開した時点の状態に見える場合があります。console仕様は表示方法のすべてを固定しておらず、DevToolsの挙動にも依存します。「記録時点の値」を確実に比較したいなら、必要なプリミティブ値だけ取り出すか、コピーしたスナップショットを渡します。

const snapshot = structuredClone(order);
console.log("[checkout:input-snapshot]", snapshot);

order.quantity = 0;

structuredClone は循環参照や Map なども扱えますが、関数やDOMノードなど複製できない値もあります。JSON.stringify も万能ではなく、undefinedBigInt、循環参照、型情報で問題が起きます。ログのためだけに巨大なオブジェクト全体を複製せず、調査に必要な項目を選ぶのが安全です。

一覧・まとまり・時間を見える形にする

複数の商品を比較するとき、同じ形のオブジェクトを何度も log するより console.table が向いています。列を限定すると、差分がさらに見つけやすくなります。

const checks = await Promise.all(
  order.items.map(async (item) => ({
    itemId: item.id,
    requested: item.quantity,
    stock: await fetchStock(item.id),
  })),
);

console.table(
  checks.map((item) => ({
    ...item,
    enough: item.stock >= item.requested,
  })),
  ["itemId", "requested", "stock", "enough"],
);

1回の処理に関するログは console.group または console.groupCollapsed でまとめられます。必ず finallygroupEnd を呼べば、途中で例外が起きてもグループの対応が崩れません。ただし、並行処理では別グループの出力が混ざり得るため、識別子は省略できません。

async function inspectCheckout(order) {
  console.groupCollapsed(`[checkout] ${order.id}`);

  try {
    console.log("input", order);
    const result = await checkout(order);
    console.log("result", result);
    return result;
  } finally {
    console.groupEnd();
  }
}

遅い区間を探すなら console.timeconsole.timeEnd を対で使います。ラベルは同じconsole内で識別されるため、並行実行する処理では注文IDを含めます。より精密な測定や集計にはPerformance APIが適しています。

async function loadCheckoutData(orderId) {
  const label = `[checkout:data] ${orderId}`;
  console.time(label);

  try {
    return await fetch(`/api/orders/${orderId}`).then((response) =>
      response.json(),
    );
  } finally {
    console.timeEnd(label);
  }
}

console.timeLog(label, data) を途中に挟めば、同じ区間の経過時間も確認できます。ただしconsole出力自体にもコストがあり、DevToolsを開いた状態と閉じた状態で結果が変わることがあります。性能の結論をconsoleの一度の数字だけで決めてはいけません。

回数と呼び出し元を調べる

「なぜ同じAPIが3回呼ばれるのか」には console.count が有効です。ラベルが同じ呼び出し回数を数え、console.countReset でリセットできます。レンダーやイベントの重複登録を疑うときの、短期的な観測点として便利です。

function onCheckoutClick(orderId) {
  console.count(`[checkout:click] ${orderId}`);
  return submitOrder(orderId);
}

// 調査が終わったら削除する
button.addEventListener("click", () => onCheckoutClick("order-42"));

値は正しいのに「誰がこの関数を呼んだか」が分からない場合は console.trace を使います。現在地点までのスタックを表示するため、意図しないイベントハンドラーや古い呼び出し経路を発見できます。

function applyDiscount(order, rate) {
  if (rate > 0.5) {
    console.trace("[discount:unexpected-rate]", {
      orderId: order.id,
      rate,
    });
  }

  return order.total * (1 - rate);
}

呼び出し経路を継続的に追うなら、DevToolsのブレークポイントや「Pause on exceptions」の方が適切です。trace は非同期境界をまたぐと情報が限定されることがあり、ソースマップが不正確なら元コードの位置もずれます。

assertは例外ではなく観測点

console.assert は、第1引数が偽のときにメッセージを出します。ブラウザーでは通常、処理を停止する例外ではありません。そのため、入力検証や業務ルールを保証する手段には使えません。「開発中に成立してほしい前提を観測する」用途に限定します。

function calculateTotal(order) {
  console.assert(order.quantity > 0, "quantity must be positive", {
    orderId: order.id,
    quantity: order.quantity,
  });

  if (!Number.isInteger(order.quantity) || order.quantity <= 0) {
    throw new TypeError("quantityには正の整数が必要です");
  }

  return order.unitPrice * order.quantity;
}

この例では、assert は調査の補助、ifthrow は実際の契約です。さらに再発を防ぐには自動テストを書きます。consoleは、その場の事実を知る道具であり、将来の正しさを保証する道具ではありません。

非同期処理は相関IDと状態遷移で追う

非同期処理では、記述された行の近さより、どの処理単位に属するログかが重要です。二つの注文を同時に処理すれば、開始順と完了順は一致しないことがあります。時刻だけを見ても、通信時間の違いなのか別の注文なのか判断できません。

そこで、一回の操作を通して変わらない相関IDと、意味のある状態名を付けます。注文IDがまだ発行されていない処理では、画面操作時に crypto.randomUUID() などで相関IDを作り、下位関数へ明示的に渡します。

async function submitCheckout(input) {
  const context = {
    correlationId: crypto.randomUUID(),
    startedAt: performance.now(),
  };

  console.info("[checkout:submitting]", {
    correlationId: context.correlationId,
    itemCount: input.items.length,
  });

  try {
    const order = await saveOrder(input, context);
    console.info("[checkout:submitted]", {
      correlationId: context.correlationId,
      orderId: order.id,
      durationMs: performance.now() - context.startedAt,
    });
    return order;
  } catch (error) {
    console.error("[checkout:submit-failed]", {
      correlationId: context.correlationId,
      durationMs: performance.now() - context.startedAt,
      error,
    });
    throw error;
  }
}

状態名は startmiddleend のような位置ではなく、validatingstock-reservedpayment-authorized のような業務上の事実にします。後から処理順が変わってもログの意味が残り、どの状態まで成功したかを確認できます。

performance.now() はページやプロセス内の経過時間測定に向く単調増加の時刻で、端末時計の変更の影響を受けにくい値です。一方、別端末やサーバーのログと突き合わせるには共通の日時や分散トレースが必要です。用途の異なる時刻を一つの値で済ませないようにします。

相関IDを渡すためだけに、すべての関数を巨大なcontextへ依存させるのも避けます。外部I/Oや重要な状態遷移を担う境界へ限定し、純粋な計算関数には本来の入力だけを渡します。ログ都合の設計が業務ロジックを支配しないことも、長期的な読みやすさには重要です。

ログへ出す値を境界で決める

調査のたびにオブジェクト全体を出すと、後から追加された機密プロパティまで自動的に記録されます。ログ用のデータを組み立てる関数を用意し、記録してよいフィールドを明示すると、安全性と検索性を同時に保てます。

function toOrderLog(order) {
  return {
    orderId: order.id,
    itemCount: order.items.length,
    total: order.total,
    status: order.status,
  };
}

console.info("[order:loaded]", toOrderLog(order));

この変換は、JSONへ変換できる単純な値を返すようにすると、本番の構造化ロガーへ移行しやすくなります。ログのスキーマを変更すると監視クエリやアラートにも影響するため、イベント名と主要フィールドはAPIのように扱います。調査用の一時ログと、運用上の契約になっているログを区別しましょう。

consoleだけで調べない

ログを追加する前に、ブラウザーのデバッガーで止めた方が速い場合があります。使い分けの目安は次のとおりです。

  • ある瞬間のスコープと値を詳しく見る:ブレークポイント
  • 値が書き換わった箇所を探す:DOM変更・XHR/fetch・イベントのブレークポイント
  • HTTPの要求、応答、待ち時間を見る:Networkパネル
  • 長いタスクや描画の遅さを測る:Performanceパネル
  • 同じ条件を何度も保証する:単体テスト、統合テスト
  • 利用者環境で発生する失敗を追う:監視・エラー収集基盤

条件付きブレークポイントなら、特定注文だけで停止できます。consoleへ大量に流す必要がなく、停止時点のクロージャやコールスタックも確認できます。

// DevToolsの条件付きブレークポイントへ指定する式の例
order.id === "order-42" && order.quantity > stock

この式はアプリへ追加するコードではありません。DevTools上で使う条件です。ログを消し忘れる心配がなく、再現手順が分かっている不具合に向いています。

本番ログでは安全性と検索性を優先する

ブラウザーconsoleは利用者から見え、出力内容を信頼できる監査記録にもできません。アクセストークン、パスワード、Cookie、カード情報、個人情報を出してはいけません。開発環境で必要に見えても、本番ビルドへ残る可能性を考えます。

サーバー側の運用ログでは、JSONなどの構造化形式、時刻、重要度、相関ID、イベント名を揃えると検索しやすくなります。console APIを直接あちこちで呼ぶより、環境や出力先を切り替えられる薄いロガーへ集約します。

function createLogger({ enabled, sink = console }) {
  return {
    info(event, context = {}) {
      if (enabled) sink.info(event, redact(context));
    },
    error(event, error, context = {}) {
      sink.error(event, { ...redact(context), error });
    },
  };
}

function redact({ token, password, ...safe }) {
  return safe;
}

この例は最小形です。実運用では機密項目を除外リストだけで管理すると漏れやすいため、記録してよい項目を明示する許可リスト方式も検討します。また、ログ量には費用と性能の影響があります。成功した全処理を詳細に残すのではなく、調査に必要な粒度、保存期間、サンプリングを設計します。

調査するときの手順

consoleデバッグが迷走したら、次の順で立て直します。

  1. 期待結果と実際の結果を一文ずつ書く。
  2. 再現条件を固定し、対象を識別するIDを決める。
  3. 入力、重要な分岐、外部I/O、出力に観測点を置く。
  4. オブジェクト全体ではなく、仮説に必要な値を構造化して出す。
  5. IDやイベント名でフィルターし、時系列を確認する。
  6. 呼び出し元なら trace、回数なら count、時間なら time へ切り替える。
  7. 原因が絞れたらブレークポイントで状態を詳しく確認する。
  8. 修正後は再現テストを追加し、一時ログを削除する。

「念のため残す」ログが積み重なると、本当に必要な異常が埋もれます。一時的な調査ログにはコメントやタスク番号を付け、修正と同時に削除する運用が有効です。残すログには、誰が、どの場面で、何を判断するために使うのかを説明できる必要があります。

よくある失敗

最も多いのは、ラベルのない console.log(value) です。次に多いのが、同じオブジェクトを何層でも出すこと、例外を文字列へ潰すこと、秘密情報を含めることです。非同期処理では、出力順をコードの記述順と同一視する失敗もあります。

consoleの各呼び出しは観測のための副作用です。出力が同期的に端末へ書かれるか、どのように整形されるかは環境に依存します。consoleを呼んだ順番と非同期処理が完了した順番を混同せず、必要ならタイムスタンプ、処理ID、状態遷移を記録します。

また、ログに現れないことは「処理されなかった」証明になりません。フィルターで非表示、出力上限で破棄、ページ遷移で消去、該当レベルが無効などの可能性があります。Preserve logの設定やフィルター状態も確認します。

まとめ

consoleを有効に使う基準は、メソッドを何個知っているかではありません。調査したい仮説を決め、処理IDを付け、入口・分岐・I/O・出口を最小限の観測点でつなぐことが重要です。

  • 値は固定ラベルと構造化データで出し、型と文脈を保つ。
  • 一覧は table、まとまりは group、時間は time、回数は count、経路は trace を使う。
  • Error オブジェクトを保ち、同じ失敗を何層でも記録しない。
  • assert は例外やテストの代わりにしない。
  • 状態の詳細はデバッガー、通信はNetwork、性能はPerformanceへ切り替える。
  • 本番では機密情報、検索性、保存量を設計し、consoleを監査基盤だと考えない。

良いログはコードの実況ではなく、原因へ近づくための証拠です。調査が終わった後に「この出力から何を判断できたか」を振り返ると、次のデバッグで置くべき観測点も洗練されます。

参考資料

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?