第28章:観測(ログ)もI/O!テスタブルにする📈📝

この章でできるようになること🎯✨* console.log を卒業して、差し替えできるLoggerにできる🧩
- 「あとで調査できるログ」を設計できる🕵️♀️🔎
- テストでは スパイ(記録係)Logger で「大事なログが出た?」を確認できる👀🧪
- ついでに「危ないログ(個人情報とか)」を避けられるようになる🚫🙅♀️ (OWASP Cheat Sheet Series)
28-1. まず大前提:ログは “外の世界” に出る=I/O だよ📣🌍ログって「画面に出すだけ」っぽく見えるけど、実際は…
- コンソール
- ファイル
- 監視サービス(Datadog / Grafana / Cloud Loggingとか)
- ログ収集基盤(Elastic など)
…みたいに 外部に送られるデータなんだよね🛰️ だからログを中心ロジックにベタ書きすると、テストが不安定になったり、設計が汚れやすいの🥲
✅ 結論:ログも fetch や DB と同じで「境界の外」扱いにして、差し替え可能にするのが勝ち✌️✨
28-2. “良いログ” の条件(ざっくり7つ)
🌟📝ログは「出せばOK」じゃなくて、あとで読む未来の自分を助ける道具だよ🥹💗
✅ ① まず “イベント” として書く「いま何が起きた?」を、イベント名で固定するのが超強い💪
例:
order.place.startedorder.place.succeededorder.place.failed
→ 文言が多少変わっても、イベント名が同じなら検索できる🔎✨
✅ ② 構造化(JSONっぽく)
する文字列だけだと検索がつらい😵💫
{ event, orderId, userId, elapsedMs } みたいに キー付きが最強!
(Pinoみたいな構造化ロガーが人気なのはこの理由だよ📦) (getpino.io)
✅ ③ レベル(debug/info/warn/error)
を決める* info:正常系の重要イベント🌈
warn:仕様上ありえる失敗(在庫なし等)⚠️error:外部都合や想定外(通信失敗等)🔥
✅ ④ “理由” を残す(失敗ログが命)
失敗のときは特に、
- 何がダメだった?
- どのID?
- どの分岐?
が分かる情報を残すと未来の自分が泣いて喜ぶ😭✨
✅ ⑤ 個人情報・秘密は絶対に入れない🙅♀️
🔒パスワード、トークン、住所、メール本文…は基本ログに出さない! OWASPも「機密情報のログ出力は危険」って強く言ってるよ🚨 (OWASP Cheat Sheet Series)
✅ ⑥ 相関ID(requestId)
で “一本の物語” にする🧵同じリクエストのログを追えると捜査が一気に楽になるよ🕵️♀️
Nodeなら AsyncLocalStorage を使う方法がよく紹介されてる✨ (dash0.com)
✅ ⑦ テストが壊れにくい形にする🧪ログの文字列を丸ごと一致させるテストは、だいたい地獄👿
→ イベント名 + 必須フィールドだけを検証するのが安定👍
28-3. 設計の型:「中心はログの “中身” を決める、外側が “出力” する」🏠➡️
🌍ここがこの章のキモだよ〜💡✨
パターンA:Loggerを注入する(いちばん分かりやすい)
🎁中心(UseCase)は logger.info(...) みたいに呼ぶけど、実体は外から渡す。
パターンB:中心は “ドメインイベント” を返す(よりキレイ)
💎中心は「起きたこと」をイベントとして返す 外側がそれをログにする。 (中心がI/Oをより知らなくて済む👍)
この章では 分かりやすさ優先でパターンAで行くね😊✨ (Bも最後にチラ見せするよ👀)
28-4. Loggerを “最小の約束” にする📜✨### ① まず型を決める(イベント中心)
🧠
// core/logger.ts
export type LogLevel = "debug" | "info" | "warn" | "error";
export type LogEntry = {
level: LogLevel;
event: string; // 固定したイベント名
message?: string; // 人間向け補助(なくてもOK)
meta?: Record<string, unknown>; // 検索したい付帯情報
err?: unknown; // 例外があるならここ
};
export interface Logger {
log(entry: LogEntry): void;
debug(event: string, meta?: Record<string, unknown>): void;
info(event: string, meta?: Record<string, unknown>): void;
warn(event: string, meta?: Record<string, unknown>): void;
error(event: string, err?: unknown, meta?: Record<string, unknown>): void;
}
ポイントはこれ👇💖
eventを必須にして、ブレない軸を作る🔩metaは検索のために キー付きにする🔎errorはerrを別枠にしておく(スタック追いやすい)🔥
28-5. 中心ロジックでログを “イベントとして” 出す📦📝
題材:注文を確定する placeOrder(すごく簡略版)🛒🍕
// core/placeOrder.ts
import { Logger } from "./logger";
type PlaceOrderInput = { orderId: string; userId: string; totalYen: number };
type PlaceOrderResult =
| { ok: true }
| { ok: false; reason: "INSUFFICIENT_STOCK" | "PAYMENT_FAILED" };
export async function placeOrder(
input: PlaceOrderInput,
deps: { logger: Logger; pay: (yen: number) => Promise<boolean>; reserveStock: (orderId: string) => Promise<boolean> }
): Promise<PlaceOrderResult> {
const { logger, pay, reserveStock } = deps;
logger.info("order.place.started", { orderId: input.orderId, userId: input.userId });
const reserved = await reserveStock(input.orderId);
if (!reserved) {
logger.warn("order.place.failed", { orderId: input.orderId, reason: "INSUFFICIENT_STOCK" });
return { ok: false, reason: "INSUFFICIENT_STOCK" };
}
const paid = await pay(input.totalYen);
if (!paid) {
logger.warn("order.place.failed", { orderId: input.orderId, reason: "PAYMENT_FAILED" });
return { ok: false, reason: "PAYMENT_FAILED" };
}
logger.info("order.place.succeeded", { orderId: input.orderId });
return { ok: true };
}
ここ最高ポイント😍✨
- 「開始/成功/失敗」が イベントで固定されてる
- 失敗の理由
reasonが metaに入ってる - テストは
order.place.failedが出たか?を見ればいい👀🧪
28-6. 外側:Pinoで “本番のLogger” を作る🚀🧰Node/TypeScript界隈だと Pino は「速い・構造化しやすい」で定番寄りだよ📌 (getpino.io)
しかも redaction(伏せ字) とか child logger(共通metaを付けた子ロガー)も用意されてるのが強い💪 (getpino.io)
// infra/pinoLogger.ts
import pino from "pino";
import { Logger, LogEntry } from "../core/logger";
export function createPinoLogger(): Logger {
const base = pino({
level: "info",
// 例:うっかり入れても伏せたいキー(実運用ではもっと慎重に!)
redact: {
paths: ["meta.password", "meta.token", "meta.authorization"],
remove: true,
},
});
const log = (entry: LogEntry) => {
const { level, event, message, meta, err } = entry;
// Pinoはオブジェクトを渡すと構造化ログになる✨
(base as any)[level]({ event, ...meta, err }, message ?? event);
};
return {
log,
debug: (event, meta) => log({ level: "debug", event, meta }),
info: (event, meta) => log({ level: "info", event, meta }),
warn: (event, meta) => log({ level: "warn", event, meta }),
error: (event, err, meta) => log({ level: "error", event, err, meta }),
};
}
※「何を伏せるか」は本当に大事だよ〜! OWASPも「機密情報をログに出すな」ってはっきり言ってる🚫 (OWASP Cheat Sheet Series)
28-7. テスト:SpyLoggerで “ログが出た?” を検証する🕵️
♀️🧪### ① テスト用SpyLogger(記録係)
を作る📝
// test/spyLogger.ts
import { Logger, LogEntry } from "../core/logger";
export class SpyLogger implements Logger {
public entries: LogEntry[] = [];
log(entry: LogEntry): void {
this.entries.push(entry);
}
debug(event: string, meta?: Record<string, unknown>): void {
this.log({ level: "debug", event, meta });
}
info(event: string, meta?: Record<string, unknown>): void {
this.log({ level: "info", event, meta });
}
warn(event: string, meta?: Record<string, unknown>): void {
this.log({ level: "warn", event, meta });
}
error(event: string, err?: unknown, meta?: Record<string, unknown>): void {
this.log({ level: "error", event, err, meta });
}
}
② placeOrder のテスト例(ログも確認)
👀✨(テストランナーは今まで使ってるやつでOK!ここでは雰囲気で書くね🧁)
// test/placeOrder.test.ts
import { describe, it, expect } from "vitest";
import { placeOrder } from "../core/placeOrder";
import { SpyLogger } from "./spyLogger";
describe("placeOrder", () => {
it("在庫がないとき、failedログに理由が残る", async () => {
const logger = new SpyLogger();
const result = await placeOrder(
{ orderId: "o-1", userId: "u-1", totalYen: 1200 },
{
logger,
reserveStock: async () => false,
pay: async () => true,
}
);
expect(result).toEqual({ ok: false, reason: "INSUFFICIENT_STOCK" });
// ✅ 文字列全文一致じゃなく、イベントと必須フィールドを見るのがコツ✨
const failed = logger.entries.find((e) => e.event === "order.place.failed");
expect(failed?.level).toBe("warn");
expect(failed?.meta).toMatchObject({ orderId: "o-1", reason: "INSUFFICIENT_STOCK" });
});
});
このテスト、めちゃ “壊れにくい” 🙌💗
- 文言
messageを変えても壊れない - 大事な「イベント」と「理由」が守れる
- ログの役割(調査できる)をテストで固定できる
28-8. もう一段プロっぽく:相関IDで追跡しやすくする🧵🔎「同じリクエストのログを一本につなぐ」ために requestId を付けると超便利✨
Nodeでは AsyncLocalStorage を使う方法がよく紹介されてるよ📌 (dash0.com)
ここでは超シンプルに「外側が logger を child 化する」イメージだけ置いとくね👇 (Pinoの child logger は “共通metaを付けた子” を作れるやつ✨) (GitHub)
- 外側(HTTPハンドラとか)で
requestIdを作る loggerWithReq = logger.child({ requestId })みたいにして- その
loggerWithReqを中心に渡す
→ 中心は何も意識せず、ログに requestId が乗る!最高!🥳
28-9. (おまけ)
パターンB:中心はイベントを返す💎✨「中心がLogger依存するのすら気になる…!」ってなったら、
- 中心:
DomainEvent[]を返す - 外側:それをログにする
にすると、中心がもっとピュアになるよ🍰✨ ただ、最初はパターンAでぜんぜんOK🙆♀️(現場でも普通に使う!)
28-10. まとめ🌈
📝* ログはI/Oだから、直書きしないで差し替え可能にする🚪✨
- イベント名 + 構造化metaで「調査できるログ」を作る🕵️♀️
- テストは SpyLogger で「大事なログが出た?」を確認する👀🧪
- 機密情報はログに入れない(ガチで危ない)🚫🔒 (OWASP Cheat Sheet Series)
ミニ課題(15〜25分)
🍬⏳1. placeOrder に elapsedMs(処理時間)を meta に入れてみよ⏱️✨
2. order.place.started が必ず出るテストを書こう🧪
3. 失敗理由が増えたとき(例:ADDRESS_INVALID)でも、ログ設計が崩れないか確認しよ🧠📝
次の章(第29章)は「AI拡張と上手に進めるコツ」🤖🎀 この章で作った ログイベント設計は、AIにケース洗い出ししてもらうとめっちゃ捗るよ〜🥳