Language: 日本語
「ログ文言を変えたらアラートが死んだ」を防ぐ。人向け message と機械向け field を分離するログ・エラー設計
ログ出力やエラーハンドリングを実装するとき、こんなコードを書いていませんか?
ログ出力やエラーハンドリングを実装するとき、こんなコードを書いていませんか?
// よくあるログ出力
logger.error(`Failed to update task ${taskId} by user ${userId}: ${err.message}`);
一見すると「タスク ID もユーザー ID もエラー内容も入っていて親切なログ」に見えますよね。
しかし、この設計には 2 つの深刻な罠 が潜んでいます。
- ログ監視の破損:
Datadog や Cloud Logging で「
Failed to update taskを含むログ」といった文字列 grep でアラートを設定していた場合、後から誰かが「読みやすくしよう」とログ文言をTask update failedに書き換えた瞬間に、本番のアラートが一切飛ばなくなります。 - 機密情報・内部構造の漏洩:
err.messageやオブジェクトをそのまま HTTP レスポンスに流し込んでしまい、DB の生クエリやスタックトレースがクライアントに丸見えになってしまうセキュリティ事故が多発します。
この記事では、Controller や Service 各所に散らばりがちなログ・例外処理を Global boundary(Interceptor / Exception Filter)に集約 し、「人間が読む message」と「機械が監視する structured field」、「フロント向け details」と「サーバー調査専用 logDetails」を型レベルで厳格に分離する設計 を解説します。
得られること
- 「人間が読む表示文言(message)」と「機械がクエリ・監視するフィールド(structured field)」を分離するログ設計の原則がわかる
- 個別モジュールからロガーやステータス変換を追放し、Global boundary へ責務を集約するアーキテクチャが学べる
AppExceptionのdetails(クライアント向け)とlogDetails(サーバー専用)を型で分離し、情報漏洩を防ぎつつ調査性を最大化する手法が身につく!
散らばるログ判断と機能側の過剰な責務
機能(Controller や Service)ごとにログの形や例外処理を実装していると、以下のように同じ判断がコードベース全体に散乱します。
【Before: 各所に散らばるログ・例外判断】
Controller A ── logger 呼び出し / error 整形 / status コード変換 💥
Service B ── logger 呼び出し / error 整形 / status コード変換 💥
Guard C ── logger 呼び出し / error 整形 / status コード変換 💥
発生する問題
- 「このエラーのときは 404 なのか 400 なのか」「ログレベルは WARN なのか ERROR なのか」が機能ごとにバラバラになる。
- 機密情報のマスキング(redaction)を各所で個別に行うため、マスク漏れが必ず発生する。
- 業務ロジックの中に「ロギングのための文字列組み立て」が混ざり、可読性と保守性が低下する。
Global boundary への集約アーキテクチャ
そこで、業務コード(UseCase / Service)からはロギングや HTTP 変換の関心を完全に剥がし、「Global boundary(Interceptor / Exception Filter)」 へすべて集約しました。
【After: 責務が集約された Global Boundary】
UseCase / Service
│ 「何が起きたか」というドメイン例外(AppException)を投げるだけ ⚡️
▼
Global boundary (Interceptor / Exception Filter)
├─ API error response (安全な HTTP レスポンスへ成形)
├─ redacted structured log (機密マスク済みの構造化ログ出力)
└─ metric / alert (監視バックエンドへのメトリクス送信)
業務コードは「どんなエラーが起きたか(事実)」を投げるだけで、それを HTTP ステータスコードにどうマッピングするか、ログをどの logger 実装でどうフォーマットするかは一切知りません。
message は人向け、field は機械向け
HTTP リクエストログを記録する際、「表示文言(message)」と「監視対象(structured field)」の役割を明確に分離 します。
// AuditInterceptor でのログ出力例
const route = `${req.method} ${(req.originalUrl || req.url).split("?")[0]}`;
const status = req.res?.statusCode ?? 200;
const durationMs = Date.now() - start;
this.logger.info(`${route} → ${status} (${durationMs}ms)`, {
event: "http.request",
http_request_id: httpRequestId,
route,
status,
durationMs,
});
| 要素 | 対象 | 役割 | 具体例 |
|---|---|---|---|
| message | 人間(開発者) | ターミナルやログビューアで一目で状況を把握する | POST /api/tasks → 201 (14ms) |
| structured field | 機械(Datadog, Cloud Logging) | 検索、フィルタリング、集計、アラート検知 | { event: "http.request", status: 201, durationMs: 14 } |
分離によって得られるメリット
- アラートが絶対に壊れない:
監視側は
status >= 500やevent: "http.request"という確定したフィールド値でクエリするため、message のフォーマット(矢印の形や表記揺れ)をいくら改善しても監視契約が一切壊れません。 - logger 実装の隠蔽: ロガー本体は Port(インターフェース)の背後に置き、NestJS 標準の Logger から Pino などの高速な構造化ロガーへ一行も業務コードを変えずに差し替えられます。
AppException による details と logDetails の型分離
例外を定義する際、「クライアントに返してよい公開情報」と「サーバー内部の調査にだけ使う機密情報」を型レベルで厳密に分割 します。
// 業務コードで投げる例外
throw new AppException({
code: "RESOURCE_NOT_FOUND",
statusCode: 404,
details: { resource: "task" }, // 🌐 クライアントへ返却(安全な情報のみ)
logDetails: { taskId, cause }, // 🔒 サーバーログにのみ出力(調査用の詳細)
});
Global Exception Filter がこの AppException を受け取ったとき、自動的に以下のように振り分けます。
【クライアントへの HTTP レスポンス (404 Not Found)】
{
"code": "RESOURCE_NOT_FOUND",
"details": { "resource": "task" }
}
【サーバーの構造化ログ (Cloud Logging / Datadog)】
{
"level": "warn",
"code": "RESOURCE_NOT_FOUND",
"details": { "resource": "task" },
"logDetails": {
"taskId": "task_999",
"cause": "Record does not exist in DB"
}
}
型分離の価値
- 情報漏洩の根絶:
detailsには公開可能な固定情報しか定義できないため、うっかりスタックトレースや内部 ID、SQL エラーをフロントエンドに垂れ流す事故が構造的に起きません。 - 調査性の向上:
漏洩を恐れてログ情報を削る必要がなく、
logDetailsに調査に必要なコンテキスト(引数、エラー原因、内部状態)を思う存分詰め込めます。
関心の分離がコードベースを守る
「ログを吐く」「エラーをレスポンスに変換する」という処理は、一見すると小さな作業に見えます。
しかし、それらが業務コードのあちこちに手書きされていると、コードベース全体の可読性を損ない、セキュリティと監視の両方を脆くしてしまいます。
- 表示(人間向け)と契約(機械向け)を分ける
- 公開(クライアント向け)と内部(サーバー調査用)を型で分ける
- 表現と配送を Global boundary に集約する
この 3 つの原則を徹底するだけで、リファクタリングに強く、本番トラブルの調査にも即座に対応できる強靭なバックエンド基盤を築くことができます。