Claude Codeの「ログ入れて」が本番で死ぬ理由と構造化ログ設計
Claude Codeに任せると増えるconsole.logは障害で役に立たない。構造化ログ・PIIマスク・アラート・可観測性を安全に設計する実装手順を、僕の決済API事故込みで紹介。
午前3時。決済が落ちた、というアラートで叩き起こされました。
ログを開く。console.log("error") と console.log(err) が延々と流れている。どの注文で、どのユーザーが、どこで詰まったのか、まったく分からない。
そのログを書いたのは、数週間前の僕とClaude Codeでした。「ログを詳しく入れといて」とだけ頼んで、出てきたものをロクに見ずにマージしていたんです。賢いAIに任せたはずなのに、いざというときいちばん知りたい情報がどこにもなかった。
ログは「動いているとき」には誰も読みません。読むのは決まって、火を噴いている真夜中です。今日はその真夜中に効く、構造化ログと監視の作り方を、僕が踏んだ地雷ごと書きます。
この記事の要点
- Claude Codeに「ログ入れて」と頼むと
console.logが増えるだけで、本番障害では役に立たない。先に「何を記録しないか」を渡すのがコツ。 - ログ・メトリクス・トレースは別物ではなく、同じリクエストを別角度から見る信号。相関キーは
requestIdとtraceparentの2本に絞る。 - いちばん怖いのは情報不足ではなくPII(個人情報)の漏洩。リクエストボディ全体をログに出す実装は事故のもと。マスクをコードとテストで強制する。
- アラートは「1件のerrorログ」ではなく、5xx率・p95遅延・マスク失敗のような率や分位点で作る。
- コピペで動く構造化ロガー(依存ゼロ・Node.js 18+)を載せたので、まず手元で
[REDACTED]が出るのを確認してほしい。
「ログを詳しく」が一番危ない依頼だった
冒頭の続きです。僕は小さな決済APIで、Claude Codeに「エラー原因が分かるようにログを詳しく」と頼みました。
返ってきた実装は、一見すると親切でした。エラーが起きたら、そのリクエストのボディを丸ごとログに残す。原因究明にはたしかに便利です。でも、そのボディにはメールアドレス、クーポンコード、配送先住所が入っていました。カード番号こそ別経路で扱っていたのでセーフでしたが、一歩間違えれば個人情報をログ基盤に垂れ流すところでした。
ここで気づいたんです。ログ設計で最初に決めるべきは「何を残すか」じゃなくて、**「何を絶対に残さないか」**だと。
ルールをこう変えました。
- PII(個人を識別できる情報)は残さない。
- 相関キーは
requestIdとtraceparentの2つだけにする。 - アラートはログの文字列マッチではなく、メトリクスで作る。
たったこれだけで、Claude Codeへのレビューが嘘みたいに楽になりました。出てきたコードを「動くか」じゃなく「禁止フィールドを破っていないか」で見ればよくなったからです。
そもそも可観測性(observability)とは、外側からシステムの中身を推測し、想定していなかった問題にも答えられる状態を指します。ログ・メトリクス・トレースは三つの別ジャンルではなく、同じ1リクエストを別の角度から照らす信号です。考え方の土台は公式のOpenTelemetry Observability primerが分かりやすいので、用語が曖昧なら先に流し読みしておくと、このあとが効きます。
| 信号 | 何のために見るか | Claude Codeに渡す制約 |
|---|---|---|
| ログ | 何が起きたかを後から読む | JSON固定フィールド・PIIマスク |
| メトリクス | どれくらい悪いかを測る | rate・p95・エラー率を集計 |
| トレース | どこで遅いかをたどる | traceparent を伝播しspanを作る |
| ヘルスチェック | 依存先が生きているか確認 | 依存先ごとにlatencyを返す |
Claude Codeに渡すと事故らないプロンプト
Claude Codeは指示が曖昧だと「親切に全部やる」方向へ振れます。ログでそれをやられると個人情報が漏れる。だから依頼文に禁止事項を先頭近くで明記します。
Claude Code task:
- Add observability to the checkout API only.
- Keep all changes inside src/checkout and tests/checkout.
- Use structured JSON logs with requestId and traceparent.
- Never log passwords, tokens, cookies, email, phone, address,
raw prompt text, or full request/response bodies.
- Add tests proving redaction and requestId propagation.
- Add a /healthz report with database and cache latency.
- Add alert rules for 5xx rate, p95 latency, and redaction failure.
- Show a diff summary and remaining manual checks at the end.
狙いは、Claude Codeの出力を「とりあえず動く実装」ではなく「レビューできる運用差分」にすることです。最後の Show a diff summary の一行があるだけで、変更全体を読み返す時間がかなり減ります。
このルールは毎回手で打つと忘れるので、CLAUDE.md に置いておきます。プロジェクトの指示を毎回読ませる仕組みで、ログレベル・フィールド名・禁止フィールド・テストコマンド・ダッシュボード名まで書いておくと、次回の修正でもぶれません。CLAUDE.md の書き方そのものはCLAUDE.mdベストプラクティスに寄せました。権限の境界をコードで縛るなら、公式のClaude Code permissionsとhooksも合わせて確認してください。
構造化ログとリクエストID:コピペで動くロガー
ログは「後から読むテキスト」ではなく「本番の個人データを扱う機能」です。OWASPのLogging Cheat Sheetも、ログをセキュリティレビューやテストの対象に含め、ログ注入やディスク枯渇まで検証せよと書いています。つまりログ実装は、片手間ではなく機能として扱うべきものです。
まずは依存ライブラリゼロで動く最小ロガーです。structured-logger.mjs として保存し、Node.js 18以上で node structured-logger.mjs を実行すれば、最後の行のトークンが [REDACTED] になるのが確認できます。
import { randomUUID } from "node:crypto";
// ログレベルの優先度。数字が大きいほど深刻
const rank = { debug: 10, info: 20, warn: 30, error: 40 };
const current = process.env.LOG_LEVEL || "info";
const threshold = rank[current] ?? rank.info;
// この名前のフィールドは中身を見せずにマスクする
const secretKeys = [
"password",
"token",
"authorization",
"cookie",
"set-cookie",
"apikey",
];
// 改行やタブを潰し、長すぎる値を切る(ログ注入と肥大化の対策)
function cleanText(value) {
return String(value).replace(/[\r\n\t]/g, " ").slice(0, 500);
}
// オブジェクトを再帰的に走査し、秘密フィールドを置き換える
function redact(value) {
if (Array.isArray(value)) return value.map(redact);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value).map(([key, item]) => {
if (secretKeys.includes(key.toLowerCase())) {
return [key, "[REDACTED]"];
}
return [key, redact(item)];
}),
);
}
export function log(level, message, fields = {}) {
// しきい値より軽いログは捨てる(本番でdebugを垂れ流さない)
if ((rank[level] ?? 99) < threshold) return;
const entry = {
ts: new Date().toISOString(),
level,
service: process.env.SERVICE_NAME || "checkout-api",
env: process.env.NODE_ENV || "development",
requestId: fields.requestId || randomUUID(),
msg: cleanText(message),
...redact(fields),
};
process.stdout.write(`${JSON.stringify(entry)}\n`);
}
// 動作確認:token は [REDACTED] になり、改行は1行に潰れる
log("info", "payment accepted", {
requestId: "req_demo_001",
userId: "user_123",
amount: 4980,
token: "sk_live_should_not_leak",
});
ポイントは2か所だけ覚えてください。redact がオブジェクトを潜って秘密フィールドを潰す門番。cleanText が改行を消してログ注入とディスク肥大を防ぐ門番。この2つがあるだけで、冒頭の「リクエストボディ丸出し事故」は起きなくなります。
WebアプリではリクエストごとにIDを発番し、レスポンスヘッダーにも返します。W3CのTrace Contextは、traceparent が来ていなければ新しいIDを作り、来ていれば引き継ぐと定めています。自前の x-request-id と標準の traceparent を混同しないのがコツです。次はExpress用のミドルウェアです。
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import type { Request, Response, NextFunction } from "express";
import { log } from "./structured-logger";
type RequestContext = {
requestId: string;
traceparent?: string;
userId?: string;
};
// リクエストごとの文脈を、引数で持ち回さずに共有する箱
const storage = new AsyncLocalStorage<RequestContext>();
export function getRequestContext() {
return storage.getStore();
}
export function requestContext(
req: Request,
res: Response,
next: NextFunction,
) {
const started = performance.now();
const user = (req as Request & { user?: { id?: string } }).user;
// 既存IDがあれば引き継ぎ、なければ発番
const requestId =
req.get("x-request-id") ||
req.get("cf-ray") ||
randomUUID();
const context = {
requestId,
traceparent: req.get("traceparent"),
userId: user?.id,
};
res.setHeader("x-request-id", requestId);
storage.run(context, () => {
res.on("finish", () => {
const durationMs = Math.round(performance.now() - started);
// ステータスでログレベルを自動で振り分ける
const level = res.statusCode >= 500
? "error"
: res.statusCode >= 400
? "warn"
: "info";
log(level, "http request completed", {
requestId,
method: req.method,
path: req.path,
statusCode: res.statusCode,
durationMs,
});
});
next();
});
}
ログレベルの線引きはシンプルでいいです。debug は本番で常時出さない、info は正常系の重要イベント、warn は回復できた異常、error は人間が調査すべき状態。Claude Codeには「既存のエラー処理を消すな」「ログのために制御フローを変えるな」「ログ出力の失敗でアプリを落とすな」の3つを必ず添えてください。これを言わないと、ログを足すついでにtry-catchの形を勝手に変えてくることがあります。
OpenTelemetryで信号をそろえる
ログとIDが整ったら、トレースとメトリクスをつなぎます。OpenTelemetryはベンダー中立の計装レイヤーで、保存先そのものではありません。Jaeger・Prometheus・Grafana・Datadogなどに送る前段で、アプリ側の信号フォーマットをそろえる役割です。
npm install @opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-trace-otlp-proto \
@opentelemetry/exporter-metrics-otlp-proto \
@opentelemetry/sdk-metrics
const opentelemetry = require("@opentelemetry/sdk-node");
const {
getNodeAutoInstrumentations,
} = require("@opentelemetry/auto-instrumentations-node");
const {
OTLPTraceExporter,
} = require("@opentelemetry/exporter-trace-otlp-proto");
const {
OTLPMetricExporter,
} = require("@opentelemetry/exporter-metrics-otlp-proto");
const {
PeriodicExportingMetricReader,
} = require("@opentelemetry/sdk-metrics");
process.env.OTEL_SERVICE_NAME ||= "checkout-api";
const endpoint =
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ||
"http://localhost:4318";
const sdk = new opentelemetry.NodeSDK({
traceExporter: new OTLPTraceExporter({
url: `${endpoint}/v1/traces`,
}),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: `${endpoint}/v1/metrics`,
}),
exportIntervalMillis: 30000,
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
// 終了時にバッファを吐き出してから落とす
process.on("SIGTERM", () => {
sdk.shutdown().finally(() => process.exit(0));
});
設定値はバージョンで変わるので、更新時は公式のJavaScript Node.js getting startedとexportersを見て合わせてください。運用中に見る信号の流れは、こんな絵になります。Claude Codeに「どこに何を送るのか」を説明するときも、この図を先に渡すと話が早いです。
flowchart LR
A["利用者の操作"] --> B["アプリ"]
B --> C["構造化ログ"]
B --> D["メトリクス"]
B --> E["トレースspan"]
C --> F["ログ基盤"]
D --> G["アラート"]
E --> H["トレース基盤"]
F --> I["インシデント引き継ぎ"]
G --> I
H --> I
複数サービスがあるなら、service.name と deployment.environment の値をブレさせないのが鉄則です。ここが揃っていないと、せっかくのトレースも検索で見つかりません。サービス分割そのものの考え方はマイクロサービス設計に書きました。
ヘルスチェックとアラート:rateで鳴らす
ヘルスチェックは 200 OK を返すだけでは足りません。DB・キャッシュ・外部APIなど、依存先ごとに成功・失敗・遅延を返します。ただし接続文字列や秘密情報は絶対に返さない。ここでもマスクの発想は同じです。
function timeout(ms) {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error("timeout")), ms);
});
}
export async function buildHealthReport(checks) {
const started = Date.now();
const results = {};
for (const [name, check] of Object.entries(checks)) {
const before = Date.now();
try {
// 依存先が固まっても800msで諦める
await Promise.race([check(), timeout(800)]);
results[name] = {
status: "ok",
latencyMs: Date.now() - before,
};
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
results[name] = {
status: "fail",
latencyMs: Date.now() - before,
reason: message.slice(0, 120),
};
}
}
const failed = Object.values(results)
.filter((item) => item.status === "fail")
.length;
return {
status: failed ? "degraded" : "ok",
uptimeSec: Math.round(process.uptime()),
totalLatencyMs: Date.now() - started,
checks: results,
};
}
そしてアラート。ここで多くの人がつまずきます。「errorログが1件出たら通知」にすると、一時的なノイズで延々と叩き起こされ、やがて誰もアラートを見なくなります。鳴らすべきは、一定時間の率や分位点です。以下はPrometheus形式の例で、5xx率が2%を10分超えたら、p95遅延が1.5秒を15分超えたら鳴ります。
groups:
- name: checkout-api
rules:
- alert: CheckoutHigh5xxRate
expr: |
sum(rate(http_requests_total{
service="checkout-api",
status_code=~"5.."
}[5m]))
/
sum(rate(http_requests_total{
service="checkout-api"
}[5m])) > 0.02
for: 10m
labels:
severity: page
annotations:
summary: "Checkout 5xx rate is above 2%"
- alert: CheckoutP95LatencyHigh
expr: |
histogram_quantile(
0.95,
sum by (le) (
rate(http_request_duration_seconds_bucket{
service="checkout-api"
}[5m])
)
) > 1.5
for: 15m
labels:
severity: ticket
annotations:
summary: "Checkout p95 latency is above 1.5s"
severity を page(叩き起こす)と ticket(業務時間に対応)で分けているのがポイントです。全部を page にすると、結局オオカミ少年になります。
3つのユースケースで何を残すか
1. ECの決済API。 残すのは orderId・requestId・paymentProvider・amount。残さないのはカード番号・メールアドレス・住所・アクセストークン。アラートは5xx率、決済失敗率、外部プロバイダのp95で分けます。障害時はログで注文番号を見つけ、トレースで決済呼び出しを追い、メトリクスで全体影響を測る、という順で動けます。
2. SaaSの管理画面。 ログイン・権限変更・メンバー招待・プラン変更は監査ログとして残します。ただし招待メール本文や個人メモは不要。Claude Codeには、監査ログをアプリログと別ストリームにすること、管理者IDと対象ユーザーIDを別フィールドにすること、RBAC(役割ベースの権限)テストを足すことまで依頼します。
3. メディア/ブログCMS。 記事公開・CTAクリック・問い合わせ送信・画像生成失敗・翻訳未完了を追います。PVだけ見ても収益にはつながらないので、cta_click と generate_lead を分けて記録するのがコツ。具体的な計測の入れ方はClaude Code分析実装にまとめました。
僕がやらかした失敗と落とし穴
正直に書くと、最初に作った監視はほぼ全部ハマりました。よくある落とし穴を、自分の失敗順に並べます。
ひとつ目は、冒頭のリクエストボディ丸出し。原因を知りたい一心で全部出すと、個人情報がログに残ります。redact を入れるまで気づきませんでした。
ふたつ目は、ログメッセージが自由文だったこと。"payment failed for user" のように毎回違う文を書いていたら、検索条件が固定できず、障害のたびにgrepの呪文を考える羽目に。固定フィールド+短いmsgに変えて解決しました。
みっつ目は、相関キーの乱立。requestId・reqId・request_id・traceId が混在して、どれで突き合わせればいいのか分からなくなりました。2本(requestId と traceparent)に絞って統一。
よっつ目は、ヘルスチェックが嘘をつく問題。依存先を見ずに常に ok を返していたので、DBが死んでもヘルスチェックは元気でした。依存先ごとにlatencyを返す形に直しました。
そしてClaude Code特有の地雷がもうひとつ。障害調査を頼むとき、本番ログをそのままプロンプトに貼ってしまうことです。これは個人情報や秘密情報をAIに渡す行為になります。調査を依頼するときは、マスク済みログ・集計済みメトリクス・短いtrace ID一覧だけを渡してください。この線引きはClaude Code permissionsガイドの考え方とそのままつながります。実際の障害対応の流れは本番インシデント対応にも書きました。
インシデントの引き継ぎは、次のようなJSONで残すとチャットでもチケットでも再利用できます。
{
"incident_id": "INC-2026-06-02-001",
"severity": "SEV2",
"owner": "oncall-api",
"customer_impact": "Checkout errors for some card payments",
"first_seen": "2026-06-02T09:15:00+09:00",
"request_ids": ["req_7f3a", "req_8b21"],
"trace_ids": ["7bba9f33312b3dbb8b2c2c62bb7abe2d"],
"dashboards": ["Checkout API overview"],
"current_hypothesis": "Payment provider latency spike",
"actions_taken": ["Disabled checkout_v2 feature flag"],
"next_checks": ["Compare p95 latency by region"],
"do_not_do": ["Do not paste raw customer data into prompts"]
}
よくある質問
Q. console.log は全部やめるべき?
ローカルの試行錯誤では使っていいです。やめるべきは「本番に残す console.log」。本番のログは検索・集計・マスクの対象なので、構造化ロガー経由に寄せてください。
Q. requestId と traceId はどう違う?
requestId は自分のアプリが発番する相関キー、traceId(traceparent の一部)は複数サービスをまたぐ標準のキーです。1サービスなら requestId だけでも回りますが、サービスが増えたら traceparent を引き継ぐ実装にしておくと後で楽です。
Q. 個人情報のマスクは正規表現で十分?
フィールド名ベース(この記事の secretKeys 方式)を基本にしてください。本文中に紛れたメールアドレスなどを正規表現で追うのは漏れが出ます。そもそもボディ全体をログに入れない設計が一番安全です。
Q. OpenTelemetryは小さいアプリにも必要?
最初は不要です。まず構造化ログと requestId だけ入れる。サービスが分かれて「どこで遅いか分からない」と感じ始めたら、トレースを足すのが現実的な順番です。
Q. 障害調査をClaude Codeに頼むとき、何を渡せばいい? 生ログは渡さないでください。マスク済みログの抜粋、集計済みメトリクス(5xx率やp95)、短いtrace ID一覧の3点に絞ると、安全かつ的確に動きます。
実際に試した結果
冒頭の午前3時以来、僕のログへの向き合い方は変わりました。「もっと詳しく残そう」ではなく、「まず何を残さないか決めよう」が口癖になった。
実装して効果がはっきり出たのは、やはり禁止フィールドを先に書くことでした。structured-logger.mjs を手元で回すと、token がちゃんと [REDACTED] になり、改行入りのメッセージが1行に正規化される。ヘルスチェックでキャッシュ失敗を模擬すると、全体が degraded に落ちる。インシデントJSONに requestId と traceId を入れておくだけで、レビューの会話が驚くほど短くなりました。
賢いAIに「いい感じのログ」を期待するより、何を残さないかを先に縛る。遠回りに見えて、真夜中の自分をいちばん助けてくれるのはこっちでした。
まず手元で上のロガーを動かして、[REDACTED] が出るのを目で確かめてください。そこからが本当のスタートです。日常の確認コマンドを固定したいなら無料チートシート、チームでログ設計・CLAUDE.md・権限・CI・インシデント運用までまとめて整えるならClaude Code研修・導入相談から、実リポジトリ前提で相談できます。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeのチーム利用でコストが読めない時に作る予算ログ
チーム導入前に、誰が何に使い、どの成果が出たかを見える化する予算ログの作り方。
コミット前の3分チェック: Claude Codeが触った範囲を確認してから確定する
Claude Codeが勝手に広げた変更を、コミット前に3分で見抜く確認手順。差分の範囲、検証ログ、ステージするファイルの絞り込みを順番に解説します。
Claude Codeをチーム導入する前に作る「リスク台帳」の中身
Claude Codeを個人実験で終わらせずチーム導入するための、権限・CI・公開の事故を防ぐリスク台帳の作り方を実例とコードで解説します。