【注記】 2026-09-03、実装とコメント・設計文書の食い違いを洗い出すために全ファイルを精読した記録です(3分割の1つ)。 ここで見つかった26件のうち、直すべき11件を
docs/handoff/指示書D_実装解析の不整合.mdに整理しています。 当時の記録であり、現行方針ではありません。 行番号は当時のものです。現状はdocs/handoff.mdを参照してください。
対象リポジトリ: C:\Users\daich\dev\d-watch 読み取りのみ実施(リポジトリは一切変更していない)。行番号はすべて解析時点の実ファイルのもの。
| 専門用語 | 平易な言い換え | このアプリでの実体 |
|---|---|---|
| 純粋関数(pure function) | 同じ材料を入れれば必ず同じ答えが出て、外の世界に手を出さない関数 | lib/judge/(判定ロジック) |
| 非純粋層(impure layer) | 外の世界に触る係。DBを読む・ファイルを書く・時計を見る・ログを吐く | lib/db lib/logger lib/api lib/settings lib/fixtures |
| 副作用(side effect) | 関数を呼ぶと、答えを返す以外に世界が変わってしまうこと(ファイルができる、ログが1行増える) | writeOverrides() がJSONを書く、logEvent() が標準出力に1行出す |
| 依存の向き | 「どちらがどちらを知っているか」の矢印 | 非純粋層 → 純粋層 の一方向のみ。逆向きは禁止(docs/logging-design.md:91-92) |
docs/logging-design.md:91-92 にこの層の定義が書かれている:
lib/logger/はlib/settings/と同格の非純粋 I/O 層。lib/judge/から import してはいけない(依存方向を一方向に保つ)。
実際に lib/judge/ 配下で logger を検索すると1件もヒットしない(規約が守られている)。
このアプリで最重要の切り替え点。 全129行。
// 18-27行
import type {
CleanLogItem,
DsAlert,
DsDevice,
Facility,
InspectionDetail,
InspectionListItem,
PrecheckItem,
Schedule,
} from "@/lib/types";
// 29-38行
import {
cleanLogs,
dsAlerts,
dsDevices,
facilities,
inspectionDetails,
inspections,
prechecks,
schedules,
} from "@/lib/fixtures";
何を import していないかが重要:
@prisma/client を import していない。 ファイル名が db でコメントに Prisma と書いてあるのに、Prisma のライブラリは1行も読み込まれていない。つまり現状このファイルはDBに接続する能力を持っていない(後述の notImplemented がその代わり)。lib/logger を import していない。 docs/logging-design.md:198-200 は「Prisma 実装が入ったら lib/db/ で judge.grade を記録する。lib/db/ は非純粋層なので lib/logger/ を import してよい」と将来を指示しているが、まだその段階に来ていない。lib/judge/ を import していない。 同 §5(docs/logging-design.md:194-196)が「judgeGrade は現在 lib/fixtures/clean-logs.ts の中でしか呼ばれていない」と書いている通り、判定はこの層を通らない。import type と書いてあるのは「型だけ借りる。実行時には消える」という宣言(TypeScript の型専用インポート)。@/lib/fixtures 側は値(実データの配列)なので import type ではない。export const USE_FIXTURES = process.env.USE_FIXTURES !== "false"; // 40行
export interface DataSource { // 47-56行
cleanLogs(): Promise<CleanLogItem[]>;
prechecks(): Promise<PrecheckItem[]>;
dsDevices(): Promise<DsDevice[]>;
dsAlerts(): Promise<DsAlert[]>;
facilities(): Promise<Facility[]>;
schedules(): Promise<Schedule[]>;
inspections(): Promise<InspectionListItem[]>;
inspectionDetail(id: number): Promise<InspectionDetail | null>;
}
export const db: DataSource = USE_FIXTURES ? fixtureSource : prismaSource; // 129行
fixtureSource(60行)・prismaSource(102行)・notImplemented(96行)は export されていない(const / function に export が付いていない)。外から直接触れないので、切り替えを迂回できない構造になっている。
USE_FIXTURES の判定式export const USE_FIXTURES = process.env.USE_FIXTURES !== "false";
(lib/db/index.ts:40)
!== "false" の意味を分解する:
| 用語 | 平易な言い換え |
|---|---|
process.env.USE_FIXTURES |
OS/実行環境が渡してくる「環境変数」という名前札付きの文字列。.env.local に書いた行がここに入る |
!== |
「型も含めて等しくない」。JavaScript の != は型を勝手に揃えてしまうが、!== は揃えない厳密比較 |
"false" |
文字列の "false"。真偽値の false ではない |
環境変数は必ず文字列として届く。USE_FIXTURES=false と書いても、プログラムから見えるのは真偽値ではなく5文字の文字列 "false" である。だから真偽値と比べる書き方(=== false)では絶対に一致しない。
実際の挙動を全パターン並べると:
.env の書き方 |
process.env.USE_FIXTURES の値 |
USE_FIXTURES |
使われるデータ源 |
|---|---|---|---|
USE_FIXTURES=false |
"false" |
false |
Prisma(= notImplemented で即エラー) |
USE_FIXTURES=true |
"true" |
true |
サンプルデータ |
| 行そのものが無い | undefined |
true |
サンプルデータ |
USE_FIXTURES= |
""(空文字) |
true |
サンプルデータ |
USE_FIXTURES=FALSE |
"FALSE" |
true |
サンプルデータ(大文字は効かない) |
USE_FIXTURES=0 |
"0" |
true |
サンプルデータ |
USE_FIXTURES=no |
"no" |
true |
サンプルデータ |
設計上の狙い: 「明示的に false と書いたときだけ実DBを見る。それ以外は全部サンプルデータ」という安全側(fail-safe)に倒した既定値になっている。もし === "true" と書いていたら、環境変数を書き忘れた瞬間に実DBを見ようとして落ちる。逆にこの式なら、書き忘れは「サンプルデータで動く」に落ちる。
副作用として注意すべき点(推測ではなく式から確定できること): FALSE / 0 / no はすべて「fixtures」と解釈される。実DBに切り替えるとき、綴りを1文字間違えるとサンプルデータのまま動き続け、しかもエラーも警告も出ない。これを検知する手段が lib/logger 側の data_source フィールド(後述)である。docs/logging-design.md:118 が data_source を「必須」と定めている理由がここにある。
ログ側で再解釈してはいけない、という明文の指示: docs/logging-design.md:393-395
lib/db/index.ts:40がexport const USE_FIXTURES = process.env.USE_FIXTURES !== "false"を公開している。ログ側でprocess.env.USE_FIXTURESを再解釈しないこと。 この export を import してmixin()で読む。二重解釈すると判定条件がズレる。
実装はこれに従っている(lib/logger/index.ts:8 で import、:49 で使用)。
DataSource インターフェースの全メソッドinterface(インターフェース)= 「この形をしていること」という契約書だけ。中身の処理は書かれていない。TypeScript ではコンパイル時に形の一致だけを検査し、実行時には消える。
| # | メソッド | 引数 | 戻り値 | 業務上の意味 | 呼び出し元(grep 実測) | |
|---|---|---|---|---|---|---|
| 1 | cleanLogs() |
なし | Promise<CleanLogItem[]> |
清掃詳細ログの全行 | app/api/clean-logs/route.ts:39, app/api/clean-logs/export/route.ts:68, app/api/clean-logs/[id]/advice/route.ts:41, app/api/clean-logs/[id]/areas/route.ts:36, app/api/dashboard/route.ts:57 |
|
| 2 | prechecks() |
なし | Promise<PrecheckItem[]> |
事前チェック(1時間前・30分前)の結果一覧 | app/api/prechecks/route.ts:51 |
|
| 3 | dsDevices() |
なし | Promise<DsDevice[]> |
D-Security 機器の一覧 | app/api/ds-devices/route.ts:46 |
|
| 4 | dsAlerts() |
なし | Promise<DsAlert[]> |
D-Security のアラート一覧 | app/api/ds-alerts/route.ts:50, app/api/dashboard/route.ts:59 |
|
| 5 | facilities() |
なし | Promise<Facility[]> |
施設マスタ | まだ呼ばれていない | |
| 6 | schedules() |
なし | Promise<Schedule[]> |
清掃スケジュールマスタ | app/api/dashboard/route.ts:58 |
|
| 7 | inspections() |
なし | Promise<InspectionListItem[]> |
点検記録の一覧(見出しだけ) | まだ呼ばれていない | |
| 8 | inspectionDetail(id: number) |
点検記録の主キー | `Promise<InspectionDetail \ | null>` | 点検記録1件の中身。見つからないときは例外ではなく null |
まだ呼ばれていない |
Promise<...>(プロミス)= 「あとで答えが返ってくる約束の券」。DBやファイルを読む処理は時間がかかるので、答えそのものではなく券を先に返す。呼ぶ側は await を付けて券を答えに引き換える。inspectionDetail だけ。他は全部「全件そのまま返す」形になっている。これが次項の設計方針に直結する。InspectionDetail | null は「見つかれば中身、無ければ null」という共用型(union type)。「無い」を例外で表さないのは、404 応答(not_found)が業務上の正常系だから。コメント原文(lib/db/index.ts:42-46):
/** * データソースが実装すべきインターフェース。 * フィルタや集計はこの層では行わず、APIルート側で組み立てます * (実DB接続時にクエリへ落とすときの差分を小さくするため)。 */
平易な言い換え: 「絞り込み(この日付だけ/この施設だけ)と足し算(合計・平均・率)を、この層に書かない。全部返して、APIルート側で絞る」。
なぜそうするのか(コメントの理由をほどく):
いま fixtureSource.cleanLogs() は lib/fixtures の配列をそのまま返している。もしこの層に絞り込みを持たせて cleanLogs(from, to, facilityId) のような形にすると:
.filter() で絞るWHERE 句で絞るという別物の実装が2つでき、両者が同じ結果になることを保証する責任が発生する。境界値(from を含むのか含まないのか、null をどう扱うか)が2箇所でズレると、fixtures では正しいのに実DBでは1行ずれる、という最も切り分けにくいバグになる。
対して現在の形なら、絞り込みロジックは APIルート側の1箇所(lib/clean-logs/query.ts 等)にしか無い。切り替えても通る道が変わらない。コメントの「実DB接続時にクエリへ落とすときの差分を小さくするため」がこれを指している。
この方針の代償(コードから読み取れる事実): cleanLogs() は毎回全件を返す。docs/logging-design.md:221 に「清掃詳細ログは約360行/日・年間約13万行ある」と書かれているので、Prisma 実装時に素直に全件 SELECT すると年間13万行をメモリに載せることになる。したがってこの方針は永続的な設計ではなく、fixtures 期間の意図的な単純化である。(この「いつ見直すか」は lib/db/index.ts にも docs/ にも書かれていない — 推測だが、Prisma 実装時にインターフェース自体が引数付きへ変わる可能性が高い。)
関連する方針(同ファイル 11-12行):
* ★判定ロジック(lib/judge/)はこの切替の外側に置くこと★ * fixture開発中も本番と同じ判定ロジックが走るようにするためです(D-044)。
docs/decisions.md:204(D-044)の原文:
設計上の注意: 判定ロジック(
lib/judge/)は切替の外側に置く。fixture開発中も本番同等のロジックが走るようにする
なぜ重要か: 切り替えの内側に判定を置くと、「fixtures では ◎ が出るのに実DBでは △」という違いが起きたとき、データが違うのかロジックが違うのか分からなくなる。外側に置けば、ロジックは1本しかないので差は必ずデータ側に絞れる。
書き込み禁止の明記(同ファイル 14-16行):
* 【重要】メインDBは原則読み取り専用です。 * 書き込んでよいのは robot_error_reports / control_error_histories の * status / comment / responded_at / resolved_at の4カラムのみ(D-042)。
現在の DataSource に書き込みメソッドは1つも無い(8メソッドすべて読み取り)。docs/logging-design.md:242-243 も「現時点で lib/db/index.ts の DataSource に書き込みメソッドはまだ無い」と一致している。
fixtureSource(60-85行)export されていない const。DataSource 型を明示的に付けている(const fixtureSource: DataSource = {)ので、8メソッドのどれかを書き忘れるとコンパイルが通らない。
const fixtureSource: DataSource = {
async cleanLogs() { return cleanLogs; }, // 61-63行
async prechecks() { return prechecks; }, // 64-66行
async dsDevices() { return dsDevices; }, // 67-69行
async dsAlerts() { return dsAlerts; }, // 70-72行
async facilities() { return facilities; }, // 73-75行
async schedules() { return schedules; }, // 76-78行
async inspections() { return inspections; }, // 79-81行
async inspectionDetail(id: number) { // 82-84行
return inspectionDetails.find((i) => i.id === id) ?? null;
},
};
async を付けているのは、DataSource が Promise を返す契約だから。async 関数は return 値 と書くだけで自動的に「解決済みの Promise」に包んでくれる。inspectionDetails.find((i) => i.id === id) は「配列を先頭から見て、id が一致する最初の要素を返す。無ければ undefined」。?? null は null 合体演算子で、「左が undefined か null なら右を使う」。つまり undefined を null に言い換えている。
Promise<InspectionDetail | null> であって undefined を含んでいないため。find の既定の返り値 undefined をそのまま返すと型が合わない。加えて JSON に変換すると undefined はキーごと消えるので、「無い」を明示的に伝えるには null の方が確実。rows.sort() のような破壊的な操作をすると、サンプルデータ本体が並び替わったまま次のリクエストに持ち越される。(コード上そう動くという事実。これを禁じるコメントは lib/db/index.ts にも lib/fixtures/index.ts にも無い — 推測だが、APIルート側が .filter()/.map()(どちらも新しい配列を作る非破壊操作)を使っているため今のところ問題になっていない。)notImplemented(name: string): never(96-100行)function notImplemented(name: string): never {
throw new Error(
`[db] ${name} のPrisma実装はまだありません。USE_FIXTURES=true で開発してください。`
);
}
(a) 引数と戻り値の業務上の意味
name: string — 呼ばれたメソッド名("cleanLogs" 等)。エラーメッセージに埋めて「どの機能が未実装か」を人間に伝えるためだけに使う。never — 戻り値の型が never(決して返らない)。これは TypeScript の特殊な型で、「この関数は正常に値を返して終わることが無い(必ず例外を投げるか無限に走る)」ことを型で宣言している。
never を付ける実利: 呼び出し側が return notImplemented("cleanLogs"); と書ける。Promise<CleanLogItem[]> を返すべき場所で「何も返さない関数」を return しても、never はあらゆる型の部分型なので型エラーにならない。もし戻り値を void にしていたら、8箇所すべてで型エラーになる。(b) 中の処理(条件分岐は無い)
分岐は1本も無い。無条件に Error を作って throw する。テンプレートリテラル(バッククォート内の ${})でメソッド名を差し込む。メッセージは日本語で、次に何をすべきか(USE_FIXTURES=true で開発)まで書いてある — 全社ルール7の「エラーメッセージを必ず用意する」への対応。先頭の [db] はログを grep する際の目印。
(c) なぜ例外を投げる設計なのか
コメント原文(lib/db/index.ts:89-95):
/** * 実DB実装。 * * TeamWorksがDBを構築し `prisma db pull` でスキーマを取り込んだあとに実装します。 * それまでは呼ばれたら分かるように明示的にエラーを投げます * (黙って空配列を返すと「データがない」のか「未実装」なのか切り分けられないため)。 */
平易な言い換え: 空の配列 [] を返すのが一番ラクだが、それをやると画面に「該当データはありません」と表示される。この表示は「本当に0件だった」ときとまったく同じ見た目になる。だから未実装は未実装として、うるさく失敗させる。
これは docs/decisions.md:253-255(D-048)が別の文脈で述べている同じ思想と一致する:
★空配列ではなく、プロパティごと省略する: 空配列は「当日の失敗が0件」を意味するため、未実装をそれで表すと UI が「失敗なし」として正常表示してしまう。
さらに、この例外がログ設計にまで影響している。docs/logging-design.md:120-122:
data_sourceを必須にする理由:lib/db/index.tsはUSE_FIXTURES=falseで Prisma 未実装だと意図的に throw する設計になっている。この「意図的な未実装エラー」と「本番DBの本当の障害」をログで区別できないと誤診断する。
つまり notImplemented の存在が data_source フィールド必須化の直接の根拠になっている。この2ファイルは設計上つながっている。
呼び出し元: prismaSource の8メソッドすべて(lib/db/index.ts:104, 107, 110, 113, 116, 119, 122, 125)。外部からは呼ばれていない(export されていない)。
prismaSource(102-127行)8メソッドすべてが return notImplemented("メソッド名"); の1行。
const prismaSource: DataSource = {
async cleanLogs() { return notImplemented("cleanLogs"); }, // 103-105行
async prechecks() { return notImplemented("prechecks"); }, // 106-108行
async dsDevices() { return notImplemented("dsDevices"); }, // 109-111行
async dsAlerts() { return notImplemented("dsAlerts"); }, // 112-114行
async facilities() { return notImplemented("facilities"); }, // 115-117行
async schedules() { return notImplemented("schedules"); }, // 118-120行
async inspections() { return notImplemented("inspections"); }, // 121-123行
async inspectionDetail() { return notImplemented("inspectionDetail"); },// 124-126行
};
fixtureSource との違い(比較表):
| 観点 | fixtureSource |
prismaSource |
|---|---|---|
| データの出所 | lib/fixtures/ の TypeScript 配列(プロセス内メモリ) |
存在しない(未実装) |
| DB接続 | 不要 | 必要だが接続コードが1行も無い |
| 呼んだ結果 | 配列が返る | Error が投げられる |
inspectionDetail の引数 |
(id: number) を受け取り実際に使う |
引数を書いていない(async inspectionDetail())。使わない引数を書かないのは lint の「未使用変数」を避けるため。JavaScript の関数は宣言より多くの引数を受け取れるので、型契約は満たされる |
| 非同期の実態 | 見た目は async だが待つものが無く即座に解決 |
async 関数の中で throw するので、例外ではなく「拒否された Promise」になる。呼ぶ側は try { await db.cleanLogs() } catch で捕まえられる |
| 行数 | 26行 | 26行(対称になるよう同じ形に揃えられている) |
「async 関数の中の throw」の意味(重要な細部): 通常の関数で throw すると、呼んだ瞬間に例外が飛ぶ。async 関数の中で throw すると、関数呼び出し自体は成功して「失敗した Promise」が返る。だから await を付け忘れると例外が飛ばずに黙って進む。APIルート側は実際に rows = await db.cleanLogs(); と await を付けており(app/api/clean-logs/route.ts:39)、その周囲の try/catch で logApiError に渡している(:44)。
db(129行)export const db: DataSource = USE_FIXTURES ? fixtureSource : prismaSource;
条件 ? A : B)で、モジュールが最初に読み込まれた1回だけどちらかに決まる。以降は固定。const なので実行中に切り替えられない。 サーバーを再起動しない限り変わらない。「本番稼働中に環境変数だけ書き換えて切り替える」ことはできない。: DataSource を明示しているので、db. の後に補完で出るのは契約の8メソッドだけ。fixtures 固有のもの(boundaryCases 等)は見えない。lib/db/index.ts:9 のコメント「★画面側のコードは切替を意識しません。APIルートがこのモジュールを呼ぶだけです★」の通り、db を import しているのは app/api/**/route.ts の9ファイルのみで、components/ からは1件も参照が無い(grep 実測)。呼び出し元(db. の grep 実測、9ファイル):
app/api/clean-logs/route.ts:39 rows = await db.cleanLogs(); app/api/clean-logs/export/route.ts:68 rows = await db.cleanLogs(); app/api/clean-logs/[id]/advice/route.ts:41 logs = await db.cleanLogs(); app/api/clean-logs/[id]/areas/route.ts:36 logs = await db.cleanLogs(); app/api/dashboard/route.ts:57-59 db.cleanLogs(), db.schedules(), db.dsAlerts() app/api/ds-alerts/route.ts:50 rows = await db.dsAlerts(); app/api/ds-devices/route.ts:46 rows = await db.dsDevices(); app/api/prechecks/route.ts:51 rows = await db.prechecks();
app/api/settings/advice/route.ts は db を使わない(設定ファイルを読むだけなので lib/settings/advice.ts を使う)。
全73行。AsyncLocalStorage という1つの仕組みだけで構成されたファイル。
// 9行
import { AsyncLocalStorage } from "node:async_hooks";
これ1本だけ。 何を import していないかが極めて重要:
pino を import していない。 ログライブラリを知らない。だから「ID を保管する」という仕事だけに専念できている。@/lib/logger(親の index.ts)を import していない。 逆に index.ts がこちらを import している(lib/logger/index.ts:9)。矢印が一方向で、循環参照(お互いを import して読み込み順が決まらなくなる状態)が起きない。next を import していない。 Next.js に依存しないので、テストから素の関数として呼べる(lib/logger/__tests__/context.test.ts が実際にそうしている)。node: という接頭辞は「これは Node.js 本体の標準機能で、npm から入れたパッケージではない」という明示。同名の npm パッケージに間違って解決されるのを防ぐ書き方。export function runWithRequestId<T>(requestId: string, fn: () => T): T // 21行 export function getRequestId(): string | undefined // 29行 export const REQUEST_ID_HEADER = "x-request-id"; // 34行 export function normalizeRequestId( value: string | string[] | undefined | null ): string | undefined // 52-54行 export function withRequestId<T>(request: Request, fn: () => T): T // 70行
export していない内部要素:
interface RequestLogStore { requestId: string; } // 11-13行
const requestLogStorage = new AsyncLocalStorage<RequestLogStore>(); // 15行
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/; // 43行
requestLogStorage が export されていないのは意図的。外から .run() を直接呼べてしまうと、形式検査(normalizeRequestId)を迂回して壊れた ID を入れられる。
| 専門用語 | 平易な言い換え |
|---|---|
| AsyncLocalStorage | 「非同期処理をまたいで値を持ち回る仕組み」。 「この作業の間だけ有効な、その作業専用の付箋」。作業の中でさらに別の作業を呼んでも、付箋は自動的についていく |
| 非同期処理(asynchronous) | 「終わるまで待たない処理」。DBに問い合わせて答えを待つ間、プログラムは別の仕事に移る |
| 同時実行(concurrency) | 1台のサーバーが、複数の人からのリクエストを同時に相手にしている状態 |
| ストア(store) | 付箋に書いてある中身。ここでは { requestId: "..." } というオブジェクト1個だけ |
| 相関ID(correlation id / request_id) | 1回のリクエストに付ける通し番号。ログを後から読むとき、この番号で grep すれば「その1回のリクエストが出した全ログ」だけを取り出せる |
| 伝播(propagation) | 値が呼び出しの連鎖を勝手にたどって届くこと |
まず「グローバル変数でやったつもりの版」を書いてみる(これは実装には無い。説明用の仮のコード):
let currentRequestId = ""; // ← 全体で1個しかない箱
function handle(request) {
currentRequestId = makeId(); // 箱に入れる
const rows = await db.cleanLogs(); // ★ここで待つ★
logger.info({ request_id: currentRequestId }); // 箱から読む
}
Web サーバーは同時に複数のリクエストを相手にしている。上のコードで、Aさんのリクエストとほぼ同時にBさんのリクエストが来ると:
| 時刻 | 起きること | currentRequestId の中身 |
|---|---|---|
| t1 | Aのリクエスト開始。箱に "A-001" を入れる |
"A-001" |
| t2 | AがDB読みで待ちに入る(Node.js は待っている間、別の仕事をする) | "A-001" |
| t3 | Bのリクエスト開始。箱に "B-002" を上書き |
"B-002" |
| t4 | AのDB読みが終わって処理が再開。箱を読む | "B-002" ← Bの番号を読んでしまう |
| t5 | Aのログに request_id: "B-002" と記録される |
— |
これが起きると、ログの相関が壊れる。しかも壊れ方が「たまに、負荷が高いときだけ」なので、開発中は1人でアクセスするため絶対に再現しない。本番で調査が必要になった瞬間にログが信用できないことに気づく、という最悪の順序になる。
Node.js の性質上これは必ず起きる。「1リクエスト=1スレッド」ではなく、1つのスレッドが待ち時間に別のリクエストへ切り替わるモデルだから、「今どのリクエストの処理中か」をグローバル変数で表すこと自体が原理的に成立しない。
AsyncLocalStorage が解決すること: 箱を「全体で1個」ではなく「待ちをまたいで追跡される、作業ごとの箱」にする。Node.js の内部が「この非同期処理は、どの run() の内側から始まったか」を記録しているので、t4 で処理が再開したときにも A の箱が復元される。
上の表を AsyncLocalStorage 版で書き直すと:
| 時刻 | 起きること | A から見える値 | B から見える値 |
|---|---|---|---|
| t1 | runWithRequestId("A-001", ...) の内側で処理開始 |
"A-001" |
(まだ無い) |
| t2 | AがDB読みで待ちに入る | "A-001" |
— |
| t3 | runWithRequestId("B-002", ...) の内側でBが開始 |
"A-001" |
"B-002" |
| t4 | Aが再開。getRequestId() を呼ぶ |
"A-001" |
"B-002" |
引数で持ち回る方式との違い: 「グローバルが駄目なら引数で渡せばいい」という案もあるが、それをやると request_id を使いたい最深部の関数まで、途中の全ての関数の引数に requestId を追加する必要がある。docs/logging-design.md:66 はこの案を別の理由で明確に却下している:
| 判定関数に logger を引数で渡す |
CLAUDE.md§9.4 の「判定関数は純粋関数(DBアクセスを混ぜない)」規約に違反する |
つまり「引数で回す」を選ぶと、純粋関数であるべき lib/judge/ にログの都合が侵入する。AsyncLocalStorage なら lib/judge/ は一切変更せずに済む。
コメント原文(lib/logger/context.ts:1-8):
/** * リクエスト相関 ID の格納。 * * `proxy.ts` は別バンドルになる可能性があり、公式ガイドは Proxy から * 共有モジュールやグローバルへ依存することを禁じている。 * そのため ID の発行は Proxy、格納はルート側の AsyncLocalStorage に分け、 * pino の mixin() が同じストアを読んで全ログへ request_id を自動付与する。 */
docs/logging-design.md:65 が却下案として同じことを書いている:
|
proxy.tsと各route.tsで AsyncLocalStorage インスタンスを共有 | 公式ガイドが「Proxy は共有モジュールやグローバルに依存すべきでない」と明記。別バンドルの可能性がある |
平易な言い換え: 「別バンドル」= ビルド時に別のファイル群にまとめられること。別バンドルになると、同じ import を書いていてもインスタンスが2個できる(new AsyncLocalStorage() が2回走る)。片方に入れた値をもう片方から読めない。だから「発行は proxy 側、保管はルート側」と役割を分け、両者はHTTPヘッダという共通の土管(x-request-id)だけで会話する。
なお proxy.ts は現時点で存在しない(ls proxy.ts → No such file)。docs/decisions.md:241-242(D-047)がこれを認めている:
proxy.tsが未着手のため、現在はヘッダが無ければルート側で生成する(proxy.ts投入後もそのまま動く)
シグネチャ: runWithRequestId<T>(requestId: string, fn: () => T): T(lib/logger/context.ts:21-23)
export function runWithRequestId<T>(requestId: string, fn: () => T): T {
return requestLogStorage.run({ requestId }, fn);
}
(a) 引数と戻り値の業務上の意味
requestId: string — このひとかたまりの処理に付ける通し番号。fn: () => T — 「引数を取らず T を返す関数」。「この番号を有効にした状態で走らせたい処理」の本体をまるごと関数として渡す。これをコールバックと呼ぶ。T — fn が返したものをそのまま返す。中身を加工しないのが重要(呼ぶ側から見て「ラップしてもしなくても同じ値が返る」)。<T>(ジェネリクス)— 「中身の型は呼ぶ側が決めていい」という穴あき。fn が Response を返せば T = Response、Promise<Response> を返せば T = Promise<Response>。ログの都合で戻り値の型が変わってしまわないようにする仕掛け。(b) 中の処理
条件分岐は1本も無い。AsyncLocalStorage.run(ストア, 関数) を呼ぶだけ。{ requestId } は短縮記法で { requestId: requestId } と同じ意味。
.run() の挙動(Node.js 標準の仕様): 第2引数の関数を即座に実行し、その関数の中と、そこから始まった全ての非同期処理の中で getStore() が第1引数を返すようになる。関数が終わると元に戻る。
注意すべき細部: fn が async 関数の場合、.run() が返るのは「Promise が返った時点」であって「処理が終わった時点」ではない。にもかかわらず値が読めるのは、AsyncLocalStorage が Promise の継続(await の後ろ)まで追跡するから。テストがこれを検証している(lib/logger/__tests__/context.test.ts:62-66「await をまたいでも同じ ID」)。
(c) なぜそう書いてあるか
コメント原文(lib/logger/context.ts:17-20):
/** * 指定した request_id を後続の非同期処理に伝播させる。 * ルート先頭でラップし、その内側の logger 呼び出しが同一 ID を持てるようにする。 */
呼び出し元:
lib/logger/context.ts:72(withRequestId の中から)instrumentation.ts:76 — runWithRequestId(requestId, () => logEvent("error", event, error));lib/logger/index.ts:126 で再 exportlib/logger/__tests__/context.test.ts:76, lib/logger/__tests__/smoke.test.ts:41, 212, 280APIルートからは直接呼ばれていない(ルートは withRequestId を使う)。instrumentation.ts だけが直接呼ぶ。理由は後述(onRequestError は Request オブジェクトではなく素のヘッダ辞書しか持たないため)。
シグネチャ: getRequestId(): string | undefined(lib/logger/context.ts:29-31)
export function getRequestId(): string | undefined {
return requestLogStorage.getStore()?.requestId;
}
(a) 引数と戻り値
string | undefined — 番号があれば文字列、run() の外側で呼ばれた場合は undefined。(b) 中の処理
getStore() は「今の作業の付箋」を返す。付箋が無ければ undefined。?. はオプショナルチェーン。「左が null/undefined なら、その先を読まずに全体を undefined にする」。これが無いと undefined.requestId で TypeError になってログを取ろうとした結果アプリが落ちる。?. に畳み込まれた1本だけ。(c) なぜそう書いてあるか
コメント原文(lib/logger/context.ts:25-28):
/** * 現在の request_id。ストア未設定時は undefined。 * mixin 側では欠落を pino がキーごと省略しないよう "-" に落とす。 */
undefined を返したまま pino に渡すと JSON からキーが消える(JSON.stringify({a: undefined}) は {} になる)。すると「相関 ID が無いログ」と「相関 ID フィールド自体が存在しないログ」の区別がつかず、grep のパターンも揃わない。だから lib/logger/index.ts:48 で getRequestId() ?? "-" と書き、必ずキーが存在する形にしている。「無い」ことを "-" という値で明示する設計。
呼び出し元: lib/logger/index.ts:48(mixin の中)、lib/logger/index.ts:127 で再 export、テスト lib/logger/__tests__/context.test.ts の多数箇所。アプリ本体のコードから直接呼んでいる箇所は無い(mixin が自動で読むので呼ぶ必要が無い)。
lib/logger/context.ts:33-34
/** 相関IDヘッダ名(docs/logging-design.md §10 で確定済み) */ export const REQUEST_ID_HEADER = "x-request-id";
文字列を定数にしている理由: 3箇所(context.ts:71、instrumentation.ts:74、テスト)で同じヘッダ名を使うので、綴りミスが起きると片方だけ効かなくなる。しかもそのバグは「相関 ID がたまに付かない」という気づきにくい形で出る。
docs/logging-design.md:370 の「確定させた名前」表に | 相関IDヘッダ | x-request-id | — | とあり、実装と一致している。
小文字なのは、HTTP/2 以降のヘッダ名が小文字で正規化されるため、および Headers.get() が大文字小文字を区別しないため。ただし instrumentation.ts:74 は request.headers[REQUEST_ID_HEADER] と素のオブジェクトの添字で読んでおり、こちらは大文字小文字を区別する(Node.js の生ヘッダは小文字化されて届くので実害は無い)。
シグネチャ(lib/logger/context.ts:52-58):
export function normalizeRequestId(
value: string | string[] | undefined | null
): string | undefined {
const raw = Array.isArray(value) ? value[0] : value;
if (!raw || !REQUEST_ID_PATTERN.test(raw)) return undefined;
return raw;
}
(a) 引数と戻り値の業務上の意味
value: string | string[] | undefined | null — HTTPヘッダから読んだ生の値。4つの型を受ける理由:
string — 普通のケース(Headers.get() の戻り)。string[] — 同じヘッダが複数回送られた場合。HTTP はヘッダの重複を許すので、Node.js の生ヘッダ辞書は配列で渡してくる。instrumentation.ts:74 の経路がこれ。undefined — ヘッダが無い(Node.js の辞書アクセス)。null — ヘッダが無い(Headers.get() の戻り)。API によって「無い」の表し方が違うので両方受ける。undefined(=「使える ID は無かった」)。(b) 中の処理を条件分岐まで全部
3行で3つの判断をしている。
55行 const raw = Array.isArray(value) ? value[0] : value; 配列なら先頭だけ採る。value[0] は配列が空([])なら undefined になるが、次の行の !raw で弾かれるので安全。 「先頭を採る」という選択の根拠はコメントに書かれていない(推測: 一番外側のプロキシが最初に付けた値が最も信頼できる、という慣行に沿ったもの)。テストは normalizeRequestId(["first-0001", "second-0002"]) → "first-0001" を固定している(lib/logger/__tests__/context.test.ts:92-94)。
56行前半 if (!raw || ...) !raw は偽値(falsy)判定。raw が undefined / null / ""(空文字)のいずれかなら真になって undefined を返す。
x-request-id: と値なしで送れる。空文字をそのまま採用すると request_id: "" というログが出て、"-"(未設定)とも正常値とも違う第3の状態が生まれる。!raw は "0" を弾かない("0" は非空文字列なので truthy)。ID として "0" は正規表現に合格するので通る。56行後半 !REQUEST_ID_PATTERN.test(raw) 正規表現 /^[A-Za-z0-9._-]{1,128}$/ に合わなければ undefined。
正規表現の各部分:
| 記号 | 意味 |
|---|---|
^ |
文字列の先頭。これが無いと途中一致で通ってしまう |
[A-Za-z0-9._-] |
許す文字は英大文字・英小文字・数字・ピリオド・アンダースコア・ハイフンのみ |
{1,128} |
その文字が1個以上128個以下 |
$ |
文字列の末尾。^ と対で「全体が」を意味する |
弾かれるもの(テストが固定しているもの+式から確定できるもの):
| 入力 | 結果 | 弾く理由 | テスト |
|---|---|---|---|
undefined |
undefined |
ヘッダ無し | context.test.ts:98 |
null |
undefined |
ヘッダ無し | context.test.ts:99 |
"" |
undefined |
空文字。!raw で弾く |
context.test.ts:100 |
"has space" |
undefined |
半角スペースが許可文字に無い。スペース入りだとログのフィールド区切りと混ざって読めなくなる | context.test.ts:101 |
| 改行を含む文字列 | undefined |
制御文字。1行1JSON の形式が壊れ、ログを1行ずつ読むツールが誤動作する。ログインジェクション(偽のログ行を注入する攻撃)の入口でもある | context.test.ts:102 |
"a".repeat(129) |
undefined |
129文字は上限超過。長大な値でログを膨らませられるのを防ぐ | context.test.ts:103 |
"a".repeat(128) |
通る | 上限ちょうどは合格({1,128} の境界) |
— |
| 日本語を含む文字列 | undefined |
許可文字が ASCII のみ(式から確定。テストは無い) | — |
記号入り(; / 等) |
undefined |
許可文字に無い(式から確定) | — |
"from-proxy-0001" |
そのまま返る | 英数字とハイフンのみ | context.test.ts:88 |
なぜ検査するのか。 コメント原文(lib/logger/context.ts:36-42):
/** * ヘッダ由来の値をそのまま使わず形を検査する。 * * `proxy.ts`(ステップ7)が入るまではクライアントが直接送れる位置にあり、 * 長大な値や制御文字を入れられるとログが読めなくなる。 * 形が合わない値は捨てて生成し直す。 */
「クライアントが直接送れる位置にある」が要点。 proxy.ts が無い今、ブラウザや curl から x-request-id: 好きな文字列 を送ると、それがそのままログに載る。ログは開発者が読むもので、docs/logging-design.md:147-148 の通りAIチャットに貼り付ける経路が現実にある。そこに任意の文字列を差し込めるのは、単に読みにくいだけでなく「ログの中身が信用できない」状態を作る。
なお docs/logging-design.md:299-300(§8 ステップ7)は、proxy.ts を作る際の方針をこう定めている:
proxy.tsを新規作成。x-request-idの発行と伝播のみ。クライアント由来のx-request-idは信頼せず常に上書き生成する
つまり最終形ではクライアントの値は採用しない(常に上書き)。現在の形式検査は、そこに至るまでの暫定的な防御である。
関数を共有する理由(コメント 45-51行):
/** * ヘッダ値として妥当な相関IDだけを返す。妥当でなければ undefined。 * * `instrumentation.ts` の `onRequestError()` はルートの AsyncLocalStorage の * 外側で走るため、ヘッダから同じ規則で ID を取り直す必要がある。 * 検査規則を1箇所に置くためこの関数を共有する。 */
呼び出し元:
lib/logger/context.ts:71(withRequestId の中)instrumentation.ts:74 — const requestId = normalizeRequestId(request.headers[REQUEST_ID_HEADER]);lib/logger/index.ts:129 で再 exportlib/logger/__tests__/context.test.ts:88-103シグネチャ: withRequestId<T>(request: Request, fn: () => T): T(lib/logger/context.ts:70-73)
export function withRequestId<T>(request: Request, fn: () => T): T {
const fromHeader = normalizeRequestId(request.headers.get(REQUEST_ID_HEADER));
return runWithRequestId(fromHeader ?? crypto.randomUUID(), fn);
}
(a) 引数と戻り値の業務上の意味
request: Request — Web 標準の Request オブジェクト。APIルートのハンドラが受け取るもの。ここからヘッダだけを読む(本文は触らない)。fn: () => T — ルートの処理本体まるごと。T — fn の戻り値そのまま。ルートの場合は Promise<Response>。(b) 中の処理を条件分岐まで全部
request.headers.get(REQUEST_ID_HEADER) — ヘッダを読む。無ければ null。normalizeRequestId(...) — 形式検査。合格すれば文字列、不合格・不在なら undefined。fromHeader ?? crypto.randomUUID() — ??(null 合体演算子)。左が null/undefined のときだけ右を評価する。つまり「ヘッダから使える ID が取れなければ、その場で新しい UUID を作る」。
crypto.randomUUID() は Web 標準の乱数 UUID 生成。ハイフンと16進数だけなので REQUEST_ID_PATTERN にも自然に合格する形。?? と || の違い(細部だが重要): || は空文字 "" や 0 でも右に倒れるが、?? は null/undefined だけ。ここは normalizeRequestId が既に空文字を undefined に正規化しているので実質同じだが、?? の方が意図が明確。runWithRequestId(決まった ID, fn) — AsyncLocalStorage に載せて fn を走らせる。★withRequestId が何を包んでいるか★
包んでいるのはAPIルートのハンドラ本体の全体。実際のルートの形(app/api/clean-logs/route.ts:30):
return withRequestId(request, async () => {
...ルートの全処理...
});
return withRequestId(request, async () => { ... }) という形なので、ハンドラが返す Response を作るまでの全工程(DB読み・判定・整形・catch 節でのエラーログ)が包まれている。だから:
logEvent() にもcatch の中の logApiError() にもdb.cleanLogs() の await を挟んだ後の処理にも同じ request_id が自動で載る。呼ぶ側は request_id を1度も書かない。
(c) なぜそう書いてあるか
コメント原文(lib/logger/context.ts:60-69):
/** * リクエストから request_id を決めてハンドラを包む。 * * `x-request-id` があればそれを使い、無ければ生成する。 * 設計(§1)では `proxy.ts` が発行する前提だが、ステップ7 が保留中でも * 相関IDが欠けないようにこの形にしてある。`proxy.ts` が入ると * ヘッダが必ず存在するようになり、この関数はそのまま動く(手戻りしない)。 * * ★5ルートに同じラップ処理を書き写さないための共通ヘルパー★ */
2つの理由が書かれている:
proxy.ts 未着手への橋渡し。 ヘッダ優先・無ければ生成という順序にしておけば、proxy.ts が入った日にこの関数を1文字も直さなくてよい(ヘッダが必ずあるので生成側の枝が死ぬだけ)。「手戻りしない」がそれ。withRequestId を呼んでいるルートは9ファイル10箇所ある(app/api/settings/advice/route.ts は GET と PUT の2箇所で呼ぶ)。コメントが書かれた時点より画面が増えている。呼び出し元(実測、10箇所):
app/api/clean-logs/route.ts:30 app/api/clean-logs/export/route.ts:59 app/api/clean-logs/[id]/advice/route.ts:32 app/api/clean-logs/[id]/areas/route.ts:27 app/api/dashboard/route.ts:31 app/api/ds-alerts/route.ts:32 app/api/ds-devices/route.ts:28 app/api/prechecks/route.ts:30 app/api/settings/advice/route.ts:27 (GET) app/api/settings/advice/route.ts:51 (PUT)
加えて lib/logger/index.ts:128 で再 export、テスト lib/logger/__tests__/api.test.ts:145 と lib/logger/__tests__/context.test.ts の多数箇所。
instrumentation.ts が withRequestId を使わない理由(コメント instrumentation.ts:71-73):
// onRequestError はルートの AsyncLocalStorage の外で走るため、 // ヘッダから相関IDを取り直す。無ければ mixin が "-" にする // (ここで新規生成しても紐づく先が無く、偽の相関を作るだけ)
ここが withRequestId との決定的な違い。 withRequestId は「無ければ生成」するが、onRequestError は生成しない。理由は「紐づく先が無く、偽の相関を作るだけ」。捕捉漏れのエラーは既に走っていたリクエストの一部なので、そこで新しい番号を振ると「その番号を持つログが世界にこの1行しか無い」状態になる。それは相関 ID としての意味を持たないどころか、grep したときに「他のログが消えている」と誤解させる。だから "-" のまま出す。
全132行。ロガーの本体。pino の設定と、書く前の検査の順序がここに集まっている。
// 7-18行
import pino from "pino";
import { USE_FIXTURES } from "@/lib/db";
import { getRequestId } from "@/lib/logger/context";
import type {
ForbiddenFieldsStrippedLogEvent,
LogEvent,
} from "@/lib/logger/events";
import {
enforceForbiddenErrorFieldsPolicy,
enforceForbiddenFieldsPolicy,
} from "@/lib/logger/redact";
import { formatLogTimestamp } from "@/lib/logger/timestamp";
何を import していないか:
process.env.USE_FIXTURES を直接読んでいない。 lib/db から USE_FIXTURES を import している。これは docs/logging-design.md:393-395 の明示的な禁止事項への対応(「ログ側で process.env.USE_FIXTURES を再解釈しないこと」)。pino-pretty を import していない。 package.json の devDependencies には入っているが、コードからは呼ばれていない(後述の食い違い項目)。node:fs を import していない。 ファイルを書かない設計(stdout のみ)。docs/logging-design.md:40 の決定通り。redact.ts からは enforce* の2本だけを import している。findForbiddenFields や stripForbiddenFields は import していない。環境による分岐の判断を redact.ts 側に閉じ込めているため(lib/logger/redact.ts:79-80 のコメント参照)。import pino from "pino" はデフォルトインポート。pino(...) として関数呼び出しでき、pino.Logger / pino.LoggerOptions / pino.DestinationStream として型も参照できる。export type LogLevel = "info" | "warn" | "error"; // 20行
export function createLogger(destination?: pino.DestinationStream): pino.Logger // 36行
export const logger = createLogger(); // 56行
export function emitTo(
target: pino.Logger,
level: LogLevel,
payload: LogEvent,
err?: unknown
): void // 71-76行
export function logEvent(level: "error", payload: LogEvent, err?: unknown): void; // 111-115行(オーバーロード宣言1)
export function logEvent(level: "info" | "warn", payload: LogEvent): void; // 116行(オーバーロード宣言2)
// 125-132行 再 export
export {
runWithRequestId,
getRequestId,
withRequestId,
normalizeRequestId,
REQUEST_ID_HEADER,
} from "@/lib/logger/context";
export type { LogEvent } from "@/lib/logger/events";
export していない内部関数:
function resolveLevel(): string // 22行 function isProduction(): boolean // 27行
再 export(125-132行)の意味: context.ts の中身を @/lib/logger からも取れるようにしている。だから APIルートは import { logEvent, withRequestId } from "@/lib/logger";(app/api/clean-logs/[id]/advice/route.ts:13)と1行で済む。呼ぶ側が @/lib/logger/context という内部構造を知らなくてよい。これをファサード(窓口)パターンと呼ぶ。
注意: logApiError は再 export されていない。ルートは import { logApiError } from "@/lib/logger/api"; と別行で import している。
シグネチャ: resolveLevel(): string(lib/logger/index.ts:22-25)
function resolveLevel(): string {
if (process.env.LOG_LEVEL) return process.env.LOG_LEVEL;
return isProduction() ? "info" : "debug";
}
(a) 引数と戻り値の業務上の意味
"debug" / "info" / "warn" / "error" など)。用語: ログレベル = 「重要度の段階」。pino は trace < debug < info < warn < error < fatal の順で、設定した段よりも低い段のログは出力自体をしない(呼んでも何も起きない)。
(b) 中の処理を条件分岐まで全部
分岐は2段。
if (process.env.LOG_LEVEL) — 環境変数があればそれを無検査でそのまま返す。
if (値) の偽値判定なので、LOG_LEVEL=(空文字)は「無い」扱いになり次の段へ落ちる。LOG_LEVEL=abc と書くと pino のインスタンス生成時にエラーになる(pino 側が未知のレベル名を拒否する)。この検査が無いのは意図的なのかどうかコメントに書かれていない — 推測だが、起動時に即座に失敗するので「黙って壊れる」形にはならないため許容している。return isProduction() ? "info" : "debug"; — 環境変数が無ければ NODE_ENV から決める。本番は info、それ以外は debug。(c) なぜそう書いてあるか
docs/logging-design.md:28-29:
next.config.jsのloggingオプションは開発時のターミナル出力にしか効かない。本番ログには無関係なので、別途LOG_LEVEL環境変数を用意する。
docs/logging-design.md:367(確定させた名前の表):
| ログレベル |
LOG_LEVEL| 環境変数。開発はdebug、本番はinfo|
実装と一致。.env.example にも LOG_LEVEL=info の行がある(§8 ステップ12 完了)。
なぜ開発は debug なのか: 開発中は情報が多い方がよく、debug にすれば info 以上も全部出る。本番で debug にすると 13万行/年のデータを扱うアプリで出力が膨らむ。
呼び出し元: lib/logger/index.ts:38(createLogger の options の中)のみ。
シグネチャ: isProduction(): boolean(lib/logger/index.ts:27-29)
function isProduction(): boolean {
return process.env.NODE_ENV === "production";
}
(a)(b) 引数なし。NODE_ENV が文字列としてちょうど "production" のときだけ true。=== の厳密比較なので "Production" や "prod" は false。分岐は1本。
(c) なぜ関数にしているか: 3箇所(resolveLevel の中、emitTo:77、それを通じて enforce*Policy へ渡す)で同じ判定が必要で、判定式を1箇所に置くため。USE_FIXTURES を lib/db の1箇所に集めるのと同じ思想。
注意すべき細部: NODE_ENV が未設定なら false(=非本番扱い)になる。非本番では禁止フィールド検出時に例外を投げるので、NODE_ENV の設定を忘れて本番起動すると「ログ機構が原因でリクエストが 500 になる」危険側に倒れる。Next.js は next start 時に NODE_ENV=production を自動設定するので実務上は起きないが、式としてはそうなっている(推測を含まない事実指摘)。
シグネチャ: createLogger(destination?: pino.DestinationStream): pino.Logger(lib/logger/index.ts:36-54)
export function createLogger(destination?: pino.DestinationStream): pino.Logger {
const options: pino.LoggerOptions = {
level: resolveLevel(),
timestamp: () => `,"timestamp":${JSON.stringify(formatLogTimestamp())}`,
formatters: {
/** 数値レベルだと grep しづらいので info/warn/error の文字列にする */
level(label) {
return { level: label };
},
},
mixin() {
return {
request_id: getRequestId() ?? "-",
data_source: USE_FIXTURES ? "fixtures" : "prisma",
};
},
};
return destination ? pino(options, destination) : pino(options);
}
(a) 引数と戻り値の業務上の意味
destination?: pino.DestinationStream — 出力先。? は「省略してよい」。省略すると標準出力(stdout)に出る。テストは自前のストリームを渡して出力を捕まえる。pino.Logger — .info() / .warn() / .error() を持つロガー1個。用語: インスタンス = 設定を焼き込んだ「動く実体」。pino(options) を呼んだ時点で設定が確定し、以後この実体はその設定で動き続ける。
options に入れている4項目を1つずつ。
1. level: resolveLevel()(38行)
しきい値。関数呼び出しの結果を入れているので、createLogger() が呼ばれた瞬間の LOG_LEVEL が焼き込まれる。後から環境変数を書き換えても効かない。
2. timestamp: () => ...(39行)— ★差し替えている理由★
timestamp: () => `,"timestamp":${JSON.stringify(formatLogTimestamp())}`,
この1行は見た目が異常なので分解する。
timestamp オプションは「JSON の断片を文字列で返す関数」を受け取る仕様。オブジェクトを返すのではなく、,"キー":値 という生の JSON テキストを返す。行頭のカンマまで自分で書くのがこの仕様の作法(pino が組み立て中の JSON にそのまま連結するため。この形にすることで、ログ1行ごとに JSON.stringify をかけ直す無駄を省いている)。JSON.stringify(formatLogTimestamp()) — 文字列を JSON の文字列リテラルに変換する(両端に " が付き、中の特殊文字がエスケープされる)。日付文字列に " は入らないが、手で " を足すより安全。{"level":"info","timestamp":"2026-09-02T18:32:20.123+09:00","request_id":"...", ...}★なぜ差し替えるのか★
理由が3箇所に書かれている。
lib/logger/timestamp.ts:1-5(差し替え先のファイルのコメント):
/** * ログ用タイムスタンプ。pino 既定の epoch ms は使わない。 * CLAUDE.md §9.3 に合わせ ISO 8601 JST(+09:00)にする。 * ホスト TZ に依存しないよう Asia/Tokyo で組み立てる。 */
docs/logging-design.md:374-377:
日時: pino の既定は
timeフィールドの epoch ms。timestampオプションにカスタム関数を渡し、フィールド名timestamp・ISO 8601+09:00の文字列にすること(CLAUDE.md§9.3 の日時規約)。既定のまま放置しない。ミリ秒まで含めること(2026-08-27T18:32:20.123+09:00)。秒精度だと同一秒内の複数リクエストの順序が追えず、pino 既定より後退する
docs/decisions.md:235-236(D-047):
決定:
pinoで1行1JSON を stdout のみに出す。日時は ISO 8601 JST(+09:00、ミリ秒付き)でフィールド名はtimestamp
平易にまとめると差し替えの理由は3つ:
| # | 理由 | 差し替えないと何が起きるか |
|---|---|---|
| 1 | フィールド名を time から timestamp へ |
他のログ・仕様書(CLAUDE.md §9.3)が timestamp で統一されているのに、ログだけ time になり grep パターンが揃わない |
| 2 | 形式を epoch ms から ISO 8601 JST へ | pino 既定は 1756800740123 のような数値(1970年から数えたミリ秒)。人間が見て「いつか」が分からず、目で追うたびに変換が必要になる |
| 3 | タイムゾーンを +09:00 に固定 | サーバーの OS 設定が UTC なら 9時間ずれた時刻が残る。深夜稼働のアプリなので日付が1日ずれる |
そして「ミリ秒を残す」ことにも独立の理由がある(lib/logger/timestamp.ts:17-23):
/** * 例: `2026-08-27T18:32:20.123+09:00` * * ミリ秒まで残すのは、秒精度だと同一秒内の複数リクエストの順序が追えず * pino 既定(epoch ms)より情報が後退するため。 * ミリ秒はタイムゾーンに依存しないので Date から直接取る。 */
つまり「読みやすさのために ISO 8601 にする」だけだと、うっかり秒精度にしてpino 既定より情報が減る。読みやすさと精度の両方を満たすためにミリ秒付きにしている。
3. formatters.level(40-45行)
formatters: {
/** 数値レベルだと grep しづらいので info/warn/error の文字列にする */
level(label) {
return { level: label };
},
},
pino は既定でレベルを数値で出す(info = 30、warn = 40、error = 50)。formatters.level に関数を渡すと、その戻り値のオブジェクトがそのまま JSON に入る。label は "info" のような文字列なので、{ level: "info" } になる。
コメントの理由がそのまま答え: grep '"level":"error"' app.log と書けるようにするため。数値のままだと "level":50 と覚えなければならず、しかも 50 が error だと知らない人には読めない。docs/logging-design.md:114 の共通フィールド表も | level | info / warn / error | と文字列を指定している。
4. mixin()(46-51行)— ★mixin() が何をするか★
mixin() {
return {
request_id: getRequestId() ?? "-",
data_source: USE_FIXTURES ? "fixtures" : "prisma",
};
},
用語 → 平易な言い換え:
| 専門用語 | 平易な言い換え |
|---|---|
| mixin(ミックスイン) | 「全部のログ行に自動で混ぜ込む共通の項目」を作る関数。日本語で言えば「全ログ共通のハンコ」 |
何をするか(pino の仕様): mixin に関数を渡すと、pino はログを1行出すたびにその関数を呼び、返ってきたオブジェクトのキーをログ行に足す。呼ぶ側(logEvent("info", {...}))は request_id も data_source も1度も書かないのに、出力には必ず入る。
「毎回呼ばれる」ことが本質的に重要。 もし mixin ではなく pino({ base: { request_id: ... } }) のように生成時に1回だけ評価する形で書いていたら、サーバー起動時の request_id が全ログに焼き付いてしまい、リクエストごとに変わらない。mixin は毎回呼ばれるから、その瞬間の AsyncLocalStorage を読める。
2つのフィールドの意味と理由:
| フィールド | 式 | 意味 | 理由 |
|---|---|---|---|
request_id |
getRequestId() ?? "-" |
相関ID。未設定時は "-" |
undefined を渡すと JSON からキーが消えるため(context.ts:27 のコメント)。キーが常に存在することを保証する |
data_source |
USE_FIXTURES ? "fixtures" : "prisma" |
サンプルデータか実DBか | docs/logging-design.md:118 が「必須」。docs/logging-design.md:120-122 の通り「意図的な未実装エラー」と「本番DBの本当の障害」を区別するため |
なぜ mixin なのか(コメント原文 lib/logger/index.ts:1-6):
/** * アプリ共通ロガー。出力は stdout のみ(ファイル化・ローテーションは OS 側)。 * * mixin() で request_id / data_source を自動付与する理由: * 呼び出し忘れで相関が切れるのを防ぎ、USE_FIXTURES の解釈を lib/db の export に一本化するため。 */
理由は2つ。
request_id: getRequestId() と手で書く方式なら、9ルート × 各数箇所で書き漏らしが起きる。しかも漏れたログだけ相関が切れるので、調査中に「このログはどのリクエストのものか分からない」という最悪の形で発覚する。mixin なら書き漏らしが構造的に起きない。USE_FIXTURES の解釈を1箇所に。 docs/logging-design.md:393-395 の禁止事項そのまま。出力先の差し替えについて(コメント 31-35行):
/** * pino インスタンスを組み立てる。 * 出力先を差し替えられるようにしてあるのは、スモークテストが本番と * 同一設定の出力を検証できるようにするため(設定を二重に書かない)。 */
(b) 最終行の分岐(53行)
return destination ? pino(options, destination) : pino(options);
destination が渡されていればそれを出力先にし、なければ pino の既定(stdout)。options は共通なのがミソ。テストと本番で設定が1文字も違わない。
docs/logging-design.md:422-424 の完了条件がこれを要求している:
lib/logger/__tests__/smoke.test.tsが通る。 pino の実際の出力を捕捉し、timestampが ISO 8601+09:00(ミリ秒付き)、levelが文字列、request_idとdata_sourceが入っていることをアサートする
「設定を二重に書かない」がなぜ重要か: テスト用に別の pino() 呼び出しを書くと、本番設定を変えたときテスト側を直し忘れて、テストは通るが本番の出力は壊れている状態になりうる。
呼び出し元:
lib/logger/index.ts:56 — export const logger = createLogger();(引数なし=stdout)lib/logger/__tests__/api.test.ts:38, 182、lib/logger/__tests__/smoke.test.ts:40, 76, 185, 210, 247, 278, 319アプリ本体から createLogger() を呼んでいる箇所は 56行の1箇所だけ。それ以外はすべてテスト。
lib/logger/index.ts:56
export const logger = createLogger();
モジュール読み込み時に1回だけ作られる共有インスタンス(この形をシングルトンと呼ぶ)。const なので差し替えられない。
呼び出し元: lib/logger/index.ts:122(logEvent の中)、lib/logger/api.ts:49(logApiError の中)、instrumentation.ts:26(logger.level を読んで app.start の log_level に載せる)。
instrumentation.ts:26 の使い方は特殊で、ログを出すためではなく「実際に効いているレベル」を読むために使っている。instrumentation.ts:19-20 のコメント:
* 「ログ機構が生きていること」と「どの設定で動いているか」を1行残す。 * これが無いと、ログが出ない原因が LOG_LEVEL なのか未起動なのか切り分けられない。
シグネチャ(lib/logger/index.ts:71-105):
export function emitTo( target: pino.Logger, level: LogLevel, payload: LogEvent, err?: unknown ): void
(a) 引数と戻り値の業務上の意味
| 引数 | 型 | 業務上の意味 | ||
|---|---|---|---|---|
target |
pino.Logger |
どのロガーに書くか。本番は logger、テストは捕獲用インスタンス |
||
level |
`"info" \ | "warn" \ | "error"` | 重要度。debug や fatal は受け付けない(LogLevel 型が3値に絞っている) |
payload |
LogEvent |
イベント本体。lib/logger/events.ts の9種の共用型のどれか |
||
err |
unknown (省略可) |
例外オブジェクト。unknown は「何が来るか分からない」型。JavaScript は文字列でも数値でも throw できるので、Error に限定できない |
||
| 戻り値 | void |
返り値なし。副作用(1行出力)のためだけの関数 |
(b) 中の処理を条件分岐まで全部
const production = isProduction(); // 77行
const { payload: safe, leaked } = enforceForbiddenFieldsPolicy( // 78-81行
payload,
production
);
// err は error レベルのみ出力する。出さない場合は検査もしない(漏れる経路が無い)
const emitsErr = level === "error" && err !== undefined; // 84行
const errResult = emitsErr // 85-87行
? enforceForbiddenErrorFieldsPolicy(err, production)
: { err, leaked: [] as string[] };
// 検査を先に済ませてから書く。非本番の例外は1行も出さずに投げる
const allLeaked = [...new Set([...leaked, ...errResult.leaked])]; // 90行
if (allLeaked.length > 0) { // 91-97行
const strippedEvent: ForbiddenFieldsStrippedLogEvent = {
event: "log.forbidden_fields_stripped",
forbidden_keys: allLeaked,
};
target.warn(strippedEvent);
}
// message / stack に施設名等が混ざりうるため info / warn には err を付けない
if (emitsErr) { // 100-103行
target.error({ ...safe, err: errResult.err });
return;
}
target[level](safe); // 104行
分岐を全部列挙する(4箇所):
① 77-81行 本番かどうかで禁止フィールドの扱いを決める enforceForbiddenFieldsPolicy(payload, production) に丸投げ。{ payload: safe, leaked } は分割代入+別名付けで、「戻り値の payload プロパティを safe という名前で受け取る」。元の変数 payload を隠さないための命名。
② 84行 emitsErr = level === "error" && err !== undefined 2条件の AND。level が "error" かつ err が渡されているときだけ真。
err !== undefined なので、err に null を渡すと真になる(null !== undefined は真)。つまり logEvent("error", ev, null) は err: null を出力する。③ 85-87行 三項演算子で検査するかしないか 偽なら { err, leaked: [] as string[] } という「検査していない」形をそのまま作る。as string[] は型注釈(空配列だけでは never[] と推論されてしまうため)。この枝では err は使われない(④で emitsErr が偽なら errResult.err を読まない)。
④ 90-97行 漏れたキーがあれば別行で warn
[...leaked, ...errResult.leaked] — 2つの配列を連結(スプレッド構文)。new Set([...]) — 重複除去。payload と err の両方に comment があった場合に2回出さない。[...new Set(...)] — Set を再び配列に戻す。if (allLeaked.length > 0) — 1件でもあれば target.warn(strippedEvent) を出す。元のログより先に出る(コード順)。⑤ 100-104行 err を付けるかどうか
emitsErr が真: target.error({ ...safe, err: errResult.err }) — ...safe でイベントのキーを展開し、err を足す。pino は err というキーを特別扱いして専用のシリアライザ(message / stack / type を取り出す処理)にかける。return で即終了。target[level](safe) — 動的メソッド呼び出し。level が "info" なら target.info(safe)。LogLevel が3値に絞られているので、存在しないメソッドを呼ぶことはない。(c) なぜそう書いてあるか
コメント原文(lib/logger/index.ts:58-70):
/** * 指定インスタンスへ1行出す実体。 * * 禁止フィールドの扱いを環境で分けるのは、ログ呼び出しが catch 節の内側から * 行われるため。本番で例外を投げると元のエラー処理を壊し、ログの機構が * リクエストを 500 にしてしまう。「ログの失敗でアプリを落とさない」を優先する。 * * 本番で検出した場合、元のログ(禁止キーを除去したもの・元のレベルのまま)とは * **別に** warn を1行出す(docs/logging-design.md §10 の「別途 warn で記録する」)。 * 元のレベルを warn へ格上げして1行にまとめる方式だと、元が error のときに * 「禁止フィールドが渡された」事実が warn として残らないため、常に分離する。 * warn 側には値を一切載せず、キー名(forbidden_keys)だけを記録する。 */
論点1: なぜ環境で分けるのか — docs/logging-design.md:382-385 の指示そのまま:
禁止フィールド検出時の挙動:
NODE_ENVがproduction以外では例外を投げて開発中に気づかせる。本番では投げず、該当キーを取り除いてwarnで記録し、ログ呼び出し自体は成功させる。 理由: ログ呼び出しは catch 節の中から行われる。そこで例外を投げると元のエラー処理を壊し、ログのための機構がリクエストを 500 にしてしまう。「ログの失敗でアプリを落とさない」が優先
論点2: なぜ「別行」なのか — これは実装者が踏み込んで決めた部分。コメントの論理をほどくと:
warn に格上げして1行にまとめる。warn を別に1行出す。案Aが駄目な理由: 元が error の場合、error を warn に格下げすることになる。すると「エラーが起きた」という事実が error レベルで残らず、grep '"level":"error"' で拾えなくなる。逆に「格上げ」の向きで考えても、error は warn より上なので格上げにならない。どちらに寄せても片方の情報が失われる。だから分離する。
論点3: warn 側に値を載せない — forbidden_keys: allLeaked はキー名の配列だけ。lib/logger/events.ts:205-207 のコメント:
* ロガー自身が出す内部イベント。本番で禁止フィールドを取り除いた事実を伝える。 * 載せるのはキー名のみ。値(施設名・電話番号・自由記述の中身)は絶対に載せない。 * 呼び出し側が偽装できないよう LogEvent の union には含めない。
「呼び出し側が偽装できないよう LogEvent の union には含めない」が巧妙。 ForbiddenFieldsStrippedLogEvent は LogEvent 共用型(events.ts:217-226)に入っていない。だから logEvent("warn", { event: "log.forbidden_fields_stripped", ... }) と書こうとしても型エラーになる。このイベントはロガー自身しか出せない。もし偽装できたら、「本当は漏れたのに漏れていないふりをする」あるいは逆に「漏れていないのに漏れたと騒ぐ」ログを外から作れてしまう。
論点4: 検査を先に済ませてから書く(89行のコメント) — 「非本番の例外は1行も出さずに投げる」。順序が重要で、もし先に本文を書いてから検査していたら、非本番で例外が飛ぶ前に禁止フィールド入りの1行が既に出力されている。今の順序なら、例外が飛ぶケースでは出力が1行も出ない。
論点5: info / warn に err を付けない(99行のコメント) — 「message / stack に施設名等が混ざりうるため」。docs/logging-design.md:386-389:
例外オブジェクトの扱い(§3 の補足): §3 の「生の例外メッセージをそのまま転記しない」は
error_messageフィールドについての規則。errシリアライザが出すmessage/stackはデバッグに必要なので出力してよい。ただし本番でerr.messageに施設名等が含まれうる(Prisma のエラーメッセージなど)。errはerrorレベルのみに付け、info/warnには付けない
呼び出し元:
lib/logger/index.ts:122(logEvent の中)lib/logger/api.ts:33(logApiErrorTo の中)lib/logger/__tests__/smoke.test.ts:42, 76, 185, 213, 248, 281, 320アプリ本体(app/ 配下)から直接 emitTo を呼んでいる箇所は無い。ルートは logEvent か logApiError を使う。
用語: オーバーロード = 「同じ名前の関数に、複数の呼び方(引数の組み合わせ)を公式に用意する」仕組み。TypeScript では「宣言だけの行」を並べ、最後に実際の中身を1つ書く。
lib/logger/index.ts:107-123:
/**
* allowlist 型のイベントを1行 JSON で出す。
* Error は pino 標準の err シリアライザに渡し、JSON.stringify(error) は使わない。
*/
export function logEvent( // 111-115行 ← 宣言1(公開される呼び方)
level: "error",
payload: LogEvent,
err?: unknown
): void;
export function logEvent(level: "info" | "warn", payload: LogEvent): void; // 116行 ← 宣言2(公開される呼び方)
export function logEvent( // 117-123行 ← 実装(外からは見えない)
level: LogLevel,
payload: LogEvent,
err?: unknown
): void {
emitTo(logger, level, payload, err);
}
呼び方の一覧(外から使えるのは2通りだけ):
| # | 呼び方 | 例 | 意味 | |
|---|---|---|---|---|
| 1 | logEvent("error", payload, err?) |
logEvent("error", event, error)(instrumentation.ts:76, 79) |
エラーを記録する。第3引数に例外を渡せる(省略も可) | |
| 2 | `logEvent("info" \ | "warn", payload)` | logEvent("info", { event: "judge.advice", ... })(app/api/clean-logs/[id]/advice/route.ts:64) |
通常の記録。第3引数を書けない |
★このオーバーロードが何を防いでいるか★
宣言2 に err が無いので、logEvent("info", payload, someError) はコンパイルエラーになる。つまり emitTo の 84行の実行時判定(level === "error" && err !== undefined)を、呼ぶ時点で型として先に止めている。
logEvent("info", ev, err) と書けてしまい、err は黙って捨てられる。書いた人は「例外もログに出た」と思い込むが出ていない。気づけない事故。error に変えるか、err を消すかをその場で選ばされる。これは docs/logging-design.md:389 の「err は error レベルのみに付け、info / warn には付けない」という規約を、コメントではなく型で強制した形。
3つ目の宣言(117-123行)について: これは実装シグネチャで、外部からは呼べない。level: LogLevel と広く受けているのは、中で emitTo に渡すため。TypeScript のオーバーロードは「実装シグネチャは全ての宣言を包含できる形に書くが、外部に公開されるのは宣言だけ」という規則になっている。
(b) 中の処理: 1行。emitTo(logger, level, payload, err) — 共有インスタンス logger を固定して emitTo に委譲するだけ。分岐なし。
(c) なぜ emitTo と2段になっているのか: emitTo は出力先を選べる(テスト可能)が、毎回 logger を書かせるのは冗長。logEvent は「本番の共有ロガーに書く」を既定にした短縮版。lib/logger/api.ts:26-27 のコメントが同じ設計を説明している:
* 5ルートすべてが通る関数なので、実出力をテストで検証できる接合部を用意する * (`emitTo` / `logEvent` と同じ形)。設定を二重に書かないため target を渡すだけにする。
コメント(107-110行)の「JSON.stringify(error) は使わない」の根拠は docs/logging-design.md:380-381:
Error の直列化: pino 標準の
errシリアライザを使う。JSON.stringify(error)はmessage/stackを落とすので使わない
平易な言い換え: Error オブジェクトの message と stack は「列挙可能でないプロパティ」なので、JSON.stringify に渡すと {}(空のカッコ)になる。ログに err: {} とだけ出る、という「静かに壊れる」形。docs/logging-design.md:61 が自作ロガーを却下した理由もこれ。
呼び出し元(アプリ本体、実測5箇所):
app/api/clean-logs/[id]/advice/route.ts:64 logEvent("info", { event: "judge.advice", ... })
app/api/clean-logs/[id]/advice/route.ts:99 logEvent("info", { event: "judge.escalation", ... })
app/api/dashboard/route.ts:85 logEvent("warn", { event: "data.anomaly", ... })
instrumentation.ts:24 logEvent("info", { event: "app.start", ... })
instrumentation.ts:76, 79 logEvent("error", event, error) ← app.uncaught
全30行。1関数だけのファイル。
import が1つも無い。 Intl(国際化 API)と Date は JavaScript 本体の機能なので import 不要。
date-fns や dayjs のような日付ライブラリを入れていない。「JST の ISO 8601 を作る」だけならライブラリは不要という判断(依存を増やさない)。export function formatLogTimestamp(date: Date = new Date()): string // 24行
export していない内部要素:
const jstStamp = new Intl.DateTimeFormat("en-GB", { // 6-15行
timeZone: "Asia/Tokyo",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
});
jstStamp をモジュール直下(トップレベル)に置いている理由: Intl.DateTimeFormat の生成はそれなりに重い処理で、ログ1行ごとに作り直すと無駄。1回作って使い回す。
オプションを1つずつ:
| オプション | 値 | 意味 |
|---|---|---|
| ロケール | "en-GB" |
英国式。数字の桁が2桁固定になりやすく、和暦や AM/PM が混ざらないロケールを選んでいる |
timeZone |
"Asia/Tokyo" |
★ホストの OS 設定を無視して常に日本時間で組み立てる★ |
year |
"numeric" |
2026 |
month / day |
"2-digit" |
09 / 02(1桁の月日を0埋め) |
hour / minute / second |
"2-digit" |
18 / 32 / 20 |
hourCycle |
"h23" |
0〜23 時制。これが無いと深夜0時が 24 になるロケールがあり、ISO 8601 として不正な文字列になる |
シグネチャ: formatLogTimestamp(date: Date = new Date()): string(lib/logger/timestamp.ts:24-30)
export function formatLogTimestamp(date: Date = new Date()): string {
const parts = Object.fromEntries(
jstStamp.formatToParts(date).map((part) => [part.type, part.value])
);
const ms = String(date.getMilliseconds()).padStart(3, "0");
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.${ms}+09:00`;
}
(a) 引数と戻り値の業務上の意味
date: Date = new Date() — 既定引数。省略すると「今」。テストは固定日時を渡して結果を検証できる(lib/logger/__tests__/timestamp.test.ts:6, 11)。この「時計を引数で差し替えられる」形にしておくのが、非純粋な処理をテスト可能にする定石。string — 例: 2026-08-27T18:32:20.123+09:00(b) 中の処理を条件分岐まで全部
条件分岐は1本も無い。直線的な3ステップ。
formatToParts で部品に分解する jstStamp.formatToParts(date) は [{type:"year",value:"2026"}, {type:"month",value:"08"}, ...] のような配列を返す。.map((part) => [part.type, part.value]) で [["year","2026"], ["month","08"], ...] の形にし、Object.fromEntries で { year:"2026", month:"08", ... } というオブジェクトに変換する。
format() で1発で文字列にしないのか: format() はロケール依存の並び(27/08/2026, 18:32:20 のような形)を返す。それを切り貼りして ISO 8601 に組み替えるのは脆い。部品で受け取って自分で並べる方が確実。date.getMilliseconds() は 0 〜 999 の数値。String(...) で文字列にし、.padStart(3, "0") で3桁に0埋め(7 → "007")。
Intl から取らないのか。 コメント(22行)の答え: 「ミリ秒はタイムゾーンに依存しないので Date から直接取る」。時分秒は TZ で変わるが、ミリ秒は変わらない。だから Intl に頼る必要がない(Intl の fractionalSecondDigits を使う手もあるが、環境差の少ない Date から取っている)。padStart が無いと何が起きるか: 7 ミリ秒が .7 と出て、2026-08-27T18:32:20.7+09:00 になる。文字列としてソートしたときに .7 > .123 となり、時系列の並びが壊れる。テスト lib/logger/__tests__/timestamp.test.ts:11 が 2026-08-27T09:32:20.007Z を渡してこれを固定している。+09:00 は文字列としてハードコードされている。timeZone: "Asia/Tokyo" で組み立てた時刻なので、オフセットは常に +09:00。日本には夏時間(サマータイム)が無いため定数で正しい。(もし夏時間のある地域なら、この書き方は年に2回間違う。)(c) なぜそう書いてあるか
ファイル冒頭コメント(1-5行)と本体コメント(17-23行)は前述の createLogger の項に全文引用済み。要点は3つ:
CLAUDE.md §9.3 の日時規約に合わせるtimeZone: "Asia/Tokyo" を明示)呼び出し元:
lib/logger/index.ts:39(createLogger の timestamp オプションの中)lib/logger/__tests__/timestamp.test.ts:6, 11アプリ本体からはこの1箇所のみ。ただしログを1行出すたびに呼ばれるので、実行回数は最も多い関数の1つ。
全232行。「ログに書いてはいけないキー名」を実行時に弾く層。
用語: redact(レダクト)= 「墨消しする」。公文書公開で個人情報を黒く塗りつぶす作業のこと。
import 文が1つも無い。 1〜7行目はファイル冒頭コメント、9行目から定数の宣言が始まる。
何を import していないか、そしてそれがなぜ重要か:
pino を import していない。 ロガーを知らない。だから「キー名を検査する純粋な処理」として単体テストできる(lib/logger/__tests__/redact.test.ts は pino を一切起動せずに全ケースを検証している)。@/lib/logger/events を import していない。 イベント型を知らない。だから LogEvent に無いキー(スプレッドで混入したもの)も検査できる。ファイル冒頭コメントがこれを説明している。process.env を読んでいない。 「本番かどうか」を引数で受け取る(isProduction: boolean)。自分で環境を判定しないので、テストが両方の挙動を確実に再現できる。この「環境判定を外から注入する」形が、非純粋な条件をテスト可能にする定石。ファイル冒頭コメント(1-7行):
/** * 禁止フィールド名の検出と除去。 * * 型の allowlist だけでは、インデックスシグネチャやスプレッドで * 禁止キーが混入しうるため、書く直前にも名前で弾く。 * 値が自由記述かどうかではなくキー名で判定する(内容は予測できない)。 */
平易な言い換え(2つの用語):
| 専門用語 | 平易な言い換え | 具体的に何が起きるか |
|---|---|---|
| allowlist(許可リスト)型 | 「載せてよい項目だけを型に列挙する」設計。lib/logger/events.ts がそれ |
型に無いキーを書くとコンパイルが通らない |
| インデックスシグネチャ | 「どんな名前のキーでも入れられる」という型の書き方([key: string]: unknown) |
これがあると型の壁を素通りする |
スプレッド(...obj) |
オブジェクトの中身を丸ごと展開してコピーすること | { ...row } と書くと row の全キー(facility_name を含む)がログに入る |
つまり「型だけでは防げない」。 logEvent("info", { event: "api.error", ...row }) のような書き方をされると、row の中身は実行時に決まるので型検査では見えない。だから書く直前に、実際のオブジェクトを見て名前で弾く二重の防御にしている。これが docs/logging-design.md:167-173 の「3段構え」の2段目:
lib/logger/events.tsのイベント型を許可フィールドのみで構成する。自由記述のstringフィールドを型に含めない → 誤って渡すとコンパイルが通らないlib/logger/redact.tsで実行時にも禁止フィールド名を弾く__tests__/redact.test.tsで、イベント型のキー集合に禁止フィールド名が含まれないことを機械的に検証する
「キー名で判定する」理由: 冒頭コメント「値が自由記述かどうかではなくキー名で判定する(内容は予測できない)」。値を見て「これは個人情報っぽい」と判定するのは不可能(担当者が notes に何を書くか予測できない)。キー名なら決まっている。
export const FORBIDDEN_PII_FIELDS = [...] as const; // 12-26行
export const FORBIDDEN_CREDENTIAL_FIELDS = [...] as const; // 35-44行
export const FORBIDDEN_LOG_FIELDS = [ // 46-49行
...FORBIDDEN_PII_FIELDS,
...FORBIDDEN_CREDENTIAL_FIELDS,
] as const;
export type ForbiddenLogField = (typeof FORBIDDEN_LOG_FIELDS)[number]; // 51行
export interface ForbiddenFieldsPolicyResult<T> { // 64-69行
payload: T;
leaked: string[];
}
export interface ForbiddenErrorFieldsPolicyResult { // 71-75行
err: unknown;
leaked: string[];
}
export function findForbiddenFields(payload: unknown): string[] // 82行
export function findForbiddenFieldsInError(err: unknown): string[] // 95行
export function stripForbiddenFields<T>(payload: T): T // 111行
export function stripForbiddenFieldsFromError(err: unknown): unknown // 123行
export function enforceForbiddenFieldsPolicy<T>(
payload: T,
isProduction: boolean
): ForbiddenFieldsPolicyResult<T> // 161-164行
export function enforceForbiddenErrorFieldsPolicy(
err: unknown,
isProduction: boolean
): ForbiddenErrorFieldsPolicyResult // 178-181行
export していない内部要素(6件):
const FORBIDDEN_SET = new Set<string>(FORBIDDEN_LOG_FIELDS); // 53行 function isForbiddenKey(key: string): boolean // 60行 function readAccessor(source: Record<string, unknown>, key: string): unknown // 193行 function isPlainObject(value: unknown): value is Record<string, unknown> // 201行 function walk(value: unknown, found: string[]): void // 207行 function strip(value: unknown): unknown // 221行
lib/logger/redact.ts:12-26)/** * 個人情報・自由記述(docs/logging-design.md §4)。 */ export const FORBIDDEN_PII_FIELDS = [ "facility_name", "address", "phone", "email", "manager_name", "charger_name", "inspector_name", "customer_name", "notes", "comment", "memo", "note", "detail", ] as const;
| # | キー名 | 何が入りうるか | 分類 |
|---|---|---|---|
| 1 | facility_name |
実在の施設名・店舗名 | 個人情報/顧客情報 |
| 2 | address |
実在の住所 | 個人情報 |
| 3 | phone |
電話番号 | 個人情報 |
| 4 | email |
メールアドレス | 個人情報 |
| 5 | manager_name |
管理者の氏名 | 氏名 |
| 6 | charger_name |
担当者の氏名 | 氏名 |
| 7 | inspector_name |
点検者の氏名 | 氏名 |
| 8 | customer_name |
顧客の氏名 | 氏名 |
| 9 | notes |
自由記述(複数形) | 自由記述 |
| 10 | comment |
自由記述(DBの許可4カラムの1つ) | 自由記述 |
| 11 | memo |
自由記述 | 自由記述 |
| 12 | note |
自由記述(単数形) | 自由記述 |
| 13 | detail |
自由記述 | 自由記述 |
docs/logging-design.md:156-157 の一覧と1件ずつ照合した結果、13件が完全一致する:
facility_name/address/phone/manager_name/charger_name/inspector_name/customer_name/notes/comment(内容) /memo/note/detail
notes と note、両方入っているのが重要。 単数形・複数形のどちらで命名されるかは書く人によって違うので、片方だけだと素通りする。同様に memo と detail も自由記述の別名として押さえてある。
なぜ自由記述が禁止なのか。 docs/logging-design.md:153-154:
自由記述または個人氏名のフィールド。担当者が電話番号や別の顧客名を書き込む可能性があり、内容を予測できない。
平易な言い換え: 「備考」欄には何でも書ける。「〇〇店の田中さんに電話(090-...)して確認済み」と書かれる可能性が現実にある。だから「備考という名前のキー」自体をログに載せない。
なぜマスキングが必須要件なのか。 docs/logging-design.md:143-149:
P1 の対象顧客は実在企業で、実在の施設名・住所・電話番号・担当者氏名を扱う。加えて
docs/spec.mdM0 に「Cursor の Privacy Mode を両名ON(会社コードのため必須)」とあり、このチームは機密の外部流出を意識している。ログは「デバッグの起点」である以上、調査時にログの中身をAIチャットに貼り付ける経路が現実に発生する。よってマスキングは「念のため」ではなく必須要件とする。
★この段落がこのファイルの存在理由そのもの。 「ログをAIチャットに貼る」という運用が想定されているので、ログに個人情報が入るとそれがそのまま外部サービスに送られる。
lib/logger/redact.ts:28-44)/** * 認証情報。§4 は認証未着手の時点で書かれたため漏れていた。 * 個人情報と違い、漏れると「読まれる」だけでなく「なりすまされる」ので分けて明示する。 * * cookie 名そのもの(`dwatch_session` 等)は未決なので入れない。 * 認証実装時に決まった名前をここへ追加すること。 */ export const FORBIDDEN_CREDENTIAL_FIELDS = [ "cookie", "set-cookie", "authorization", "token", "session", "secret", "password", "api_key", ] as const;
| # | キー名 | 何が入りうるか | 由来 |
|---|---|---|---|
| 1 | cookie |
ブラウザが送る Cookie 全体(セッションIDを含む) | HTTPリクエストヘッダ名 |
| 2 | set-cookie |
サーバーが発行する Cookie | HTTPレスポンスヘッダ名。ハイフン入りのヘッダ名がそのまま入っている |
| 3 | authorization |
Bearer <トークン> 等の認証ヘッダ |
HTTPリクエストヘッダ名 |
| 4 | token |
各種トークン。AUTH_TOKEN(.env.example)の値など |
汎用名 |
| 5 | session |
セッションID | 汎用名 |
| 6 | secret |
秘密鍵・共有秘密 | 汎用名 |
| 7 | password |
パスワード | 汎用名 |
| 8 | api_key |
APIキー | 汎用名 |
★個人情報と認証情報を別の配列に分けている理由(コメントの引用)★
* 個人情報と違い、漏れると「読まれる」だけでなく「なりすまされる」ので分けて明示する。
平易な言い換え: 施設名が漏れたら「見られてしまった」で被害が止まる。トークンが漏れたらその人になりきってシステムを操作できる。被害の性質が違うので、リストを分けて「これは認証情報である」と読む人に伝えている。
もう1つ重要なコメント:
* 認証情報。§4 は認証未着手の時点で書かれたため漏れていた。
つまり docs/logging-design.md §4 には認証情報の一覧が無い(実際 §4 の記録禁止リスト 156-157行には cookie も token も出てこない)。設計文書が書かれた時点で認証機能が未着手だったため、実装時に実装者が気づいて追加した。この経緯がコメントに残されている。
申し送りも書かれている:
* cookie 名そのもの(`dwatch_session` 等)は未決なので入れない。 * 認証実装時に決まった名前をここへ追加すること。
「未決なので入れない」= 推測で埋めない。docs/logging-design.md:316(§9 のタイトル「対象外・未確定(推測で埋めていない)」)と同じ規律が実装コードにも適用されている。
lib/logger/redact.ts:46-53)export const FORBIDDEN_LOG_FIELDS = [ ...FORBIDDEN_PII_FIELDS, ...FORBIDDEN_CREDENTIAL_FIELDS, ] as const; export type ForbiddenLogField = (typeof FORBIDDEN_LOG_FIELDS)[number]; const FORBIDDEN_SET = new Set<string>(FORBIDDEN_LOG_FIELDS);
13 + 8 = 21件。 テストが件数を固定している(lib/logger/__tests__/redact.test.ts:68):
expect(FORBIDDEN_LOG_FIELDS).toHaveLength(21);
なぜ件数をテストで固定するのか(推測を含まない読み取り): 誰かが「このキーはもう要らない」と削除したときにテストが落ちる。禁止リストは減らすと静かに危険になる種類のデータなので、変更時に必ず人間の判断を通す仕掛け。
3つの定数の使い分け:
| 定数 | 用途 | 呼び出し元 |
|---|---|---|
FORBIDDEN_PII_FIELDS |
個人情報だけを列挙したい場合 | テストのみ(redact.test.ts:44) |
FORBIDDEN_CREDENTIAL_FIELDS |
認証情報だけを列挙したい場合 | テストのみ(redact.test.ts:61) |
FORBIDDEN_LOG_FIELDS |
検査に使う全件 | redact.ts:53(Set 生成)、redact.test.ts:17,45,62,68、smoke.test.ts:10,233,306 |
as const の意味: 「この配列は読み取り専用で、中身は書いてある文字列そのもの」と型に伝える。これが無いと型は string[] になり、次行の ForbiddenLogField が string に潰れてしまう。as const があると "facility_name" | "address" | ... | "api_key" という21値の共用型になる。
ForbiddenLogField 型の用途: (typeof 配列)[number] は「配列の要素の型」を取り出す書き方。この型は現在どこからも使われていない(export されているが参照は redact.ts:51 の宣言だけ。grep 実測)。
FORBIDDEN_SET(53行): 配列を Set に変換している。Set は「重複のない集合」で、.has(x) の判定が要素数によらず高速(配列の .includes() は要素を先頭から順に見るので遅い)。ログ1行につき全キーを再帰的に調べるので、この差が効く。モジュール読み込み時に1回だけ作られる。
lib/logger/redact.ts:55-62:
/**
* キー名の判定は大文字小文字を無視する。
* HTTP ヘッダは `Authorization` / `Set-Cookie` のように大文字で来るため、
* 完全一致だと素通りしてしまう。
*/
function isForbiddenKey(key: string): boolean {
return FORBIDDEN_SET.has(key.toLowerCase());
}
実装方法(2段構え):
FORBIDDEN_CREDENTIAL_FIELDS は "authorization" "set-cookie" と小文字。.toLowerCase() で小文字化してから照合する。つまり「両方を小文字に揃えてから比べる」。この方式を正規化して比較すると呼ぶ。
判定例(式から確定できること):
| 検査対象のキー名 | .toLowerCase() の結果 |
判定 |
|---|---|---|
authorization |
authorization |
禁止 |
Authorization |
authorization |
禁止(大文字始まりも捕まる) |
AUTHORIZATION |
authorization |
禁止 |
Set-Cookie |
set-cookie |
禁止 |
SET-COOKIE |
set-cookie |
禁止 |
Facility_Name |
facility_name |
禁止 |
facility_id |
facility_id |
通る(リストに無い) |
token_count |
token_count |
通る(token とは別の文字列。前方一致ではなく完全一致で判定するため) |
comment_changed |
comment_changed |
通る(comment とは別の文字列) |
★最後の2行が設計上とても重要。 判定は完全一致(Set.has)であって部分一致ではない。だから:
comment_changed: boolean(lib/logger/events.ts:175 の DbWriteLogEvent のフィールド)は通る。これは意図した動作で、docs/logging-design.md:258 が「comment_changed | boolean のみ。内容は絶対に記録しない(自由記述)」と定めている通り、「コメントが変わったかどうか」だけを残す設計。もし部分一致で弾いていたら、この正当なフィールドまで消えてしまう。findForbiddenFields({ facility_id: 1, token_count: 3 }) → [](redact.test.ts:113)、findForbiddenFields({ event: "db.write", comment_changed: true }) → [](redact.test.ts:151)。なぜ大文字小文字を無視するのか(コメントの理由): HTTP ヘッダの慣習的な書き方が Authorization / Set-Cookie のように単語頭が大文字だから。ヘッダをそのままオブジェクトにしてログに渡す({ headers: req.headers } のような書き方)と、完全一致では素通りする。テストがこの経路を検証している(redact.test.ts:100 — headers の中に入れたケース)。
シグネチャ: findForbiddenFields(payload: unknown): string[](lib/logger/redact.ts:82-86)
export function findForbiddenFields(payload: unknown): string[] {
const found: string[] = [];
walk(payload, found);
return found;
}
(a) 引数と戻り値の業務上の意味
payload: unknown — 検査したいもの。unknown は「何が来るか分からない」型。オブジェクト・配列・文字列・数値、何でも受ける。string[] — 見つかった禁止キーの名前だけの配列。値は含まない。空配列なら「問題なし」。(b) 中の処理
分岐は無い。空配列を用意して walk に渡し、walk が見つけたものを詰めて返す(アキュムレータ方式)。
(c) なぜ検出と除去を分けているのか
コメント原文(lib/logger/redact.ts:77-81):
/** * 禁止キーを再帰的に探して名前を返す。副作用なし。 * 検出と除去を分けているのは、環境によって挙動を変える判断を * 呼び出し側(enforceForbiddenFieldsPolicy)に閉じ込めるため。 */
「副作用なし」= この関数は何も変えない。見つけて報告するだけ。「見つけたらどうするか」(例外を投げるのか、消すのか)は環境によって違うので、その判断を1箇所(enforceForbiddenFieldsPolicy)に集めている。もし検出関数の中で「本番なら消す」と書いていたら、環境判定が複数箇所に散る。
呼び出し元: lib/logger/redact.ts:165(enforceForbiddenFieldsPolicy の中)、テスト redact.test.ts:84, 100, 106, 113, 129, 134, 140, 146, 151。
lib/logger/redact.ts:207-219:
function walk(value: unknown, found: string[]): void {
if (Array.isArray(value)) {
for (const item of value) walk(item, found);
return;
}
if (!isPlainObject(value)) return;
for (const [key, child] of Object.entries(value)) {
if (isForbiddenKey(key) && !found.includes(key)) {
found.push(key);
}
walk(child, found);
}
}
用語: 再帰(recursion)= 「関数が自分自身を呼ぶ」書き方。入れ子の深さが分からないデータを、深さに関係なく全部たどるための定石。
分岐を全部(4本):
walk を呼び直して return。配列の添字(0, 1, 2...)はキー名として検査しない(数字は禁止キーになり得ないため)。if (!isPlainObject(value)) return; — 素のオブジェクトでなければ何もせず終わる。文字列・数値・null・Date・Error などはここで打ち切られる。if (isForbiddenKey(key) && ...) — キー名が禁止リストにあるか。&& !found.includes(key) — 重複を避ける。同じキー名が別の場所に2回出てきても1回だけ記録する。forbidden_keys に同じ名前が並ぶのを防ぐ。walk(child, found) — 禁止キーだったかどうかに関わらず、値の中もさらに潜る。これにより { a: { b: { comment: "..." } } } のような深い入れ子も見つかる(テスト redact.test.ts:140 が入れ子を検証)。lib/logger/redact.ts:201-205:
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== "object") return false;
const proto = Object.getPrototypeOf(value) as object | null;
return proto === Object.prototype || proto === null;
}
用語: プロトタイプ(prototype)= JavaScript のオブジェクトが持つ「親の設計図」への参照。{} で作った素のオブジェクトの親は Object.prototype、new Date() の親は Date.prototype、new Error() の親は Error.prototype。
分岐を全部(3本):
value === null — null は typeof が "object" になる JavaScript の有名な癖なので、先に弾く。typeof value !== "object" — 文字列・数値・真偽値・関数を弾く。proto === Object.prototype || proto === null — 親が「素のオブジェクト」か「親なし」(Object.create(null) で作った辞書)のときだけ真。★なぜ「素のオブジェクトだけ」に絞るのか★ stripForbiddenFields のコメント(108-109行)に答えがある:
* 禁止キーを取り除いた複製を返す。元のオブジェクトは変更しない。 * Error や Date は複製せずそのまま通す(pino の err シリアライザに壊れた形で渡さないため)。
平易な言い換え: Date を「キーを1つずつコピーして作り直す」と、日付としての機能を失った空のオブジェクトになる(Date の実際の値は内部スロットにあり、列挙可能なキーとして存在しない)。Error も同様に message / stack を失う。だからそういうものは触らずにそのまま通す。
isPlainObject は walk(検査)と strip(除去)の両方で使われるので、「検査対象」と「複製対象」が完全に一致する。片方だけ Date を見て片方が見ない、というズレが起きない。
呼び出し元: lib/logger/redact.ts:212(walk の中)、:225(strip の中)。
シグネチャ: findForbiddenFieldsInError(err: unknown): string[](lib/logger/redact.ts:95-105)
export function findForbiddenFieldsInError(err: unknown): string[] {
const found: string[] = [];
if (err === null || typeof err !== "object") return found;
for (const [key, value] of Object.entries(err)) {
if (isForbiddenKey(key) && !found.includes(key)) {
found.push(key);
}
walk(value, found);
}
return found;
}
(a) 引数と戻り値
err: unknown — 例外オブジェクト(Error のインスタンスに限らない。JavaScript は文字列でも throw できる)。string[] — 見つかった禁止キー名。コメント原文(lib/logger/redact.ts:88-94):
/** * 例外オブジェクトの**列挙可能な独自プロパティ**を検査する。 * * Prisma のエラーは `meta` のような独自プロパティを持ち、そこに値が入りえるため * payload と同じ検査をかける必要がある。 * `message` / `stack` は非列挙なので対象外(文字列であり、デバッグに必要なので出力は維持する)。 */
用語: 列挙可能(enumerable)= 「for...in や Object.entries() で名前が出てくる」性質。JavaScript の Error は message と stack を非列挙(enumerable: false)で持つので、Object.entries(err) には現れない。
検査するもの / しないもの:
| 対象 | 検査するか | 理由 |
|---|---|---|
err 自身の列挙可能な独自プロパティ(例: Prisma の meta、code、独自クラスで代入したフィールド) |
する | ここに値が入りうる |
| そのプロパティの値の中身(入れ子・配列) | する(102行の walk(value, found)) |
meta: { target: { facility_name: ... } } のような形を捕まえる |
err.message |
しない | 非列挙なので Object.entries に出てこない |
err.stack |
しない | 同上 |
err が null / 文字列 / 数値の場合 |
しない(97行で即 return) |
プロパティを持たない |
なぜ Prisma の話が出てくるのか: Prisma のエラーオブジェクトは PrismaClientKnownRequestError のようなクラスで、code("P2002" 等)と meta(どのカラムで失敗したかの詳細)を持つ。meta の中には実際のカラム名や値が入ることがある。だから payload と同じ再帰検査をかける必要がある。
★message / stack を検査対象から外していることの意味(重要な設計判断)★
これは「検査漏れ」ではなく意図した設計で、コメントに理由が書かれている(「文字列であり、デバッグに必要なので出力は維持する」)。根拠は docs/logging-design.md:386-389:
例外オブジェクトの扱い(§3 の補足): §3 の「生の例外メッセージをそのまま転記しない」は
error_messageフィールドについての規則。errシリアライザが出すmessage/stackはデバッグに必要なので出力してよい。ただし本番でerr.messageに施設名等が含まれうる(Prisma のエラーメッセージなど)。errはerrorレベルのみに付け、info/warnには付けない
つまりトレードオフを明示的に受け入れている:
message / stack は個人情報が混ざるリスクがある(設計文書自身がそう認めている)error レベルだけに限定する(emitTo:84 の emitsErr 判定)この「キー名では守れない部分を、出す場所を絞ることで抑える」という二段構えが、lib/logger/index.ts の emitsErr 判定の意味である。redact.ts だけを読んでいると「message が漏れる穴」に見えるが、index.ts と合わせて読むと意図的な設計だと分かる。
呼び出し元: lib/logger/redact.ts:182(enforceForbiddenErrorFieldsPolicy の中)、テスト redact.test.ts:233, 240, 245, 292。
シグネチャ: stripForbiddenFields<T>(payload: T): T(lib/logger/redact.ts:111-113)
export function stripForbiddenFields<T>(payload: T): T {
return strip(payload) as T;
}
(a) 入力と同じ型を返す(<T> で型を保つ)。as T は「strip は unknown を返すが、キーを減らしただけなので元の型として扱ってよい」という型の断言。厳密には嘘(キーが減っているので元の型を完全には満たさない)だが、呼ぶ側の使い勝手のためにこう書いている。
(b) 分岐なし。strip に委譲。
(c) コメント(108-110行): 「元のオブジェクトは変更しない。Error や Date は複製せずそのまま通す」。非破壊(元を変えない)であることをテストが固定している(redact.test.ts:180 — stripForbiddenFields(original) を呼んだ後で original が無傷であることを検証)。
なぜ非破壊なのか: ログを取るためにアプリのデータを壊してはいけない。emitTo は catch 節から呼ばれるので、そこで渡したオブジェクトが書き換わると、その後のエラー処理(クライアントへの応答生成)が壊れる。
呼び出し元: lib/logger/redact.ts:172(enforceForbiddenFieldsPolicy の本番枝)、テスト redact.test.ts:90, 160, 166, 180。
lib/logger/redact.ts:221-232:
function strip(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => strip(item));
}
if (!isPlainObject(value)) return value;
const out: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
if (isForbiddenKey(key)) continue;
out[key] = strip(child);
}
return out;
}
walk と鏡合わせの構造。 分岐を全部(3本):
.map() で各要素を strip して新しい配列を作る(元の配列は変えない)。Date / Error / 文字列などは無加工で通す)。out を作り、禁止キーは continue で入れずに飛ばす。禁止でないキーは値をさらに strip して入れる。「新しい入れ物に詰め直す」のが非破壊の実現方法。 元のオブジェクトから delete するのではなく、コピーを作る。
シグネチャ: stripForbiddenFieldsFromError(err: unknown): unknown(lib/logger/redact.ts:123-151)
export function stripForbiddenFieldsFromError(err: unknown): unknown {
if (err === null || typeof err !== "object") return err;
const source = err as Record<string, unknown>;
const clone = Object.create(Object.getPrototypeOf(source)) as Record<
string,
unknown
>;
for (const key of Object.getOwnPropertyNames(source)) {
if (isForbiddenKey(key)) continue;
const descriptor = Object.getOwnPropertyDescriptor(source, key);
if (!descriptor) continue;
if ("value" in descriptor) {
Object.defineProperty(clone, key, {
...descriptor,
value: strip(descriptor.value),
});
continue;
}
// アクセサはレシーバ(this)に紐づく。V8 の Error.stack がこれで、
// 記述子をそのまま写すと複製側で undefined になるため、値を読んで写す
Object.defineProperty(clone, key, {
value: strip(readAccessor(source, key)),
writable: true,
enumerable: descriptor.enumerable,
configurable: descriptor.configurable,
});
}
return clone;
}
このファイルで最も複雑な関数。用語を先に:
| 専門用語 | 平易な言い換え |
|---|---|
| プロパティ記述子(property descriptor) | 「そのキーがどういう性質か」を表す設計情報(値・書き換え可否・for...in に出るか・設定変更可否) |
| アクセサ(accessor / getter) | 「読んだときに計算して返す」プロパティ。値を保持しているのではなく関数が動く |
レシーバ(receiver / this) |
その getter が動くときに「自分」として見るオブジェクト |
Object.getOwnPropertyNames |
非列挙のキーも含めて全ての独自キー名を返す(Object.entries は列挙可能なものだけ) |
(a) 引数と戻り値: 例外オブジェクトを受け、禁止キーを除いた複製を返す。
(b) 中の処理を条件分岐まで全部(6本):
if (err === null || typeof err !== "object") return err; — オブジェクトでなければそのまま返す。テストが stripForbiddenFieldsFromError("boom") → "boom" を固定(redact.test.ts:293)。Object.create(Object.getPrototypeOf(source)) = 「元と同じ親を持つ、中身が空のオブジェクト」。Object.getOwnPropertyNames(source) — 非列挙も含めた全キーを回す。ここが検査側(findForbiddenFieldsInError は Object.entries = 列挙可能のみ)と意図的に非対称になっている。検査は message / stack を見ないが、複製は message / stack も写す(出力を維持するため)。if (isForbiddenKey(key)) continue; — 禁止キーは写さない。if (!descriptor) continue; — 記述子が取れないケースの防御(通常起きない)。if ("value" in descriptor) — 値を持つ普通のプロパティなら、記述子をそのままコピーしつつ value だけ strip() を通した値に差し替える。{ ...descriptor, value: strip(descriptor.value) } がそれ。 // アクセサはレシーバ(this)に紐づく。V8 の Error.stack がこれで、
// 記述子をそのまま写すと複製側で undefined になるため、値を読んで写す
平易な言い換え: V8(Chrome / Node.js の JavaScript エンジン)では Error.stack が「読んだときに計算する getter」として実装されている。この getter は「自分(this)のスタック情報」を返すので、記述子をそのまま新しいオブジェクトにコピーすると、新しいオブジェクトはスタック情報を持っていないので undefined が返る。だから「getter を写す」のではなく「getter を1回実行して、その結果の値を写す」。
writable: true に固定しているのは、値プロパティとして作り直すため。enumerable と configurable は元の記述子から引き継ぐ(message / stack は非列挙のまま保たれる)。(c) なぜプロトタイプを引き継ぐのか
コメント原文(lib/logger/redact.ts:115-122):
/** * 例外オブジェクトから禁止キーを除いた複製を返す。 * * プロトタイプを引き継ぐのは、pino の err シリアライザが出す `type` が * コンストラクタ名由来で、素の `Error` に詰め替えると * `PrismaClientKnownRequestError` のような型名が失われるため。 * `message` / `stack` は非列挙のまま記述子ごと写して出力を維持する。 */
平易な言い換え: pino はエラーをログに出すとき "type":"PrismaClientKnownRequestError" のようにクラス名を書く。この名前は「そのオブジェクトの親の設計図の名前」から取られる。複製を作るときに new Error(...) で作り直すと、クラス名が "Error" になってしまい、「どの種類のエラーか」という最も重要な手がかりが消える。だから親の設計図を引き継いだ複製を作る。
テストがこれを固定している(redact.test.ts:250-266 — cleaned のクラス名や stack が保たれることを検証)。
呼び出し元: lib/logger/redact.ts:189(enforceForbiddenErrorFieldsPolicy の本番枝)、テスト redact.test.ts:250, 266, 293。
lib/logger/redact.ts:192-199:
/** getter が投げてもログ処理を止めない(ログの失敗でアプリを落とさない) */
function readAccessor(source: Record<string, unknown>, key: string): unknown {
try {
return source[key];
} catch {
return undefined;
}
}
(a) オブジェクトとキー名を受け、値を読んで返す。読めなければ undefined。
(b) try/catch 1本。catch {} はエラー変数を書かない書き方(受け取っても使わないため)。
(c) コメントがそのまま理由: getter は任意のコードなので例外を投げうる。もしここで例外が漏れると、「ログを取ろうとしたらアプリが落ちた」ことになる。docs/logging-design.md:385 の「ログの失敗でアプリを落とさない」がここにも適用されている。
呼び出し元: lib/logger/redact.ts:144(stripForbiddenFieldsFromError のアクセサ枝)のみ。
シグネチャ(lib/logger/redact.ts:161-173):
export function enforceForbiddenFieldsPolicy<T>(
payload: T,
isProduction: boolean
): ForbiddenFieldsPolicyResult<T> {
const leaked = findForbiddenFields(payload);
if (leaked.length === 0) return { payload, leaked };
if (!isProduction) {
throw new Error(
`ログに禁止フィールドを含められません: ${leaked.join(", ")}`
);
}
return { payload: stripForbiddenFields(payload), leaked };
}
(a) 引数と戻り値の業務上の意味
payload: T — ログに出そうとしているイベント。isProduction: boolean — 本番かどうかを外から渡す。自分で process.env を読まない(テスト容易性のため)。ForbiddenFieldsPolicyResult<T> = { payload: T; leaked: string[] }
payload — 出力してよい形にしたもの(コメント 65行)。leaked — 検出したキー名。ログ本文には出さず件数のみ使う(コメント 67行)。(b) 中の処理を条件分岐まで全部(3本)
| 順 | 条件 | 結果 |
|---|---|---|
| 1 | leaked.length === 0(禁止キーなし) |
{ payload, leaked: [] } を返す。元のオブジェクトをそのまま返す(複製しない。無駄なコピーを避ける) |
| 2 | !isProduction(非本番)かつ禁止キーあり |
例外を投げる。メッセージは日本語で、leaked.join(", ") によりどのキーが問題かを列挙する |
| 3 | 本番かつ禁止キーあり | { payload: stripForbiddenFields(payload), leaked } を返す。キーを取り除いた複製と、取り除いたキー名の両方を返す |
分岐3の「両方を返す」形がミソ。 呼び出し側(emitTo)は payload を出力に使い、leaked を別行の warn(forbidden_keys)に使う。1回の関数呼び出しで2つの用途を満たしている。
★本番と非本番の違いをまとめた表★
| 観点 | 非本番(NODE_ENV !== "production") |
本番(NODE_ENV === "production") |
|---|---|---|
| 禁止キーを検出したときの動作 | 例外を投げる | 例外を投げない。キーを取り除いて続行 |
| ログは出るか | 1行も出ない(emitTo が検査を先に済ませるので、本文も warn も出ない) |
元のログ(キー除去済み・元のレベルのまま)+ log.forbidden_fields_stripped の warn 1行 |
| 開発者が気づく方法 | テストが赤くなる/画面がエラーになる/その場で止まる | ログを後から読んで forbidden_keys を見つける |
| リクエストへの影響 | 500 になる可能性がある(catch 節から呼ばれた場合、元のエラー処理が壊れる) | 影響なし。リクエストは正常に完走する |
(c) なぜ環境で分けるのか
コメント原文(lib/logger/redact.ts:153-160):
/** * 環境に応じた扱いを決める。 * * 非本番では例外を投げて開発中に気づかせる。 * 本番では投げずにキーを取り除く。ログ呼び出しは catch 節の内側から行われるため、 * ここで例外を投げると元のエラー処理を壊し、ログの機構がリクエストを 500 にしてしまう。 * 「ログの失敗でアプリを落とさない」を優先する。 */
docs/logging-design.md:382-385 の指示原文:
禁止フィールド検出時の挙動:
NODE_ENVがproduction以外では例外を投げて開発中に気づかせる。本番では投げず、該当キーを取り除いてwarnで記録し、ログ呼び出し自体は成功させる。 理由: ログ呼び出しは catch 節の中から行われる。そこで例外を投げると元のエラー処理を壊し、ログのための機構がリクエストを 500 にしてしまう。「ログの失敗でアプリを落とさない」が優先
docs/decisions.md:243-245(D-047)も同じ:
マスキング: 個人情報・自由記述と認証情報をキー名で弾く3段構え(allowlist 型 + 実行時チェック + テスト)。施設は
facility_idで参照し名前を書かない。非本番は例外、本番はキーを除去して継続する(ログ呼び出しは catch 節の中から行われるため、ここで例外を投げるとリクエストを 500 にしてしまう)
★この非対称の設計思想を平易に★
| 環境 | 何を優先するか | 理由 |
|---|---|---|
| 開発中 | 早く気づくこと | 開発中に例外が飛んでも困るのは開発者だけ。逆に気づかないまま本番へ出す方が危険 |
| 本番 | アプリを止めないこと | ログを取るための仕組みが原因で、お客さんの操作が失敗するのは本末転倒。個人情報はキー除去で守れるので、両方を満たせる |
「catch 節の内側から呼ばれる」という事実が全ての根拠。 実際のルートのコード(app/api/clean-logs/route.ts:41-46 付近)は try { ... } catch (e) { ...; logApiError({...}); return NextResponse.json(body, {status:500}) } という形で、logApiError は既にエラー処理をしている最中に呼ばれる。そこで新しい例外が飛ぶと、return に到達できず、クライアントには何も返らない(あるいは Next.js の既定のエラー画面になる)。
呼び出し元: lib/logger/index.ts:78(emitTo の中)、テスト redact.test.ts:188, 197, 202, 211。
シグネチャ(lib/logger/redact.ts:178-190):
export function enforceForbiddenErrorFieldsPolicy(
err: unknown,
isProduction: boolean
): ForbiddenErrorFieldsPolicyResult {
const leaked = findForbiddenFieldsInError(err);
if (leaked.length === 0) return { err, leaked };
if (!isProduction) {
throw new Error(
`ログの err に禁止フィールドを含められません: ${leaked.join(", ")}`
);
}
return { err: stripForbiddenFieldsFromError(err), leaked };
}
enforceForbiddenFieldsPolicy と完全に同じ構造で、payload の代わりに err を扱う。分岐も3本で同じ。
コメント(175-177行):
/** * err に対する同じ方針。分岐の理由は enforceForbiddenFieldsPolicy と同じ。 */
2つの関数を分けている理由(コード上の事実から): payload(素のオブジェクト)と err(プロトタイプ・非列挙プロパティ・アクセサを持つ特殊なオブジェクト)で、検査方法(Object.entries vs Object.entries+walk)と複製方法(strip vs Object.create+記述子コピー)が根本的に違うため。1つの関数にまとめると分岐が入り組む。
エラーメッセージが違うことに注意: 「ログに禁止フィールドを含められません」(payload 側)と「ログのerr に禁止フィールドを含められません」(err 側)。開発者が例外を見たときにどちら側の問題かが即座に分かる。全社ルール7の「エラーメッセージを必ず用意する」への具体的な対応。
戻り値の型 ForbiddenErrorFieldsPolicyResult(71-75行):
export interface ForbiddenErrorFieldsPolicyResult {
/** 出力してよい形にした例外オブジェクト */
err: unknown;
leaked: string[];
}
ForbiddenFieldsPolicyResult<T> と違いジェネリクスが無い。err は unknown のままで、型を保つ意味がないため(例外の型は呼ぶ側も知らない)。
呼び出し元: lib/logger/index.ts:86(emitTo の中)、テスト redact.test.ts:272, 279, 286, 294。
テストが undefined のケースも固定している(redact.test.ts:294): enforceForbiddenErrorFieldsPolicy(undefined, false) → { err: undefined, leaked: [] }。findForbiddenFieldsInError(undefined) が 97行で即 return found(空配列)するので、分岐1に落ちる。非本番でも例外を投げない(漏れが無いので投げる理由がない)。
全226行。関数が1つも無い、型だけのファイル。「ログに書いてよい項目の一覧表」。
// 8-13行
import type {
AdviceRuleId,
ErrorCode,
EscalationLevel,
Grade,
} from "@/lib/types";
import type だけ。 実行時にはこのファイルは完全に消える(型情報はコンパイル後に残らない)。つまりこのファイルは1バイトも実行されない。
何を import していないか: pino なし、lib/db なし、lib/logger/redact なし。純粋な型定義だけ。
型エイリアス(4件):
export type LogDataSource = "fixtures" | "prisma"; // 16行 export type PeriodMode = "summary_table" | "on_the_fly"; // 22行 export type DbWriteTable = "robot_error_reports" | "control_error_histories";// 25-27行 export type DbWriteColumn = "status" | "comment" | "responded_at" | "resolved_at"; // 30-34行
イベント型(10件のインターフェース):
export interface AppStartLogEvent // 43行 event: "app.start" export interface AppUncaughtLogEvent // 58行 event: "app.uncaught" export interface ApiErrorLogEvent // 76行 event: "api.error" export interface JudgeGradeLogEvent // 89行 event: "judge.grade" export interface JudgeEscalationLogEvent // 109行 event: "judge.escalation" export interface JudgeAdviceLogEvent // 133行 event: "judge.advice" export interface DbWriteLogEvent // 162行 event: "db.write" export interface InspectionLogEvent // 183行 event: "inspection.read" | "inspection.write" | "inspection.photo" export interface DataAnomalyLogEvent // 194行 event: "data.anomaly" export interface ForbiddenFieldsStrippedLogEvent // 209行 event: "log.forbidden_fields_stripped"
共用型(1件):
export type LogEvent = // 217-226行 | AppStartLogEvent | AppUncaughtLogEvent | ApiErrorLogEvent | JudgeGradeLogEvent | JudgeEscalationLogEvent | JudgeAdviceLogEvent | DbWriteLogEvent | InspectionLogEvent | DataAnomalyLogEvent;
★ForbiddenFieldsStrippedLogEvent が LogEvent に入っていない(9件しか並んでいない)★ — 前述の通り、呼び出し側が偽装できないようにするため(events.ts:208 のコメント「呼び出し側が偽装できないよう LogEvent の union には含めない」)。
ファイル冒頭コメント(1-7行):
/** * ログイベントの allowlist 型。 * * 許可フィールドだけを型に載せるのは、P1 が実在施設の個人情報を扱うため。 * ログはデバッグ起点として AI チャットへ貼られる経路があるので、 * 自由記述・氏名系のキーを型から排除し、誤って渡すとコンパイルを落とす。 */
平易な言い換え: 「書いてはいけないものを列挙する」(denylist / 禁止リスト)ではなく「書いてよいものだけを列挙する」(allowlist / 許可リスト)方式。禁止リストは「まだ思いついていない危険なキー」を漏らすが、許可リストは「まだ許可していないキー」が自動的に禁止になる。漏れの向きが安全側になる。
docs/logging-design.md:45(決定サマリ):
| マスキング | allowlist 型で型レベルに強制 + 禁止フィールド名のユニットテスト |
AppStartLogEvent(43-50行) — サーバー起動時に1回。
export interface AppStartLogEvent {
event: "app.start";
/** 実際に効いているレベル。LOG_LEVEL 未設定時の既定値も含めて確認できるようにする */
log_level: string;
node_env: string;
/** nodejs / edge。register() は両ランタイムで呼ばれる */
next_runtime: string;
}
コメント(36-42行)にあえて持たせなかったフィールドの理由がある:
* `data_source` は mixin が付けるので持たせない。 * `route` / `method` も持たせない(リクエスト起因ではないので、 * 埋めると不正確な推測情報になる。不明な情報は書かない)。
★「不明な情報は書かない」がこのプロジェクトの一貫した規律。 docs/logging-design.md:262-264(§6)も同じことを言っている:
「誰が」の欄は設けない。 P1 にはユーザーアカウントが無く共有トークン1本のみ(CLAUDE.md §3)。個人識別子を無理に入れると「今使われている共有トークンの持ち主」という不正確な推測情報になる。不明な情報は書かない。
AppUncaughtLogEvent(58-70行) — try/catch で拾えなかったエラー。
export interface AppUncaughtLogEvent {
event: "app.uncaught";
/** 実際のパス。**クエリ文字列は落とす**(`facility_name` 等の値が載るため) */
route: string;
method: string;
/** ルートファイルのパス(例: /api/clean-logs/[id]/advice) */
route_path: string;
router_kind: "Pages Router" | "App Router";
route_type: "render" | "route" | "action" | "proxy";
revalidate_reason?: "on-demand" | "stale";
/** React 経由で元の例外が差し替わった場合の識別子 */
error_digest?: string;
}
route のコメント「クエリ文字列は落とす」が実装されている場所: instrumentation.ts:51-53
// request.path はクエリ文字列を含む(例: /api/clean-logs?facility_name=...)。
// 施設名などの値が載るため、パス部分だけにする(§4 のマスキング規約)
const route = request.path.split("?")[0] ?? request.path;
★これは redact.ts では防げない漏洩経路。 route は許可フィールドなので、値に ?facility_name=セブンイレブン〇〇店 が入っていてもキー名検査は通ってしまう。だから呼び出し側で切り落としている。「キー名で弾く」方式の限界を、呼び出し側の1行で補っている実例。
api.error を流用しない理由(コメント 52-57行):
* `api.error` を流用しない。`api.error` は「ルートの catch 節で意図的に処理した」 * という意味を持たせてあり、捕捉漏れとは性質が違う。混ざると障害調査で区別できない。
ApiErrorLogEvent(76-83行)
export interface ApiErrorLogEvent {
event: "api.error";
route: string;
method: string;
error_code: ErrorCode;
error_message: string;
http_status: number;
}
コメント(72-75行): 「error_message はクライアント定型文のみ。生の例外メッセージは転記しない。」
error_code: ErrorCode の型は lib/types/index.ts:24-28 の4値共用型:
export type ErrorCode = | "validation_failed" | "not_found" | "internal_error" | "not_implemented";
docs/logging-design.md:132-134 がこの型強化を指示していた:
lib/types/index.tsのApiError.error.codeを、現在使われている4値(validation_failed/not_found/internal_error/not_implemented)を列挙した union 型に強化する。以後コードを増やすには型に追加が必要になり、実質 enum の強制力を持つ。
実装済み。 かつ lib/types/index.ts:20-22 にログとの関係が明記されている:
* クライアントへ返す `error.code`。 * ログの `error_code` と同一の変数を再利用し、ログ側で別文字列を組み立てない(logging-design.md §3)。 * YAML は `type: string` のまま据え置き、内部 TS のみ union で強制する。
JudgeGradeLogEvent(89-103行)/JudgeEscalationLogEvent(109-121行)/JudgeAdviceLogEvent(133-156行) — 判定の根拠を残す3つ。
3つとも「入力の生値 + しきい値スナップショット + 結果」の3点セットを持つ。JudgeAdviceLogEvent のコメント(123-132行)が理由を最も詳しく書いている:
/** * `matchAdviceRules()` 呼び出し直後。 * 文言ではなく発火したルール ID だけを残す(自由記述のアドバイス本文は禁止)。 * * 入力の生値としきい値スナップショットを持つ理由: * `matchAdviceRules()` は `GRADE_THRESHOLDS`(R2 / B1)と * `ESCALATION_THRESHOLDS`(R3 / R4)の両方を読んでいる。 * F-10(M5)でしきい値が編集可能になると、同じ入力でも判定日によって * 発火するルールが変わるため、結果だけでは後から再現できない(§5 の3点セット)。 */
docs/logging-design.md:214-217 が同じ根拠を述べている:
thresholds_usedを残す理由:docs/spec.mdF-10(運用設定)でしきい値が編集可能になる予定(M5、未着手)。そうなると同じ生値でも判定日によって結果が変わる。判定関数自体は決定的でテストで担保されているので、後から再現するために本当に必要なのは実行過程の逐一記録ではなく「その時点で使われた入力値としきい値のペア」である。
raw_completion_rate の「raw」(生値)が重要。 JudgeAdviceLogEvent:141 のコメント:
/** 入力: **丸める前の生値**(CLAUDE.md §4.3)。未出発は null */ raw_completion_rate: number | null;
docs/logging-design.md:229-232:
条件4を必須にする理由: CLAUDE.md §4.3 は「表示用に丸めた値で判定すること」を最も事故る箇所と明記している。
89.5は表示90%だが判定は ○ であり、条件1〜3だけだとこの行は◎○側なので記録されず、まさに疑義が生じる場面のログが無いことになる。
DbWriteLogEvent(162-178行) — メインDB書き込みの監査。
export interface DbWriteLogEvent {
event: "db.write";
route: string;
method: string;
table: DbWriteTable;
record_id: number;
columns: DbWriteColumn[];
status_old?: string;
status_new?: string;
responded_at_old?: string | null;
responded_at_new?: string | null;
resolved_at_old?: string | null;
resolved_at_new?: string | null;
comment_changed: boolean;
success: boolean;
db_error_code?: string;
}
コメント(158-161行): 「メインDB書き込みの監査(成功・失敗とも必須)。comment の内容は残さず、変わったかどうかの boolean だけにする。」
table と columns が専用の型で絞られているのが要点。DbWriteTable は2テーブル、DbWriteColumn は4カラムのみ。書いてよい範囲を型で表現しているので、許可外のテーブル名を書こうとするとコンパイルが落ちる。lib/db/index.ts:14-16 の「書いてよいのは...4カラムのみ(D-042)」がログの型にも反映されている。
comment_changed: boolean — 前述の通り comment とは別文字列なので redact.ts を通る。設計(docs/logging-design.md:258)と実装が一致。
DataAnomalyLogEvent(194-202行)
export interface DataAnomalyLogEvent {
event: "data.anomaly";
route: string;
method: string;
metric: string;
expected: number | string;
actual: number | string | null;
period_mode?: PeriodMode;
}
コメント(191-193行): 「期待値と実測の乖離。原因の断定(例: TeamWorks のバグ)は書かない。」
docs/logging-design.md:284-286:
メッセージに「TeamWorks側のバグ」のような断定を書かないこと。 期待値と実測値の差分のみを記録する。DFA 側は TeamWorks の内部処理を観測できないため断定はできず、誤った断定が残ると後から読む人(開発者本人を含む)を誤誘導する。
★型に「原因」フィールドが存在しないことで、断定を書く場所自体を作っていない。 規約をコメントで書くのではなく、型で不可能にしている。
period_mode?: PeriodMode("summary_table" | "on_the_fly") — コメント(18-21行):
/** * 確定期間は TeamWorks サマリ、当期は DFA のその場集計(D-023)。 * どちらで起きたかで疑う先が変わるため、該当イベントに載せる。 */
docs/logging-design.md:277 の切り分け表と一致。
| イベント型 | 実際に出している箇所 |
|---|---|
AppStartLogEvent |
instrumentation.ts:24-29 |
AppUncaughtLogEvent |
instrumentation.ts:60-69(logEvent("error", event, error) として 76・79行) |
ApiErrorLogEvent |
lib/logger/api.ts:36-43(9ルートの catch から logApiError 経由) |
JudgeAdviceLogEvent |
app/api/clean-logs/[id]/advice/route.ts:64-83 |
JudgeEscalationLogEvent |
app/api/clean-logs/[id]/advice/route.ts:99-110 |
DataAnomalyLogEvent |
app/api/dashboard/route.ts:85-93 |
ForbiddenFieldsStrippedLogEvent |
lib/logger/index.ts:92-96(ロガー内部) |
JudgeGradeLogEvent |
まだ出されていない(型定義のみ) |
DbWriteLogEvent |
まだ出されていない(型定義のみ) |
InspectionLogEvent |
まだ出されていない(型定義のみ) |
未使用3件はいずれも設計文書が「まだ書く場所が無い」と明記しているものであり、書き忘れではない:
judge.grade — docs/logging-design.md:192, 198-199: 「judgeGrade は実行時の呼び出し箇所が無い」「Prisma 実装(lib/db/index.ts の prismaSource)が入ると、DB の行を CleanLogItem に変換する際に judgeGrade を呼ぶことになる。そこが judge.grade の記録先である。」db.write — docs/logging-design.md:242-243: 「現時点で lib/db/index.ts の DataSource に書き込みメソッドはまだ無い。PATCH /prechecks/{id}/status / PATCH /ds-alerts/{id}/status(M3)の実装と同時に組み込む。」inspection.* — 点検記録ストア(INSPECTION_DB_URL の SQLite)が未実装。db.inspections() / db.inspectionDetail() にも呼び出し元が無いことと整合する。未使用の型エイリアス:
LogDataSource — export type LogDataSource = "fixtures" | "prisma"(16行)。どのイベント型にも使われていない(data_source は mixin が付けるので型に載らない)。grep 実測で参照は宣言行のみ。コメント「mixin が付与するデータ源。USE_FIXTURES をログ側で再解釈しない」は、この型が記録すべき値の一覧を文書として残す役割であることを示している(推測を含む解釈)。全50行。APIルートの catch 節から呼ぶための専用ヘルパー。
// 9-11行
import type pino from "pino";
import { emitTo, logger } from "@/lib/logger";
import type { ApiError } from "@/lib/types";
何を import していないか:
logEvent を import していない。 emitTo を直接使う。理由は後述(出力先を差し替えられる形を保つため)。lib/logger/redact を import していない。 検査は emitTo が済ませる。この層は「イベントの形を組み立てる」だけ。import type pino from "pino" — 型としてだけ借りている。pino の実体は使わない(emitTo に渡すだけ)。export interface ApiErrorLogParams { // 13-22行
/** 実際のパス。`/api/v1/...` のような架空のパスを書かない */
route: string;
method: string;
/** クライアントへ返すものと同一の body */
body: ApiError;
status: number;
/** 例外そのもの。pino の err シリアライザに渡す */
err: unknown;
}
export function logApiErrorTo(
target: pino.Logger,
params: ApiErrorLogParams
): void // 29-32行
export function logApiError(params: ApiErrorLogParams): void // 48行
まず「そうでない設計」と比べる。
素直に書けばこうなる(これは実装には無い。比較用):
// 案A: 必要な値だけを個別に受け取る
logApiError({ route, method, errorCode: "internal_error", errorMessage: "サーバー内部エラー", status: 500, err: e });
実装はこう(ApiErrorLogParams の body: ApiError):
// 案B(採用): クライアントへ返す body をまるごと渡す
logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
そして関数の中で body から取り出す(lib/logger/api.ts:40-41):
error_code: params.body.error.code,
error_message: params.body.error.message,
理由(コメント原文 lib/logger/api.ts:1-8):
/** * APIルートの catch 節から呼ぶロギングヘルパー。 * * クライアントへ返す `ApiError` を**そのまま**受け取るのが要点。 * ログ側で code / message を組み立て直すとレスポンスとズレるため、 * 同一オブジェクトから読む形にして構造的に防ぐ(docs/logging-design.md §3)。 * 生の例外メッセージは `error_message` に入らない(定型文のみ)。 */
docs/logging-design.md:128(§3 のエラーイベント追加フィールド表):
|
error_code|ErrorCodeunion | クライアントに返すerror.codeと同一の変数を再利用する。 ログ側で別途文字列を組み立てないこと(ズレを構造的に防ぐ) |
★「ズレを構造的に防ぐ」を具体例で★
案A(個別に渡す)で起きる事故:
const body: ApiError = { error: { code: "internal_error", message: "サーバー内部エラーが発生しました" } };
logApiError({ ..., errorCode: "internal_error", errorMessage: "内部エラー" }); // ← 文言が違う
return NextResponse.json(body, { status: 500 });
書いた瞬間は同じつもりでも、あとで body の文言だけを直したときにログ側が置き去りになる。すると調査時に「ログには『内部エラー』とあるが、お客さんの画面には『サーバー内部エラーが発生しました』と出た」という状態になり、同じエラーなのか別のエラーなのか判断できない。
案B(採用)なら、body は1つしかないので物理的にズレようがない。body.error.message を直せばログもレスポンスも同時に変わる。
用語: 「構造的に防ぐ」= 「気をつけて書く」ではなく「間違った書き方ができない形にする」。この文書全体に一貫する思想(notImplemented の例外、logEvent のオーバーロード、allowlist 型、すべて同じ)。
副次的な利点(コード上の事実): ルート側の呼び出しが短くなる。実際の呼び出しは全ルートで同じ1行:
logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
body は直前で作った変数をそのまま渡すだけ。code と message を書き写す作業自体が発生しない。
ApiErrorLogParams の各フィールドのコメントも規約を持っている:
| フィールド | コメント | 対応する規約 |
|---|---|---|
route |
「実際のパス。/api/v1/... のような架空のパスを書かない」 |
docs/logging-design.md:117「実際のパスを入れる(/api/v1/... と書かない)」 |
body |
「クライアントへ返すものと同一の body」 | 同 §3(上記) |
err |
「例外そのもの。pino の err シリアライザに渡す」 | 同 380-381行「JSON.stringify(error) は使わない」 |
/api/v1/... を禁じている理由(推測を含む読み取り): このアプリの実際のパスは /api/clean-logs のようにバージョン番号を含まない。一般的な API 設計の慣習で v1 を書いてしまうと、ログに存在しないパスが残り、grep しても実際のアクセスと突き合わせられない。
シグネチャ: logApiErrorTo(target: pino.Logger, params: ApiErrorLogParams): void(lib/logger/api.ts:29-46)
export function logApiErrorTo(
target: pino.Logger,
params: ApiErrorLogParams
): void {
emitTo(
target,
"error",
{
event: "api.error",
route: params.route,
method: params.method,
error_code: params.body.error.code,
error_message: params.body.error.message,
http_status: params.status,
},
params.err
);
}
(a) 引数と戻り値の業務上の意味
target: pino.Logger — 出力先ロガー。本番は共有の logger、テストは捕獲用。params: ApiErrorLogParams — ルートが持っている情報一式。void — 副作用(1行出力)のみ。(b) 中の処理
条件分岐は1本も無い。 ApiErrorLogParams を ApiErrorLogEvent の形に組み替えて emitTo に渡すだけ。組み替えの対応:
ApiErrorLogParams |
→ | ApiErrorLogEvent |
|---|---|---|
| (固定値) | → | event: "api.error" |
params.route |
→ | route |
params.method |
→ | method |
params.body.error.code |
→ | error_code |
params.body.error.message |
→ | error_message |
params.status |
→ | http_status |
params.err |
→ | emitTo の第4引数(err) |
params.body.details は使われていない。 ApiError の型(lib/types/index.ts:30-36)には details?: Record<string, string[]> | null があるが、ログには載せていない。details はバリデーション失敗時に「どの項目がどう駄目か」を入れる欄で、値が入りうる(例: { facility_name: ["不正な値です"] } のようにキー名が入る可能性)。ApiErrorLogEvent に details が無いので、そもそも渡せない。(この省略理由はコメントに明記されていない — 推測だが、allowlist 型の方針に沿った意図的な除外と読める。)
レベルが "error" に固定されている。 第2引数がハードコードなので、この関数から info や warn を出すことはできない。API のエラーは必ず error レベル。
(c) なぜ logApiError と2段になっているのか
コメント原文(lib/logger/api.ts:24-28):
/** * 出力先を差し替えられる版。 * 5ルートすべてが通る関数なので、実出力をテストで検証できる接合部を用意する * (`emitTo` / `logEvent` と同じ形)。設定を二重に書かないため target を渡すだけにする。 */
「5ルートすべてが通る関数なので」が理由の核心。 ここが壊れると全ルートのエラーログが同時に壊れる。だから実際の出力をテストで検証できる形(出力先を差し替えられる形)を必ず用意する。lib/logger/__tests__/api.test.ts がこれを使って検証している。
なお実測ではルート数は9(logApiError を呼ぶファイルが9つ)。コメントの「5ルート」は docs/logging-design.md:397-405 の当時のルート一覧に基づく記述で、現在は増えている。
emitTo / logEvent と同じ形 = 「〜To(出力先を受け取る版)」と「短縮版(共有ロガー固定)」の2段構え。3組すべて同じパターン:
| 出力先を受け取る版 | 共有ロガー固定版 |
|---|---|
emitTo(target, level, payload, err?) |
logEvent(level, payload, err?) |
logApiErrorTo(target, params) |
logApiError(params) |
createLogger(destination?) |
logger(= createLogger()) |
呼び出し元: lib/logger/api.ts:49(logApiError の中)、テスト lib/logger/__tests__/api.test.ts:39, 185。アプリ本体から直接呼んでいる箇所は無い。
シグネチャ: logApiError(params: ApiErrorLogParams): void(lib/logger/api.ts:48-50)
export function logApiError(params: ApiErrorLogParams): void {
logApiErrorTo(logger, params);
}
(a)(b) 引数は params のみ。中身は1行、共有ロガーを固定して logApiErrorTo に委譲。分岐なし。
(c) ルート側が毎回 logger を書かなくて済むようにする短縮版。
呼び出し元(実測、9ファイル9箇所):
app/api/clean-logs/route.ts:44 logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e });
app/api/clean-logs/export/route.ts:73 (同じ形)
app/api/clean-logs/[id]/advice/route.ts:44 (同じ形)
app/api/clean-logs/[id]/areas/route.ts:39 (同じ形)
app/api/dashboard/route.ts (同じ形)
app/api/ds-alerts/route.ts (同じ形)
app/api/ds-devices/route.ts (同じ形)
app/api/prechecks/route.ts (同じ形)
app/api/settings/advice/route.ts (同じ形)
9箇所すべてが同一の書き方({ route: ROUTE, method, body, status: 500, err: e })。docs/logging-design.md:303-304(§8 ステップ9)の「5箇所・同一パターンなので一気に行う」という方針が、ルートが増えた後も維持されている。
全141行。画面(ブラウザ側)から自アプリの API を呼ぶための唯一の入口。
// 17行
import type { ErrorCode } from "@/lib/types";
これ1本だけ。しかも import type。
何を import していないか、そしてその意味:
lib/logger を import していない。 ★重要★ このファイルはブラウザで動く(components/features/**/*.tsx から呼ばれる)。lib/logger は pino を使い、pino は Node.js の API に依存するので、ブラウザ側の import に混ぜられない。だから API クライアントはログを出さない。lib/db を import していない。 ブラウザから DB は読めない(docs/decisions.md:225-226(D-046)「Prisma はブラウザでは動かせない(DB 接続情報をブラウザに渡せない)」)。axios などの HTTP ライブラリを import していない。 ブラウザ標準の fetch を直接使う(依存を増やさない)。next を import していない。 Next.js に依存しない素の Web 標準 API だけで書かれている。export class ApiFetchError extends Error { // 28-53行
readonly status: number;
readonly code: ErrorCode | null;
readonly serverMessage: string | null;
readonly details: Record<string, string[]> | null;
constructor(params: {
status: number;
code: ErrorCode | null;
serverMessage: string | null;
details?: Record<string, string[]> | null;
cause?: unknown;
});
}
export async function apiFetch<T>(
path: string,
init?: RequestInit
): Promise<T> // 90-93行
export していない内部関数:
function parseApiError(body: unknown): { // 63-67行
code: ErrorCode | null;
message: string;
details: Record<string, string[]> | null;
} | null
ファイル冒頭コメント(1-16行):
/**
* フロントから自アプリの API ルートを叩くための唯一の入口。
*
* ★fetch を画面に直書きせず、必ずここを通すこと★
*
* 1箇所に寄せる理由:
* - **認証方式が未確定(cookie 予定)。** 決まった際に付与処理を入れる場所がここになる。
* 画面ごとに fetch が散っていると、認証の追加が全画面の修正になる。
* なお同一オリジンの fetch は既定で cookie を送るため、現時点で追加設定は要らない。
* - **エラー形式 `{ error: { code, message, details? } }` と一覧の
* `meta { total, page, per_page }` は CLAUDE.md §9.3 が全API共通と定めている。**
* 共通の形が決まっているものは、共通の場所で解釈するのが筋。
* - `docs/spec.md` にあと9画面が控えており、呼び出しが数十箇所へ散る前に寄せる。
*
* 外部URLは扱わない。D-046 の通りビューアーは自アプリの API ルート経由でしかデータを読まない。
*/
3つの理由を平易に:
CLAUDE.md §3 の簡易トークン + IP制限)が決まったとき、fetch が20箇所に散っていれば20箇所を直す。1箇所なら1箇所。「まだ決まっていないものに備える」形の依存整理。
credentials: "include" を書き足していない)。<T> が何を意味するか(平易に)★export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T>
用語: ジェネリクス(generics / 総称型)= 「型を後から差し込める穴」。
日常のたとえ: 宅配便の伝票に「中身: __」という空欄がある。伝票の書式(配送の仕組み)は中身が何であっても同じだが、受け取る人は「中身は本です」と伝票に書いてあれば、開ける前に何が入っているか分かる。<T> はその空欄。
具体的に何が起きるか(実際の呼び出しで比較):
// components/features/dashboard/DashboardScreen.tsx:44
apiFetch<DashboardResponse>("/api/dashboard"),
// → 戻り値の型が Promise<DashboardResponse> になる。
// body.summary や body.schedule_progress を書くと補完が効き、
// body.summaryy と打ち間違えるとコンパイルが落ちる
// components/features/dashboard/DashboardScreen.tsx:45
apiFetch<ListResponse<CleanLogItem>>(`/api/clean-logs?${qs}`),
// → 戻り値の型が Promise<ListResponse<CleanLogItem>> になる。
// body.items の各要素が CleanLogItem として扱われる
// components/features/ds-logs/DsLogScreen.tsx:71
apiFetch<SummaryListResponse<DsDevice, DsDeviceSummary>>(...)
// → summary と items で別の型を持つレスポンス
<T> が無い場合との比較:
| 設計 | 戻り値の型 | 呼ぶ側が書くこと |
|---|---|---|
<T> なし(Promise<unknown>) |
unknown |
毎回 as DashboardResponse と自分で断言する(書き忘れる/間違った型を書く) |
<T> なし(Promise<any>) |
any |
何も書かなくていいが、型検査が完全に無効になる(打ち間違いも通る) |
<T> あり(採用) |
Promise<T> |
呼ぶ場所で1回だけ型名を書く。以降は補完も検査も効く |
★重要な注意点(コードから確定できる事実): apiFetch<T> は T を実行時に検証していない。最終行(140行)は:
return body as T;
as T は「型としてそう扱う」という断言だけで、中身が本当にその形か確かめていない。つまりサーバーが違う形を返したら、型は合っているつもりで実行時に壊れる。
これは「型は開発時の道具、実行時の検証は別問題」という TypeScript の性質そのもの。(この割り切りの理由はコメントに書かれていない — 推測だが、d-watch-api.yaml が API 契約として存在し、サーバーも同じリポジトリ内(D-046)なので、形が食い違うリスクを低く見積もっていると読める。)
lib/api/client.ts:19-53:
/**
* API 呼び出しの失敗。
*
* `serverMessage` を独立させているのは、画面が
* 「サーバーが返した文言を出す」か「画面側の既定文言を出す」かを
* 判別できるようにするため。ここを `message` に混ぜると、
* HTML のエラーページが返ったときに `"<!DOCTYPE html>..."` のような
* 文字列が画面に出てしまう。
*/
export class ApiFetchError extends Error {
/** HTTP ステータス。ネットワーク到達不能などレスポンスが無い場合は 0 */
readonly status: number;
/** `ApiError` として解釈できた場合のみ。それ以外は null */
readonly code: ErrorCode | null;
/** `ApiError` として解釈できた場合のみ。それ以外は null */
readonly serverMessage: string | null;
readonly details: Record<string, string[]> | null;
constructor(params: {
status: number;
code: ErrorCode | null;
serverMessage: string | null;
details?: Record<string, string[]> | null;
cause?: unknown;
}) {
super(params.serverMessage ?? `API request failed (status ${params.status})`, {
cause: params.cause,
});
this.name = "ApiFetchError";
this.status = params.status;
this.code = params.code;
this.serverMessage = params.serverMessage;
this.details = params.details ?? null;
}
}
用語: extends Error = 「標準の Error を土台にして、追加の情報を持つ独自のエラー型を作る」。これにより e instanceof ApiFetchError で「これは API 呼び出しの失敗だ」と判別できる。
持っている情報の全一覧(5項目):
| # | プロパティ | 型 | 意味 | null / 0 になるケース |
|
|---|---|---|---|---|---|
| 1 | status |
number |
HTTPステータスコード(404, 500 等) | ネットワーク到達不能なら 0。「サーバーに届かなかった」を 0 で表す |
|
| 2 | code |
`ErrorCode \ | null` | {error:{code}} のコード |
ApiError として解釈できなかった場合 null。JSONに code が無い場合も null |
| 3 | serverMessage |
`string \ | null` | サーバーが返した文言そのもの | 同上、または本文が JSON でなかった場合 null |
| 4 | details |
`Record \ | null` | バリデーション失敗の項目別詳細(項目名 → エラー文の配列) | 無ければ null |
| 5 | message(Error 由来) |
string |
表示用ではなく開発者向けのメッセージ | 常に何か入る(44行の ?? で既定値が入る) |
|
| 6 | cause(Error 由来、オプション) |
unknown |
元の例外(fetch が投げた TypeError など) |
ネットワーク失敗時のみ設定される | |
| 7 | name |
string |
常に "ApiFetchError"(47行で代入) |
— |
readonly の意味: 作った後に書き換えられない。エラー情報が途中で改変されないことを型で保証する。
★serverMessage を message と別に持つ理由(コメント 19-27行の核心)★
Error にはもともと message がある。それに加えて serverMessage を持つのは冗長に見えるが、コメントが理由を説明している:
serverMessageを独立させているのは、画面が「サーバーが返した文言を出す」か「画面側の既定文言を出す」かを判別できるようにするため。ここをmessageに混ぜると、HTML のエラーページが返ったときに"<!DOCTYPE html>..."のような文字列が画面に出てしまう。
具体的な事故のシナリオ:
<!DOCTYPE html><html>...503 Service Unavailable...)。message に本文をそのまま入れていたら、画面は message を表示するので<!DOCTYPE html>... という文字列がユーザーに見える。serverMessage を別に持ち、JSON として {error:{message}} の形に解釈できたときだけ埋める設計なら、HTML の場合は serverMessage = null になる。画面側の実際の使い方(components/features/dashboard/DashboardScreen.tsx:53):
(e instanceof ApiFetchError ? e.serverMessage : null) ??
?? 画面側の既定文言 と続く形(5画面すべて同じパターン):
| ファイル | 行 |
|---|---|
components/features/alerts/AlertsScreen.tsx |
79 |
components/features/dashboard/DashboardScreen.tsx |
53 |
components/features/detail-log/DetailLogScreen.tsx |
123, 176 |
components/features/ds-alerts/DsAlertsScreen.tsx |
70 |
components/features/ds-logs/DsLogScreen.tsx |
78 |
??(null 合体)で繋いでいるのがポイント。 serverMessage が null なら画面側の既定文言に落ちる。null という値が「サーバーの言葉は信用できない」という意思表示になっている。
コンストラクタの中身(37-52行)を1行ずつ:
super(params.serverMessage ?? \API request failed (status ${params.status})`, { cause: params.cause })` 親(Error)の message を決める。サーバーの文言があればそれ、無ければ英語の定型文。
message が英語なのは、これが開発者向け(コンソール・スタックトレース用)だから。 画面表示には serverMessage を使う設計なので、message は日本語である必要がない。{ cause: params.cause } — Error の第2引数 cause(原因の連鎖)。ネットワーク失敗時に元の TypeError を保持する。これがあると「なぜ失敗したか」の元情報を辿れる。this.name = "ApiFetchError"; — Error の既定は "Error" なので明示的に上書き。コンソールに ApiFetchError: ... と出る。details だけ params.details ?? null で undefined を null に正規化(? で省略可能な引数なので undefined が来る)。シグネチャ(lib/api/client.ts:63-82):
function parseApiError(body: unknown): {
code: ErrorCode | null;
message: string;
details: Record<string, string[]> | null;
} | null {
if (typeof body !== "object" || body === null) return null;
const error = (body as { error?: unknown }).error;
if (typeof error !== "object" || error === null) return null;
const { code, message, details } = error as {
code?: unknown;
message?: unknown;
details?: unknown;
};
if (typeof message !== "string") return null;
return {
code: typeof code === "string" ? (code as ErrorCode) : null,
message,
details: (details as Record<string, string[]> | null | undefined) ?? null,
};
}
(a) 引数と戻り値の業務上の意味
body: unknown — response.json() の結果。何が入っているか分からない。{ code, message, details }、形が違えば null(「これは ApiError ではない」の意思表示)。(b) 中の処理を条件分岐まで全部(5本)
if (typeof body !== "object" || body === null) return null; 本文がオブジェクトでない(文字列・数値・null)なら諦める。null を別途弾くのは typeof null === "object" の癖のため。const error = (body as { error?: unknown }).error; error プロパティを取り出す。as で「error があるかもしれないオブジェクト」として扱う。if (typeof error !== "object" || error === null) return null; error がオブジェクトでなければ諦める。{ error: "何かの文字列" } のような形も弾く。unknown(中身が何か分からない前提)。if (typeof message !== "string") return null; — ★判定の要★。message が文字列でなければ諦める。code: typeof code === "string" ? (code as ErrorCode) : null — code が文字列なら ErrorCode として扱い、そうでなければ null。code が無くても全体は成功する。details: (...) ?? null — details は無検査で通し、undefined を null に正規化。(c) なぜ message だけを必須にするのか(★意図的に緩くしている★)
コメント原文(lib/api/client.ts:55-62):
/**
* `{ error: { code, message } }` の形を読む。形が違えば null 扱い。
*
* 判定の要は `message` が文字列かどうか。`code` が欠けていても `message` は採用する。
* CLAUDE.md §9.3 は両方揃うと定めているが、ここを厳しくすると
* 規約違反のレスポンスが来たときに画面の文言が既定文へ変わってしまう
* (移行前の `err.error?.message ?? 既定文` と挙動を揃える)。
*/
平易な言い換え: 規約(CLAUDE.md §9.3)では code と message の両方が必ず揃うと決まっている。しかし規約違反のレスポンスが来たときを考える:
| 判定方法 | {error:{message:"施設が見つかりません"}}(code 欠落)が来たとき |
|---|---|
| 厳しく両方必須 | null → 画面は既定文「エラーが発生しました」を表示。サーバーが伝えたかった具体的な情報が消える |
message だけ必須(採用) |
{ code: null, message: "施設が見つかりません" } → 画面は具体的な文言を表示 |
「厳しくする方が安全」ではない、という判断。 ここでの目的は「画面に有用な情報を出すこと」で、規約違反を検出することではない。だから使える情報は使う。
「移行前の err.error?.message ?? 既定文 と挙動を揃える」も重要。 このファイルは既存コードからの移行として作られたので、移行前後で画面の挙動が変わらないことを優先している。リファクタリングで挙動を変えない、という原則。
呼び出し元: lib/api/client.ts:123(apiFetch の !response.ok 枝)のみ。
シグネチャ: apiFetch<T>(path: string, init?: RequestInit): Promise<T>(lib/api/client.ts:90-141)
(a) 引数と戻り値の業務上の意味
path: string — /api/... 形式の相対パス。絶対URLは受け付けない(コメント 88行)。init?: RequestInit — fetch に渡すオプション(メソッド・ヘッダ・本文など)。省略すると GET。Web 標準の型をそのまま使っている(独自の設定型を作っていない = 覚えることを増やさない)。Promise<T> — 成功時はパース済みの本文。コメント(84-89行):
/** * 自アプリの API を叩き、成功時はパース済みの本文を返す。 * 失敗時(HTTPエラー / JSON不正 / ネットワーク到達不能)は `ApiFetchError` を投げる。 * * @param path `/api/...` 形式の相対パス。絶対URLは受け付けない */
(b) 中の処理を条件分岐まで全部(5本)
if (/^([a-z][a-z0-9+.-]*:)?\/\//i.test(path)) {
throw new TypeError(
`apiFetch は自アプリの API ルート専用です(相対パスで指定してください): ${path}`
);
}
正規表現を分解:
| 部分 | 意味 |
|---|---|
^ |
先頭から |
([a-z][a-z0-9+.-]*:)? |
スキーム部分(http: https: ftp: javascript: など)が「あってもなくてもよい」。? が「省略可」 |
\/\/ |
//(スラッシュ2つ) |
$ は無い |
以降は何でもよい |
/i |
大文字小文字を無視(HTTPS:// も捕まる) |
弾かれるもの:
| 入力 | 弾くか | 理由 |
|---|---|---|
/api/clean-logs |
通る | // で始まらない |
/api/clean-logs?facility_id=1 |
通る | 同上 |
https://example.com/api |
弾く | スキーム + // |
http://example.com/api |
弾く | 同上 |
HTTPS://EXAMPLE.COM |
弾く | /i があるため |
//example.com/api |
弾く | ★プロトコル相対URL★(スキームを省略した外部URL)。ブラウザは現在のページと同じプロトコルで example.com へ行く。これを見逃すと外部への通信になる |
javascript://... |
弾く | スキーム扱いで捕まる |
テストが両方を固定している(lib/api/__tests__/client.test.ts:198, 201):
await expect(apiFetch("https://example.com/api")).rejects.toThrow(
await expect(apiFetch("//example.com/api")).rejects.toThrow(TypeError);
★//example.com を弾くことがこの正規表現の主目的。 単に path.startsWith("http") で判定していたら、プロトコル相対URLを見逃す。
★絶対URLを弾く理由★
ファイル冒頭コメント(15行):
* 外部URLは扱わない。D-046 の通りビューアーは自アプリの API ルート経由でしかデータを読まない。
docs/decisions.md:214-226(D-046):
D-046
[確定]ビューアーは自アプリの API ルート経由で DB を読む
- 決定: 画面はブラウザから自アプリの
app/api/...に HTTP リクエストを送り、Route Handler が Prisma で DB を読む。この構成を維持する- 確認した事実:
app/api/...は本アプリと同一プロセス・同一リポジトリの中にある(D-001)。TeamWorks が API サーバーを提供するわけではないdocs/spec.md§4「TeamWorks待ち項目の一覧」に API は1件も無い
理由を3つに整理(コードとドキュメントから読み取れること):
apiFetch("https://外部サービス/...") と書いたら、その場で TypeError が飛ぶ。credentials: "include" を書けば送れる)。入口で外部を禁止すればこの経路自体が存在しない。ApiError 形式の前提が成り立つのは自アプリだけ。 parseApiError は {error:{code,message}} という形を前提にしている。外部 API はその形を返さないので、この関数の処理が意味を持たない。TypeError を投げている点に注目。 これは ApiFetchError ではない。「通信が失敗した」のではなく「呼び出し方が間違っている」ので、種類の違うエラーにしている。画面側の catch は e instanceof ApiFetchError で判定しているので、この TypeError は画面の既定文言に落ちる(=プログラマのミスがユーザー向けメッセージに化けない)。テストも rejects.toThrow(TypeError) で型を固定している。
let response: Response;
try {
response = await fetch(path, init);
} catch (cause) {
// レスポンスが得られていないので、サーバー由来の文言は無い
throw new ApiFetchError({
status: 0,
code: null,
serverMessage: null,
cause,
});
}
fetch は「サーバーに届かなかった」場合に例外を投げる(サーバーが 500 を返した場合は例外を投げない。これが fetch の有名な癖)。
status: 0 の意味: HTTP ステータスは 100〜599 の範囲なので、0 は「HTTPのやりとりが成立しなかった」を表す番外の値。コメント(29行)「HTTP ステータス。ネットワーク到達不能などレスポンスが無い場合は 0」。
cause に元の例外を入れているので、開発者はコンソールで元の原因(DNS 解決失敗・接続拒否など)を辿れる。テストが status: 0 を固定している(client.test.ts:67-70 付近の系)。
// 失敗時も本文を読む。ApiError が入っていれば文言を画面へ渡せる
let body: unknown;
let parsed = true;
try {
body = await response.json();
} catch {
parsed = false;
}
★この位置が重要。 if (!response.ok) の前に本文を読んでいる。つまり成功・失敗を問わず必ず本文を読む。
★エラーでも body を読む理由★
コメント(113行)がそのまま答え: 「失敗時も本文を読む。ApiError が入っていれば文言を画面へ渡せる」。
平易な言い換えと、読まない場合との比較:
素直に書くとこうなる(実装には無い。比較用):
if (!response.ok) throw new Error(`HTTP ${response.status}`); // 本文を読まずに諦める
const body = await response.json();
この書き方だと、サーバーが親切に返してくれた情報が全部捨てられる:
| サーバーの応答 | 本文を読まない場合の画面 | 本文を読む場合(採用)の画面 |
|---|---|---|
404 + {"error":{"code":"not_found","message":"指定された清掃ログが見つかりません"}} |
「エラーが発生しました」(既定文) | 「指定された清掃ログが見つかりません」 |
400 + {"error":{"code":"validation_failed","message":"日付範囲が不正です","details":{"from":["形式が不正です"]}}} |
「エラーが発生しました」 | 「日付範囲が不正です」+ どの項目が駄目か(details) |
500 + HTML のエラーページ |
「エラーが発生しました」 | 「エラーが発生しました」(parsed が false → serverMessage: null → 既定文に落ちる) |
★HTTP のエラー応答は「本文が空」ではない。 REST API のエラー応答には理由が入っているのが普通で、それを読まないのは情報を自分で捨てていることになる。
parsed フラグの役割: 「本文を JSON として読めたか」を覚えておく変数。try/catch の中で body に代入し、失敗したら parsed = false にする。body の値そのもので判定できない(null という正当な JSON もあり得るし、失敗時の body は undefined のまま)ので、別の変数で状態を持つ。
catch {} にエラー変数を書いていないのは、読めなかった理由に興味がないため(読めなかった事実だけが必要)。
if (!response.ok) {
const error = parsed ? parseApiError(body) : null;
throw new ApiFetchError({
status: response.status,
code: error ? error.code : null,
serverMessage: error?.message ?? null,
details: error?.details ?? null,
});
}
response.ok は「ステータスが 200〜299 か」。
分岐が入り組んでいるので全部書き出す:
| 状況 | parsed |
parseApiError |
code |
serverMessage |
details |
|---|---|---|---|---|---|
{"error":{"code":"not_found","message":"..."}} |
true |
成功 | "not_found" |
文言 | null または詳細 |
{"error":{"message":"..."}}(code 欠落) |
true |
成功 | null |
文言 | — |
{"error":"文字列"} |
true |
null(70行で弾く) |
null |
null |
null |
{} |
true |
null |
null |
null |
null |
| HTML のエラーページ | false |
呼ばれない | null |
null |
null |
code: error ? error.code : null — error が null なら null。serverMessage: error?.message ?? null — オプショナルチェーンで安全に読み、undefined を null に。status は必ず本物の HTTP ステータスが入る(0 にならない。ここまで来ているならレスポンスは届いている)。テストが各パターンを固定している(lib/api/__tests__/client.test.ts:85, 109, 122, 139, 152, 166, 183)。
if (!parsed) {
throw new ApiFetchError({
status: response.status,
code: null,
serverMessage: null,
});
}
★成功(200)でも本文が JSON でなければエラーにする。apiFetch<T> は「パース済みの本文を返す」契約なので、パースできなければ契約を果たせない。黙って undefined を返すと、呼ぶ側が body.items を読んで TypeError になり、本当の原因(サーバーが JSON を返さなかった)から遠い場所で壊れる。
テストがこれを明示的に固定している(lib/api/__tests__/client.test.ts:59):
test("成功でも本文が JSON でなければ ApiFetchError(serverMessage は null)", async () => {
そして status が 200 であることも検証している(client.test.ts:69): expect((error as ApiFetchError).status).toBe(200);。「ステータスは成功だが中身が駄目」という状態が、そのまま情報として残る。
return body as T;
前述の通り、実行時の形の検証はしていない。
(c) 全体の設計として何を達成しているか(コードから読み取れること)
apiFetch を通ると、失敗の形が1種類(ApiFetchError)に統一される。呼ぶ側は:
try {
const body = await apiFetch<DashboardResponse>("/api/dashboard");
} catch (e) {
setError((e instanceof ApiFetchError ? e.serverMessage : null) ?? "既定文言");
}
とだけ書けばよい。「ネットワーク不通」「404」「JSON崩れ」「絶対URLの誤用」の4種類の失敗が、すべて同じ形で届く(絶対URLだけは TypeError なので serverMessage を持たず既定文言に落ちる)。5画面すべてが同一のパターンで書かれている(前掲の表)。
呼び出し元(実測、5コンポーネント9箇所):
components/features/alerts/AlertsScreen.tsx:72 apiFetch<SummaryListResponse<PrecheckItem, PrecheckSummary>>(...)
components/features/dashboard/DashboardScreen.tsx:44 apiFetch<DashboardResponse>("/api/dashboard")
components/features/dashboard/DashboardScreen.tsx:45 apiFetch<ListResponse<CleanLogItem>>(`/api/clean-logs?${qs}`)
components/features/detail-log/DetailLogScreen.tsx:115 apiFetch<ListResponse<CleanLogItem>>(...)
components/features/detail-log/DetailLogScreen.tsx:148 apiFetch<CleanLogAdviceResponse>(...)
components/features/detail-log/DetailLogScreen.tsx:173 apiFetch<ApiError>(`/api/clean-logs/${row.id}/areas`)
components/features/ds-alerts/DsAlertsScreen.tsx:63 apiFetch<SummaryListResponse<DsAlert, DsAlertSummary>>(...)
components/features/ds-logs/DsLogScreen.tsx:71 apiFetch<SummaryListResponse<DsDevice, DsDeviceSummary>>(...)
detail-log/DetailLogScreen.tsx:173 の apiFetch<ApiError> が異質。 型引数に ApiError(エラー型)を渡している。/api/clean-logs/[id]/areas は現在 not_implemented を返すルートなので、「成功しても中身はエラー形式」という前提で書かれている(推測を含む解釈だが、直後の :176 で e instanceof ApiFetchError && e.serverMessage を見ていることから、未実装ルートの応答を扱う暫定コードと読める)。
全85行。このリポジトリで唯一、ファイルに書き込む層。
// 1-8行
import { promises as fs } from "node:fs";
import path from "node:path";
import {
DEFAULT_ADVICE_RULES,
type AdviceRuleDefinition,
type AdviceRuleId,
type EditableAdviceFields,
} from "@/lib/config/advice-rules";
注目点:
import { promises as fs } — Node.js の fs(ファイルシステム)モジュールのPromise 版を fs という名前で借りている。fs.readFile が「コールバックを渡す古い形」ではなく「await できる形」になる。node:path — OS ごとの区切り文字(Windows は \、Linux は /)を吸収する。手で "data/settings/advice.json" と書くと Windows で動かない可能性がある。DEFAULT_ADVICE_RULES は値、type が付いた3つは型。type 修飾子を個別に付ける書き方。何を import していないか:
lib/db を import していない。 ★重要★ 設定はメインDBに保存しない。コメント(13-15行)が理由を明記:* 保存先は `data/settings/advice.json`。**メインDBには保存しません** * (DBはTeamWorks管理で読み取り専用のため。CLAUDE.md 7. 禁止事項)。
lib/logger を import していない。 ファイル書き込みという副作用のある操作をしているのに、ログを出していない(後述の所感で触れる)。lib/judge を import していない。 発火条件(どのルールが該当するか)は lib/judge/advice.ts の担当で、このファイルは文言だけを扱う。export async function loadAdviceRules(): Promise< Record<AdviceRuleId, AdviceRuleDefinition> > // 46-48行 export async function saveAdviceRule( id: AdviceRuleId, fields: Partial<EditableAdviceFields> ): Promise<AdviceRuleDefinition> // 62-65行 export async function resetAdviceRule( id: AdviceRuleId ): Promise<AdviceRuleDefinition> // 78-80行
export していない内部要素:
const SETTINGS_PATH = path.join(process.cwd(), "data", "settings", "advice.json"); // 22行 type AdviceOverrides = Partial<Record<AdviceRuleId, Partial<EditableAdviceFields>>>; // 24行 async function readOverrides(): Promise<AdviceOverrides> // 26行 async function writeOverrides(overrides: AdviceOverrides): Promise<void> // 36行
/** * アドバイス文言の運用設定 * * システム管理 →「運用設定」から編集された文言を保持します。 * 保存先は `data/settings/advice.json`。**メインDBには保存しません** * (DBはTeamWorks管理で読み取り専用のため。CLAUDE.md 7. 禁止事項)。 * * 保存されているのは編集された項目だけで、未編集のルールは * `lib/config/advice-rules.ts` の既定値がそのまま使われます。 * これにより、既定値を後から改善したときに、編集していないルールへ自動で反映されます。 */
★「編集された項目だけを保存する」方式の意味★
2つの案を比べる:
| 案 | 保存内容 | 既定値を改善したとき |
|---|---|---|
| 全件保存(採用せず) | 7ルール × 全項目を JSON に書く | 既定値の改善が誰にも届かない(保存済みの値が全部を上書きしているため) |
| 差分だけ保存(採用) | 実際に編集された項目だけ | 編集していないルールには自動で反映される |
平易な言い換え: 「工場出荷時の設定」の上に「書き換えた分だけの付箋」を貼る方式。付箋が貼られていない場所は、工場出荷時の設定が改善されればそのまま新しい方が使われる。
これをマージ(重ね合わせ)と呼び、実装は { ...base, ...ov }(55行)というスプレッド構文の1行で表現されている。
const SETTINGS_PATH = path.join(process.cwd(), "data", "settings", "advice.json");
process.cwd() — プロセスを起動したディレクトリ(カレントワーキングディレクトリ)。path.join(...) — OS に合った区切り文字で連結する。Windows では C:\Users\daich\dev\d-watch\data\settings\advice.json になる。process.cwd() を使うことの含意(コードから確定できる事実): 起動ディレクトリが変わると保存先も変わる。npm run dev はリポジトリ直下で走るので通常は問題ないが、別のディレクトリから起動すると設定が別の場所に作られる。(この点への注意はコメントに無い — 推測だが、社内サーバーで固定の作業ディレクトリから起動する運用が前提と読める。)
type AdviceOverrides = Partial<Record<AdviceRuleId, Partial<EditableAdviceFields>>>;
Partial<T> を2重に使っているのが要点。
| 記述 | 平易な言い換え |
|---|---|
Record<AdviceRuleId, X> |
「R1〜R4・B1〜B3 の7キーすべてに X がある辞書」 |
Partial<Record<AdviceRuleId, X>> |
「7キーの一部だけがあってもよい辞書」= 編集されたルールだけが入る |
Partial<EditableAdviceFields> |
title / text / actions の一部だけがあってもよい = 編集された項目だけが入る |
つまり型が「差分だけ保存する」という方針をそのまま表現している。 「ルール単位でも項目単位でも部分的」という2階層の部分性が、Partial の2重適用として書かれている。
シグネチャ: readOverrides(): Promise<AdviceOverrides>(lib/settings/advice.ts:26-34)
async function readOverrides(): Promise<AdviceOverrides> {
try {
const raw = await fs.readFile(SETTINGS_PATH, "utf-8");
return JSON.parse(raw) as AdviceOverrides;
} catch {
// ファイルがない = 一度も編集されていない。既定値のみで動く
return {};
}
}
(a) 引数と戻り値の業務上の意味
{}。(b) 中の処理を条件分岐まで全部(2本)
JSON.parse でオブジェクトにする。as AdviceOverrides は型の断言(実行時の検証はしていない)。catch — 例外を握りつぶして {} を返す。(c) なぜ catch で {} を返すのか
コメント(31行): 「ファイルがない = 一度も編集されていない。既定値のみで動く」
★これが lib/db/index.ts の notImplemented(黙って空を返さない)と正反対に見えるが、意味が違う。
| 場面 | 「空」が意味すること | 正しい振る舞い |
|---|---|---|
db.cleanLogs()(Prisma 未実装) |
「データが0件」と区別できない → 誤診断の原因 | 例外を投げる |
readOverrides()(設定ファイルなし) |
「一度も編集していない」という正常な初期状態 | {} を返す |
設定ファイルが無いのはアプリを新規に立てた直後の当然の状態で、異常ではない。だから空を返すのが正しい。
★ただし catch が広すぎる(コードから確定できる指摘)★
この catch は「ファイルが無い」だけでなく次のすべてを同じ扱いにする:
| 実際の原因 | この実装の挙動 | 望ましいか |
|---|---|---|
ファイルが存在しない(ENOENT) |
{} を返す |
★正しい |
権限が無くて読めない(EACCES) |
{} を返す |
編集内容が消えたように見える |
JSON が壊れている(JSON.parse の失敗) |
{} を返す |
編集内容が消えたように見える。しかも何も記録されない |
| ディスク障害 | {} を返す |
同上 |
つまり JSON が壊れた場合、画面には既定文言が表示され、編集内容が消えたように見えるが、ログにも画面にも何の手がかりも出ない。(lib/logger を import していないので logEvent も呼べない。)これは docs/logging-design.md:3-4 の全社ルール7「デバッグはログを起点に行う」の観点から見ると弱い箇所である。(この指摘は実装コードとログ設計文書の突き合わせによるもので、どちらかに「こうすべき」と書かれているわけではない。)
呼び出し元: lib/settings/advice.ts:49(loadAdviceRules)、:70(saveAdviceRule)、:81(resetAdviceRule)。3関数すべてが最初にこれを呼ぶ。
シグネチャ: writeOverrides(overrides: AdviceOverrides): Promise<void>(lib/settings/advice.ts:36-43)
async function writeOverrides(overrides: AdviceOverrides): Promise<void> {
await fs.mkdir(path.dirname(SETTINGS_PATH), { recursive: true });
await fs.writeFile(
SETTINGS_PATH,
`${JSON.stringify(overrides, null, 2)}\n`,
"utf-8"
);
}
(a) 差分オブジェクトを受け取り、JSON ファイルとして保存する。戻り値なし(副作用のみ)。
(b) 中の処理(分岐なし、2ステップ)
fs.mkdir(path.dirname(SETTINGS_PATH), { recursive: true })
path.dirname(...) — パスからファイル名を除いた部分(.../data/settings)。{ recursive: true } — 途中のディレクトリも全部作る、かつ既にあってもエラーにしない(mkdir -p に相当)。だから初回起動時に data/settings/ が無くても動く。fs.writeFile
JSON.stringify(overrides, null, 2) — 第3引数の 2 がインデント2スペース。人間が読める形で保存する。第2引数の null は「置換関数なし」。`${...}\n` — 末尾に改行を1つ足している。これは POSIX のテキストファイル慣習で、cat で表示したときにプロンプトが行頭に戻る、git diff が「\ No newline at end of file」を出さない、といった実務上の配慮。"utf-8" — 文字コードを明示。日本語の文言を保存するので必須。(c) なぜ整形して保存するのか(コメントは無い — 推測を明記)
このファイルにこの点のコメントは無い。推測だが、data/settings/advice.json は運用中に人間が直接開いて確認・手直しする可能性がある設定ファイルなので、1行に詰めた JSON ではなく整形済みにしていると読める。
★注意すべき実装上の性質(コードから確定できる事実):
readOverrides → 変更 → writeOverrides の間に他の書き込みが挟まると、読んだ時点の内容を基準に上書きするため)。P1 は社内利用で同時編集が想定されていない(推測)が、コード上そうなっている。fs.writeFile は既存ファイルを切り詰めてから書くので、途中で電源が落ちるとファイルが壊れる。その場合 readOverrides の catch が {} を返し、全設定が既定値に戻ったように見える。呼び出し元: lib/settings/advice.ts:72(saveAdviceRule)、:83(resetAdviceRule)。
シグネチャ: loadAdviceRules(): Promise<Record<AdviceRuleId, AdviceRuleDefinition>>(lib/settings/advice.ts:45-59)
/** 既定値に編集内容を重ねた、実際に表示するルール定義を返す */
export async function loadAdviceRules(): Promise<
Record<AdviceRuleId, AdviceRuleDefinition>
> {
const overrides = await readOverrides();
const merged = {} as Record<AdviceRuleId, AdviceRuleDefinition>;
(Object.keys(DEFAULT_ADVICE_RULES) as AdviceRuleId[]).forEach((id) => {
const base = DEFAULT_ADVICE_RULES[id];
const ov = overrides[id];
merged[id] = ov ? { ...base, ...ov } : base;
});
return merged;
}
(a) 引数と戻り値の業務上の意味
Partial ではなく Record なので必ず7件揃う(差分の型と違う点が重要)。(b) 中の処理を条件分岐まで全部(1本)
merged を用意。as Record<...> は「今は空だがループの後には全キーが埋まる」という断言(型の一時的な嘘)。Object.keys(DEFAULT_ADVICE_RULES) as AdviceRuleId[] — 既定値側のキーを基準に回す。★これが重要★merged[id] = ov ? { ...base, ...ov } : base; — 唯一の分岐。
ov がある(そのルールが編集されている)→ { ...base, ...ov } で重ね合わせ。スプレッドは後に書いた方が勝つので、編集された項目だけが上書きされる。ov が無い → base をそのまま返す(複製すらしない)。★52行「既定値側のキーを基準に回す」ことの効果★
もし差分側(overrides)のキーを基準に回していたら、差分に存在しないルール ID(例: 古いバージョンの R9 が JSON に残っている)が入り込んで、merged に不正なキーができる。既定値側を基準にすれば:
★{ ...base, ...ov } の細部: ov は Partial<EditableAdviceFields> なので title / text / actions のいずれかだけを持つ。だから level / icon / condition / id / phase は常に base の値が残る。docs/decisions.md:133-134(D-036)の「編集不可: level / icon / condition」が、EditableAdviceFields 型(Pick で3項目に絞ったもの)によって構造的に守られている。
(c) なぜこの形なのか
コメント(45行)の「既定値に編集内容を重ねた、実際に表示するルール定義を返す」と、ファイル冒頭 17-19行:
* 保存されているのは編集された項目だけで、未編集のルールは * `lib/config/advice-rules.ts` の既定値がそのまま使われます。 * これにより、既定値を後から改善したときに、編集していないルールへ自動で反映されます。
lib/config/advice-rules.ts:6(呼び出し先のコメント)も対応している: 「実行時は lib/settings/advice.ts の loadAdviceRules() を使ってください。」つまり DEFAULT_ADVICE_RULES を直接読むのは禁止で、必ずこの関数を通す。
呼び出し元(実測):
app/api/clean-logs/[id]/advice/route.ts:85 — const rules = await loadAdviceRules();(アドバイス取得APIで文言を組み立てる)app/api/settings/advice/route.ts:28 — const rules = await loadAdviceRules();(設定画面の一覧表示)シグネチャ(lib/settings/advice.ts:61-75):
/** 1ルール分の文言を保存する。level / icon / condition は編集不可 */
export async function saveAdviceRule(
id: AdviceRuleId,
fields: Partial<EditableAdviceFields>
): Promise<AdviceRuleDefinition> {
if (!DEFAULT_ADVICE_RULES[id]) {
throw new Error(`未知のアドバイスルールです: ${id}`);
}
const overrides = await readOverrides();
overrides[id] = { ...overrides[id], ...fields };
await writeOverrides(overrides);
return { ...DEFAULT_ADVICE_RULES[id], ...overrides[id] };
}
(a) 引数と戻り値の業務上の意味
id: AdviceRuleId — どのルールを保存するか("R1" 〜 "B3")。fields: Partial<EditableAdviceFields> — 編集された項目だけ。title だけ変えたなら { title: "..." }。AdviceRuleDefinition — 保存後の完成形(既定値+累積した差分)。画面がそのまま表示に使える。(b) 中の処理を条件分岐まで全部(1本)
if (!DEFAULT_ADVICE_RULES[id]) — 未知の ID を弾く。
AdviceRuleId に絞られているが、API 経由で来る文字列は実行時には何でもあり得る(app/api/settings/advice/route.ts:66 でリクエストの文字列を AdviceRuleId として扱っている)。だから実行時にも確認する。未知のアドバイスルールです: R9)。overrides[id] = { ...overrides[id], ...fields }; — ★累積させる★
...overrides[id] — 既にあった差分を残す。overrides[id] が undefined でもスプレッドは何も足さないので安全。...fields — 今回の変更を上書き。title を編集、今回 text を編集」した場合に、両方が残る。もし overrides[id] = fields と書いていたら、前回の title の編集が消える。return { ...DEFAULT_ADVICE_RULES[id], ...overrides[id] }; — 既定値に累積差分を重ねた完成形を返す。loadAdviceRules の55行と同じ計算をここでも行っている(1ルール分だけ)。(c) なぜ完成形を返すのか(コメントは無い — 推測を明記)
コメントには書かれていない。推測だが、保存直後に画面を再描画するために、呼び出し側が loadAdviceRules() を再度呼ばなくて済むようにしている(ファイル読み込みを1回で済ませる)と読める。実際 app/api/settings/advice/route.ts:92 は const saved = await saveAdviceRule(ruleId, fields); と受け取っている。
呼び出し元: app/api/settings/advice/route.ts:92(PUT ハンドラ)。
シグネチャ(lib/settings/advice.ts:77-85):
/** 1ルール分の編集を取り消して既定値に戻す */
export async function resetAdviceRule(
id: AdviceRuleId
): Promise<AdviceRuleDefinition> {
const overrides = await readOverrides();
delete overrides[id];
await writeOverrides(overrides);
return DEFAULT_ADVICE_RULES[id];
}
(a) ID を受け、そのルールの編集を取り消して既定値そのものを返す。
(b) 中の処理(分岐なし、4ステップ)
delete overrides[id]; — そのルールのキーを差分から丸ごと削除。「編集していない状態」に戻す。return DEFAULT_ADVICE_RULES[id]; — 差分が無くなったので既定値がそのまま完成形。マージ計算が不要。★saveAdviceRule にある ID 検証(66-68行)が、こちらには無い。(コードから確定できる非対称)
delete overrides["R9"](存在しないキーの削除は何も起きない)→ ファイルを書き直す → return DEFAULT_ADVICE_RULES["R9"] = undefined を返す。Promise<AdviceRuleDefinition> なので、型と実際の値が食い違う(undefined が返る)。app/api/settings/advice/route.ts:66-73)は resetAdviceRule を呼ぶ前に ID を検証しているかどうか — grep では :66 に AdviceRuleId の参照、:67 に DEFAULT_ADVICE_RULES の参照があるので、ルート側で検証していると読める(推測を含む。ルート本体の該当行は未読)。いずれにせよsaveAdviceRule と resetAdviceRule で防御の強さが揃っていないのは事実。(どちらかの文書に「こうすべき」と書かれているわけではない。)
呼び出し元: app/api/settings/advice/route.ts:73 — const reverted = await resetAdviceRule(ruleId);
docs/logging-design.md:333-335:
- F-10(しきい値編集機能、M5)を実装する際、
lib/settings/advice.tsと同じ「現在値を上書きするだけ」の実装にすると、過去の判定を再現する手がかりが失われる。変更履歴を追記型で残す設計を検討すること
★この文はこのファイルの実装を「そのまま真似するな」と名指ししている。 現在の実装は「今の値」しか持たず、いつ誰がどう変えたかの履歴が無い。文言だけなら影響が小さいが、しきい値(判定を変える数値)で同じことをすると「過去の判定を再現できない」という問題になる。設計文書が実装を見た上で申し送りを書いている、という関係。
全35行。サンプルデータの日付を「実行日からの相対」で作るヘルパー。
import が1つも無い。 Date は JavaScript 本体の機能。
export function jst(date: Date, time: string): string // 9行 export function daysAgo(n: number): Date // 17行 export function dateOnly(n: number): string // 25行 export function at(n: number, time: string): string // 33行
4関数すべて export されている(lib/fixtures/index.ts:11 の export * from "./_helpers"; でさらに再 export される)。
/** * fixture用の日時ヘルパー * * 画面確認のたびに日付が古くなると「昨夜の清掃結果」という運用イメージが崩れるため、 * サンプルデータの日付は実行日からの相対で生成しています。 */
★この理由が本質的。 サンプルデータに 2026-08-27T02:00:00+09:00 のような固定日付を書くと:
だから「今日から n 日前」という相対で生成する。いつ開いても「昨夜のデータ」が入っている。
副作用として: サンプルデータはモジュール読み込み時に評価される(lib/fixtures/clean-logs.ts:52 などがトップレベルで at() を呼ぶ)ので、サーバーを起動したまま日付が変わると、データの日付は起動時のままになる。(この点への注意はコメントに無い — 推測だが、開発時はサーバーを再起動するので実務上問題にならないと読める。)
シグネチャ: jst(date: Date, time: string): string(lib/fixtures/_helpers.ts:8-14)
/** JSTのオフセット付きISO8601文字列を作る(例: 2026-08-26T02:00:00+09:00) */
export function jst(date: Date, time: string): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}T${time}:00+09:00`;
}
(a) 引数と戻り値の業務上の意味
date: Date — 日付部分の元。time: string — "02:00" のような時刻の文字列(時と分だけ)。2026-08-26T02:00:00+09:00 の形。(b) 中の処理(分岐なし、4ステップ)
date.getFullYear() — 4桁の年。date.getMonth() + 1 — ★getMonth() は 0 始まり(1月が 0)なので +1 する。JavaScript の有名な罠。String(...).padStart(2, "0") — 2桁に0埋め(8 → "08")。:00 で固定、オフセットは +09:00 を文字列でハードコード。getFullYear / getMonth / getDate(getUTC* ではない)を使っているので、実行環境のタイムゾーンで解釈された日付になる。日本時間で動くサーバー/PC を前提とした実装。(lib/logger/timestamp.ts が timeZone: "Asia/Tokyo" を明示して環境依存を排除しているのと対照的。fixtures は開発用データなのでそこまで厳密にしていない、と読める — 推測。)
(c) コメントは「何を作るか」を例示するのみ。
呼び出し元(実測): at()(33-35行)からのみ。 外部からの呼び出しは1件も無い(grep で _helpers.ts 以外にヒットなし)。
★したがって jst は「export されているが外部からは呼ばれていない」関数。★
シグネチャ: daysAgo(n: number): Date(lib/fixtures/_helpers.ts:16-22)
/** n日前のDateを返す(n=0 が当日) */
export function daysAgo(n: number): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - n);
return d;
}
(a) n 日前の Date。n=0 が当日。
(b) 中の処理(分岐なし、3ステップ)
new Date() — 今の日時。d.setHours(0, 0, 0, 0) — 時・分・秒・ミリ秒を全部 0 にする(=その日の 00:00:00.000)。これが無いと「n 日前の、今と同じ時刻」になり、実行時刻によってデータの時刻がぶれる。d.setDate(d.getDate() - n) — 日付を n 日戻す。★setDate は月をまたぐ計算を自動でやる(例: 3月1日から 5 日戻すと 2月24日、うるう年も正しく処理)。手で「日 - n」と計算すると 0 日や負の日ができてしまう。(c) コメントは動作の説明のみ。「n=0 が当日」という境界の明示が親切な点。
呼び出し元(実測): dateOnly()(26行)と at()(34行)からのみ。外部からの呼び出しは1件も無い。
★したがって daysAgo も「export されているが外部からは呼ばれていない」関数。★
シグネチャ: dateOnly(n: number): string(lib/fixtures/_helpers.ts:24-30)
/** n日前の日付を YYYY-MM-DD で返す */
export function dateOnly(n: number): string {
const d = daysAgo(n);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
d.getDate()
).padStart(2, "0")}`;
}
(a) n 日前を 2026-09-02 の形(時刻なし)で返す。
(b) 分岐なし。daysAgo(n) で日付を得て、jst と同じ0埋め処理で YYYY-MM-DD にする。jst と処理が重複している(jst の 10-12行と同じ計算を書き直している)。これは jst が時刻を必須にしているため再利用できない、という構造上の理由(コードから読み取れる事実)。
呼び出し元(実測、12箇所):
lib/fixtures/inspections.ts:68, 81, 94, 107, 120, 132 visited_on: dateOnly(40) など(点検日) lib/fixtures/reports.ts:198, 206, 214, 222, 230 last_problem_on: dateOnly(1)(最終問題日) lib/fixtures/__tests__/schedules.test.ts:31 const today = dateOnly(0);
テストからも使われている点が重要。schedules.test.ts は「当日のスケジュールが正しく選ばれるか」を検証するために「今日」の文字列を必要とする。
シグネチャ: at(n: number, time: string): string(lib/fixtures/_helpers.ts:32-35)
/** n日前の指定時刻をISO8601で返す */
export function at(n: number, time: string): string {
return jst(daysAgo(n), time);
}
(a) n 日前の time 時刻を ISO 8601 で返す。at(1, "23:10") = 「昨日の 23:10」。
(b) 分岐なし。daysAgo と jst を組み合わせる1行の合成関数。
(c) この関数の存在が jst と daysAgo の使い方を1つに固定している。呼ぶ側は at(1, "23:10") とだけ書けばよく、「0埋め」「+09:00」「時刻を0時にリセット」を意識しない。
呼び出し元(実測、多数): lib/fixtures/clean-logs.ts:52、lib/fixtures/ds-alerts.ts の 27, 41, 55, 58, 69, 72, 73, 86, 100, 103, 114, 128, 131, 142, 145, 146, 156, 170, 184 ほか(detected_at / responded_at / resolved_at に使用)。4関数の中で最も使われている。
4関数の関係図(呼び出しの向き):
外部(fixtures 各ファイル・テスト) ├─→ at(n, time) ──→ jst(daysAgo(n), time) │ ├─→ daysAgo(n) │ └─→ (0埋めと +09:00 の組み立て) └─→ dateOnly(n) ──→ daysAgo(n) jst / daysAgo は外部から直接呼ばれていない(at / dateOnly の内部部品)
全19行。サンプルデータの「玄関」。関数は1つも無い。
import 文が1つも無い。 代わりに export * from が9本並んでいる(再 export のみのファイル)。
export * from "./_helpers"; // 11行 export * from "./clean-logs"; // 12行 export * from "./prechecks"; // 13行 export * from "./reports"; // 14行 export * from "./ds-devices"; // 15行 export * from "./ds-alerts"; // 16行 export * from "./facilities"; // 17行 export * from "./schedules"; // 18行 export * from "./inspections"; // 19行
export * from "..." = 「そのファイルが export している全てを、このファイルからも export する」。平易な言い換え: 転送。9つの部屋の中身を1つの玄関から全部取れるようにする。
効果: lib/db/index.ts:29-38 が 1つの import 文で8つのデータを取れる:
import {
cleanLogs, dsAlerts, dsDevices, facilities,
inspectionDetails, inspections, prechecks, schedules,
} from "@/lib/fixtures";
もし玄関が無ければ、from "@/lib/fixtures/clean-logs" のように8行の import が必要になる。
/** * サンプルデータのエントリポイント * * DB接続前の開発はすべてここのデータで行います(D-044)。 * DB接続後も削除しません(テスト用・デモ用・障害切り分け用。D-045)。 * * 用意すべきケースの一覧は docs/fixtures.md を参照してください。 * * ★ 実在の店舗名・住所・電話番号・氏名は絶対に入れないこと ★ */
根拠となる決定を全文引用:
docs/decisions.md:198-206(D-044):
D-044
[確定]DB接続前はサンプルデータ(fixtures)で開発する
- 決定:
lib/fixtures/にサンプルデータを置き、環境変数USE_FIXTURESで実DBと切り替える。ある程度画面ができてからDB接続する- 却下した案: Prism(OpenAPIからのモックサーバ)を使う
- PrismはYAMLのexample値をそのまま返すため、全レコードが同じ値になる
- フィルタ(△×のみ/全件/×のみ)、判定バッジの色分け、L1〜L3の3段階表示、ページング、空データ表示が確認できない
- 理由: モック(
d-watch-mock.jsx)に既に良質なダミーデータ(LOG/PRECHK/DSA/RANK/ROBOTS/SCHED/DSDEV/INSPECTIONS)があり、これを型付きで移植する方が早く確実。別プロセスを立てる必要もない- 設計上の注意: 判定ロジック(
lib/judge/)は切替の外側に置く。fixture開発中も本番同等のロジックが走るようにする
★却下理由が具体的なのが特徴。 「Prism を使うと全レコードが同じ値になる」→ だからフィルタの動作確認ができない。サンプルデータの目的が「画面の全パターンを確認すること」なので、値がばらけていないと役に立たない。
docs/decisions.md:208-209(D-045):
D-045
[確定]fixtures はDB接続後も削除しない
- テスト用・デモ用・障害切り分け用(「画面の問題かDBの問題か」を切り分けられる)に残す
★「障害切り分け用」が最も実務的な価値。 本番で表示がおかしいとき、USE_FIXTURES=true で起動して同じ画面を開けば:
| fixtures でも同じ症状 | 原因は画面/DFAのコード |
|---|---|
| fixtures では正常 | 原因はDB/データ側(TeamWorks 側の可能性) |
この切り分けが1回の再起動で済む。docs/logging-design.md:276 の data_source フィールドも同じ目的:
|
data_source(fixtures/prisma) | fixtures モードでの異常は即「DFAコードの問題」に絞れる |
★9行目の警告★
* ★ 実在の店舗名・住所・電話番号・氏名は絶対に入れないこと ★
docs/decisions.md:211-212(D-043)が背景:
D-043
[確定]モックの実在住所・電話番号はダミー値に置換する
- モック(
d-watch-mock.jsx)には実在の店舗住所・電話番号が残っている。コンポーネント分割の際にダミー値へ置換する
★これは実際に起きていた問題への対処。 元のモック(d-watch-mock.jsx、163KB)に実在の住所・電話番号が入っていたので、fixtures へ移植する際にダミー値へ置換するというルールが必要になった。fixtures はリポジトリにコミットされるので、実在情報を入れるとGit 履歴に永久に残る。
lib/logger/redact.ts の禁止キーとの関係: fixtures にはダミーの facility_name が入っている(lib/fixtures/facilities.ts:217 に facilityNameById という Map がある)。fixtures にデータがあること自体は問題ない(画面に施設名を表示する必要があるため)。禁止されているのはログに書くこと。役割が分かれている。
そして docs/logging-design.md:311-312 が重要な申し送りをしている:
M7(
USE_FIXTURES=false切替)の前に「ログ出力のPIIレビュー」をチェックリストへ追加すること。fixtures はダミーデータなのでこのレビューを代替できない。
★平易な言い換え: fixtures で動かしている間は、ログに施設名が漏れてもそれはダミーの施設名なので害がない。だから「fixtures で動かして問題なかったから安全」とは言えない。実DBに繋ぐ前に、必ず別途レビューが必要。
各ファイルの export(grep "^export" lib/fixtures/*.ts の実測結果):
| ファイル | export しているもの(行) | DataSource に対応するか |
|---|---|---|
_helpers.ts |
jst(9) / daysAgo(17) / dateOnly(25) / at(33) |
— (ヘルパー) |
clean-logs.ts |
boundaryCases(80) / escalationCases(107) / statusCases(144) / history(218) / cleanLogs(221) |
★cleanLogs() |
prechecks.ts |
prechecks(18) |
★prechecks() |
ds-devices.ts |
dsDevices(16) / dsDevicesAllOk(122) |
★dsDevices() |
ds-alerts.ts |
dsAlerts(19) |
★dsAlerts() |
facilities.ts |
facilities(21) / facilityNameById(217) |
★facilities() |
schedules.ts |
schedules(57) |
★schedules() |
inspections.ts |
INSPECTION_ITEMS(24) / inspectionDetails(64) / inspections(145) |
★inspections() / inspectionDetail() |
reports.ts |
kpiWeekly(36) / kpiMonthly(43) / kpiCurrentPeriod(55) / facilityPerformance(65) / continuousProblems(192) / errorBreakdown(238) / errorBreakdownEmpty(249) |
対応するメソッドが無い |
構造上わかる3つのこと:
clean-logs.ts は「ケース別の小さな配列」を組み合わせて cleanLogs を作っている。 boundaryCases(しきい値の境界)/ escalationCases(連続日数)/ statusCases(各ステータス)/ history(履歴)という4つの意図別グループがあり、221行で cleanLogs にまとめられている。D-044 の却下理由「フィルタ・判定バッジの色分け・L1〜L3の3段階表示・ページング・空データ表示が確認できない」に対応するデータが、名前の付いたグループとして用意されている。dsDevicesAllOk(122行)と errorBreakdownEmpty(249行)は「別のシナリオ用」の派生データ。 前者は dsDevices.map(...) で全件正常にしたもの、後者は空配列。「異常0件のとき」「データが無いとき」の画面を確認するための別バージョン。db の切り替えとは別に、直接 import して使う想定(推測)。reports.ts の7つの export には対応する DataSource メソッドが無い。 レポート画面(F-04)が db を経由せず直接 fixtures を import している、または未実装(推測。レポート系のAPIルートは find app/api -name route.ts の9件に含まれていない=まだ作られていない)。components/features/*.tsx (ブラウザで動く)
│
└─→ lib/api/client.ts ─→ lib/types(型のみ)
(fetch。lib/logger も lib/db も知らない)
app/api/**/route.ts (サーバーで動く)
├─→ lib/logger(index) ─→ pino
│ ├─→ lib/db(USE_FIXTURES を読むためだけ)
│ ├─→ lib/logger/context ─→ node:async_hooks
│ ├─→ lib/logger/events ─→ lib/types(型のみ)
│ ├─→ lib/logger/redact ─→ (import なし)
│ └─→ lib/logger/timestamp ─→ (import なし)
├─→ lib/logger/api ─→ lib/logger(index), lib/types
├─→ lib/db ─→ lib/types(型), lib/fixtures(値)
│ └─→ lib/fixtures/index ─→ 各データファイル ─→ lib/fixtures/_helpers
├─→ lib/settings/advice ─→ node:fs, node:path, lib/config/advice-rules
└─→ lib/judge/**(純粋層。この層からは import されるが、逆はない)
instrumentation.ts ─→ lib/logger(動的 import)
★確認できた規約遵守(grep 実測): lib/judge/ 配下に logger を含む行は1件も無い。docs/logging-design.md:91-92 の「lib/judge/ から import してはいけない(依存方向を一方向に保つ)」が守られている。
| # | 思想 | 平易な言い換え | 実例 |
|---|---|---|---|
| 1 | 構造的に防ぐ | 「気をつける」ではなく「間違った書き方ができない形にする」 | notImplemented の例外 / logEvent のオーバーロード / allowlist 型 / logApiError が body をまるごと受ける / EditableAdviceFields の Pick |
| 2 | 黙って壊れさせない | 失敗を「空」や「無反応」で表さない | notImplemented(空配列を返さない)/ apiFetch が JSON 不正でも投げる / data_source を必須にする |
| 3 | ログの失敗でアプリを落とさない | ログ機構が原因でお客さんの操作を失敗させない | 本番では例外を投げずキー除去 / readAccessor の try-catch / getRequestId の ?. |
| 4 | 不明な情報は書かない | 推測で欄を埋めない | app.start に route/method を持たせない / db.write に「誰が」を設けない / data.anomaly に原因欄が無い / 認証 cookie 名を禁止リストに入れない |
| 5 | 設定を二重に書かない | テストと本番で同じ設定を使う | createLogger(destination?) / emitTo と logApiErrorTo の2段構え |
GET /api/clean-logs/12/advice を例に、この層の関数がどの順で動くか:
| 順 | 起きること | 関与するファイル:行 |
|---|---|---|
| 1 | ブラウザが apiFetch<CleanLogAdviceResponse>(...) を呼ぶ |
lib/api/client.ts:90 |
| 2 | 絶対URLでないことを確認 | lib/api/client.ts:94 |
| 3 | fetch でサーバーへ |
lib/api/client.ts:102 |
| 4 | ルートが withRequestId(request, ...) で全体を包む |
app/api/clean-logs/[id]/advice/route.ts:32 → lib/logger/context.ts:70 |
| 5 | ヘッダの x-request-id を検査(無ければ UUID 生成) |
lib/logger/context.ts:71-72 |
| 6 | AsyncLocalStorage に載せる | lib/logger/context.ts:22 |
| 7 | db.cleanLogs() でデータ取得(fixtures なら配列を返す) |
lib/db/index.ts:61 |
| 8 | 判定(純粋層)→ 直後に logEvent("info", { event: "judge.advice", ... }) |
route.ts:64 → lib/logger/index.ts:117 |
| 9 | emitTo が禁止キーを検査 |
lib/logger/index.ts:78 → lib/logger/redact.ts:161 |
| 10 | pino が1行出力。mixin() が request_id と data_source を足し、timestamp を JST で作る |
lib/logger/index.ts:46, 39 → context.ts:29, timestamp.ts:24 |
| 11 | loadAdviceRules() が data/settings/advice.json を読んで既定値に重ねる |
lib/settings/advice.ts:46 |
| 12 | logEvent("info", { event: "judge.escalation", ... }) |
route.ts:99 |
| 13 | 例外が起きた場合は catch で logApiError({ body, ... }) |
route.ts:44 → lib/logger/api.ts:48 |
| 14 | ブラウザ側が本文を読む(失敗なら ApiFetchError を投げる) |
lib/api/client.ts:117, 124 |
| 15 | 画面が e.serverMessage ?? 既定文言 を表示 |
components/features/detail-log/DetailLogScreen.tsx:123 |
すべて実ファイルの grep / 内容確認に基づく。「実装が悪い」ではなく「文書と実装のどちらかが古い」箇所の指摘であり、多くは実装側が正しく、文書側が更新されていない。
docs/logging-design.md:151-157(§4 記録禁止)の一覧は13キーのみ。cookie / authorization / token / password などは1つも書かれていない。lib/logger/redact.ts:35-44 で8キーを追加(合計21キー)。redact.ts:29): 「§4 は認証未着手の時点で書かれたため漏れていた。」docs/logging-design.md §4 の一覧を21キーに更新すべき。現状、文書だけを読んで実装すると認証情報が素通りする。timestamp.ts と api.ts が無いdocs/logging-design.md:78-84 の構成図には index.ts / context.ts / events.ts / redact.ts / __tests__/redact.test.ts の5つだけ。lib/logger/ に7ファイル(上記+timestamp.ts / api.ts)、テストは5本(redact.test.ts / smoke.test.ts / api.test.ts / context.test.ts / timestamp.test.ts)。timestamp.ts は §10 の日時要件を、api.ts は §3 の error_code 再利用要件を、それぞれ独立した関心として切り出したもの)。文書の構成図が古い。docs/logging-design.md:397-405(§10「既存5ルート」)に5本のパスが列挙されている。find app/api -name route.ts の結果は9本。logApiError の呼び出しも9箇所、withRequestId は10箇所(settings/advice が GET/PUT の2箇所)。app/api/dashboard/route.ts / app/api/ds-alerts/route.ts / app/api/ds-devices/route.ts / app/api/prechecks/route.tslib/logger/context.ts:68 — 「★5ルートに同じラップ処理を書き写さないための共通ヘルパー★」lib/logger/api.ts:26 — 「5ルートすべてが通る関数なので」docs/logging-design.md:219-232。§5 の見出し自身が「★ここが本設計で最も重要」と書いている。清掃詳細ログは約360行/日・年間約13万行あるため全行常時記録はしない。次のいずれかに該当する行のみ記録する。
- 判定が △ または ×
escalation_levelが null でない- アドバイスが1件以上ある
- 生値がいずれかのしきい値の近傍(±0.5)にある
さらに docs/logging-design.md:361(フェーズ表)が「3 | 11〜13 | 判定ログ。§5 の記録条件4を必ず実装する」と念を押している。
app/api/clean-logs/[id]/advice/route.ts:61-63 に条件を実装しないという明示的な判断が書かれている: // 判定根拠の記録。記録条件は設けず無条件に出す
// (このルートは行クリックで1件だけ呼ばれるため件数の問題が無く、
// 条件分岐を入れると「根拠が必要な場面に限って記録が無い」事故のほうが起きやすい)
judge.grade(一覧を作るときに360行分の判定が走る場所=Prisma 実装後)に向けた条件であり、judge.advice には当てはまらない、という整理が妥当と読める(推測)。docs/logging-design.md:202 自身が「後述の記録条件4が実際に効き始めるのは Prisma 実装以降」と書いているので、文書内部でも整合が取れていない。LOG_JUDGE_VERBOSE と JUDGE_LOG_BOUNDARY_MARGIN が存在しないdocs/logging-design.md:368-369(「確定させた名前(実装者が決めない)」表):| 全行記録モード |
LOG_JUDGE_VERBOSE| 環境変数。未設定=OFF | | しきい値近傍の幅 |JUDGE_LOG_BOUNDARY_MARGIN(値0.5) |lib/config/。ハードコード禁止 |
また docs/logging-design.md:232 が「近傍の幅(±0.5)は lib/config/ から読む(ハードコード禁止)」、:234 が「全行記録が必要な調査のために、環境変数による verbose モードを用意する(既定 OFF)」と指示。
grep -rn "LOG_JUDGE_VERBOSE\|BOUNDARY_MARGIN" が空)。lib/config/index.ts(54行、全文確認)にも該当定数は無い。.env.example にも記載なし。proxy.ts が存在しない(§8 ステップ7)docs/logging-design.md:299-300(ステップ7)、:43(§1 の決定サマリ「相関ID | proxy.ts で x-request-id を発行」)。proxy.ts も middleware.ts も無い(ls で確認)。docs/decisions.md:241-242(D-047)が「proxy.ts が未着手のため、現在はヘッダが無ければルート側で生成する(proxy.ts 投入後もそのまま動く)」と明記しており、lib/logger/context.ts:60-67 のコメントも同じ経緯を書いている。文書・実装・コメントの3つが一致して「保留中」と言っている。(§10 のフェーズ表でフェーズ2として区切られている。)pino-pretty が dev スクリプトに繋がっていないdocs/logging-design.md:378-379:開発時の可読性: 生 JSON は人間には読めない。
pino-prettyを devDependency として導入し、npm run devのときだけ通す。本番(npm start)では通さない(stdout は素の JSON のまま)
package.json の devDependencies に "pino-pretty": "^13.1.3" は入っているが、スクリプトは "dev": "next dev" のままで、パイプも transport 設定も無い。lib/logger/index.ts も pino-pretty を参照していない。route / method があるが、app.start は持たないdocs/logging-design.md:117 — | route / method | string | 実際のパスを入れる | が「全イベント必須」の表に入っている。lib/logger/events.ts:43-50(AppStartLogEvent)に route / method が無い。コメント(39-41行)が理由を書いている: 「route / method も持たせない(リクエスト起因ではないので、埋めると不正確な推測情報になる。不明な情報は書かない)。」ForbiddenFieldsStrippedLogEvent(route? / method? がオプショナル)にも当てはまる。lib/logger/index.ts:66 の §10 引用が原文と一致しないlib/logger/index.ts:65-66):* 本番で検出した場合、元のログ(禁止キーを除去したもの・元のレベルのまま)とは * **別に** warn を1行出す(docs/logging-design.md §10 の「別途 warn で記録する」)。
docs/logging-design.md:383): 「本番では投げず、該当キーを取り除いて warn で記録し、ログ呼び出し自体は成功させる。」grep -rn "別途" docs/logging-design.md の結果は 29行(別途 LOG_LEVEL 環境変数)と 128行(別途文字列を組み立てない)の2件のみで、「別途 warn で記録する」という文字列は文書に存在しない。index.ts:67-69 で自ら説明している(「元のレベルを warn へ格上げして1行にまとめる方式だと、元が error のときに『禁止フィールドが渡された』事実が warn として残らない」)。実装の判断は妥当だが、引用の形を取ると「文書にそう書いてある」と誤読される。ForbiddenFieldsStrippedLogEvent の route / method が常に空route?: string / method?: string があるが(events.ts:211-212)、emitTo(index.ts:92-96)は event と forbidden_keys の2つしか設定しない。mixin が request_id を付けるので相関は追える)。ただし型にあるのに使われていないフィールド。emitTo は payload から route / method を取り出せる位置にいるので、埋めることは可能(推測: 実装の簡潔さを優先したと読める)。docs/logging-design.md:161-163(記録可)に precheck_id / alert_id / serial_number が挙がっている。lib/logger/events.ts のどのイベント型にもこの3キーは無い(record_id はある)。inspection.* / M3 の PATCH 系)が未実装なだけ。| 文書の指示 | 実装 | 判定 |
|---|---|---|
§10「data_source の取得元」— lib/db の export を import し、process.env.USE_FIXTURES を再解釈しない(393-395行) |
lib/logger/index.ts:8, 49 |
★一致 |
§10 確定名「LOG_LEVEL」(367行) |
lib/logger/index.ts:23、.env.example に記載 |
★一致 |
§10 確定名「x-request-id」(370行) |
lib/logger/context.ts:34 |
★一致 |
§10「日時は timestamp フィールド・ISO 8601 +09:00・ミリ秒付き」(374-377行) |
lib/logger/timestamp.ts:29、index.ts:39 |
★一致 |
§10「err は error レベルのみ。info / warn には付けない」(389行) |
lib/logger/index.ts:84, 99-104 + logEvent のオーバーロード |
★一致(型でも強制) |
| §10「非本番は例外、本番はキー除去して継続」(382-385行) | lib/logger/redact.ts:161-173, 178-190 |
★一致 |
§10「JSON.stringify(error) は使わない」(380-381行) |
lib/logger/index.ts:101 で pino の err に渡す |
★一致 |
§3「error_code はクライアントに返す error.code と同一の変数を再利用」(128行) |
lib/logger/api.ts:40-41(body からの読み出し) |
★一致 |
§3「ApiError.error.code を4値の union に強化」(132-134行) |
lib/types/index.ts:24-28 |
★一致 |
§3「d-watch-api.yaml は変更しない」(136-137行、345-351行) |
変更されていない(error.code は YAML 上 string のまま) |
★一致(lib/types/index.ts:22 のコメントも同旨) |
§8 ステップ10「eslint.config.mjs に no-console(lib/logger 以外禁止)」(305行) |
eslint.config.mjs:17-29 |
★一致 |
§8 ステップ12「.env.example に LOG_LEVEL=info」(307行) |
記載あり | ★一致 |
§2「lib/judge/ から lib/logger/ を import してはいけない」(91-92行) |
grep -rn "logger" lib/judge/ が0件 |
★一致 |
§5「judgeGrade の記録先は Prisma 実装時の lib/db/」(192, 198-199行) |
lib/db/index.ts は lib/logger を import していない(=まだその段階でない) |
★一致(保留中) |
§6「DataSource に書き込みメソッドはまだ無い」(242-243行) |
lib/db/index.ts:47-56 は読み取り8メソッドのみ |
★一致 |
§7「data.anomaly は期待値と実測値だけ。原因を断定しない」(284-286行) |
events.ts:194-202 に原因欄が無い。app/api/dashboard/route.ts:77 のコメントも同旨 |
★一致 |
§9「D-049 の planned 乖離で data.anomaly」(decisions.md:273-274) |
app/api/dashboard/route.ts:84-93 |
★一致 |
| §10 完了条件5-6「スモークテストで timestamp / level / request_id / data_source と禁止フィールドを検証」(422-426行) | lib/logger/__tests__/smoke.test.ts に該当アサーションあり(:10, 233, 306 で FORBIDDEN_LOG_FIELDS を全件走査) |
★一致 |
「呼び出し元」はアプリ本体(app/ / components/ / instrumentation.ts / lib/ 内の他ファイル)を指す。テストのみからの呼び出しは「アプリ本体からは呼ばれていない」として区別した。
| # | 名前 | 定義位置 | 状態 |
|---|---|---|---|
| 1 | db.facilities() |
lib/db/index.ts:52(契約)/ :73(fixtures 実装) |
施設マスタ取得。使う画面が未実装 |
| 2 | db.inspections() |
lib/db/index.ts:54 / :79 |
点検記録一覧。F-05 未実装 |
| 3 | db.inspectionDetail(id) |
lib/db/index.ts:55 / :82 |
点検記録詳細。同上 |
| 4 | jst(date, time) |
lib/fixtures/_helpers.ts:9 |
export されているが、at() の内部からのみ呼ばれる |
| 5 | daysAgo(n) |
lib/fixtures/_helpers.ts:17 |
export されているが、at() / dateOnly() の内部からのみ呼ばれる |
| 6 | ForbiddenLogField(型) |
lib/logger/redact.ts:51 |
export されているが参照0件 |
| 7 | LogDataSource(型) |
lib/logger/events.ts:16 |
export されているが参照0件(data_source は mixin が付けるので型に載らない) |
| 8 | JudgeGradeLogEvent(型) |
lib/logger/events.ts:89 |
LogEvent の一員だが、この event を出すコードが無い |
| 9 | DbWriteLogEvent(型) |
lib/logger/events.ts:162 |
同上(M3 予定) |
| 10 | InspectionLogEvent(型) |
lib/logger/events.ts:183 |
同上(点検ストア未実装) |
| 11 | DbWriteTable / DbWriteColumn(型) |
lib/logger/events.ts:25, 30 |
DbWriteLogEvent の中でのみ使用(そのイベント自体が未使用) |
| 12 | PeriodMode(型) |
lib/logger/events.ts:22 |
3イベントの period_mode? で型として使われているが、実際に period_mode を設定している呼び出しは0件 |
| 13 | prismaSource の8メソッド |
lib/db/index.ts:103-126 |
USE_FIXTURES=false でのみ到達。呼ぶと notImplemented で例外 |
| 14 | notImplemented(name) |
lib/db/index.ts:96 |
上記8メソッドからのみ(=USE_FIXTURES=false のときだけ実行される) |
8〜12 が未使用である理由は設計文書が明記している(docs/logging-design.md:192, 198-199, 242-243)。書き忘れではなく将来の記録先が確定していて、その場所がまだ無い状態。
| # | 名前 | 定義位置 | 呼ぶのは |
|---|---|---|---|
| 15 | createLogger(destination)(引数付き) |
lib/logger/index.ts:36 |
api.test.ts:38,182 / smoke.test.ts:40,76,185,210,247,278,319。アプリ本体は index.ts:56 で引数なし呼び出し1箇所のみ |
| 16 | emitTo(target, ...) |
lib/logger/index.ts:71 |
lib/logger/api.ts:33 と lib/logger/index.ts:122(=ライブラリ内部)+テスト。app/ 配下からの直接呼び出しは0件 |
| 17 | logApiErrorTo(target, params) |
lib/logger/api.ts:29 |
lib/logger/api.ts:49 + api.test.ts:39,185 |
| 18 | getRequestId() |
lib/logger/context.ts:29 |
lib/logger/index.ts:48(mixin)+テスト。アプリ本体からの直接呼び出しは0件(mixin が自動で読むので呼ぶ必要がない) |
| 19 | findForbiddenFields(payload) |
lib/logger/redact.ts:82 |
redact.ts:165 + redact.test.ts |
| 20 | findForbiddenFieldsInError(err) |
lib/logger/redact.ts:95 |
redact.ts:182 + redact.test.ts |
| 21 | stripForbiddenFields(payload) |
lib/logger/redact.ts:111 |
redact.ts:172 + redact.test.ts |
| 22 | stripForbiddenFieldsFromError(err) |
lib/logger/redact.ts:123 |
redact.ts:189 + redact.test.ts |
| 23 | enforceForbiddenFieldsPolicy(...) |
lib/logger/redact.ts:161 |
lib/logger/index.ts:78 + redact.test.ts |
| 24 | enforceForbiddenErrorFieldsPolicy(...) |
lib/logger/redact.ts:178 |
lib/logger/index.ts:86 + redact.test.ts |
| 25 | formatLogTimestamp(date) |
lib/logger/timestamp.ts:24 |
lib/logger/index.ts:39 + timestamp.test.ts |
| 26 | runWithRequestId(id, fn) |
lib/logger/context.ts:21 |
context.ts:72(withRequestId 経由)+ instrumentation.ts:76 +テスト。`app/api/からの直接呼び出しは0件**(ルートはwithRequestId` を使う) |
| 27 | FORBIDDEN_PII_FIELDS |
lib/logger/redact.ts:12 |
redact.ts:47(合成)+ redact.test.ts:44 |
| 28 | FORBIDDEN_CREDENTIAL_FIELDS |
lib/logger/redact.ts:35 |
redact.ts:48(合成)+ redact.test.ts:61 |
| 29 | FORBIDDEN_LOG_FIELDS |
lib/logger/redact.ts:46 |
redact.ts:53(Set 生成)+テスト2本 |
| 30 | readOverrides() / writeOverrides() |
lib/settings/advice.ts:26, 36 |
同ファイル内の3関数からのみ(export されていないので外部から呼べない) |
15〜17 が「テストからのみ」なのは設計上の意図(lib/logger/api.ts:26-27 のコメント「実出力をテストで検証できる接合部を用意する」)。呼ばれていないのではなく、テストのために存在する引数。
| 入口 | 呼び出し箇所数 |
|---|---|
db(8メソッドのうち5つ) |
9ファイル10箇所 |
withRequestId |
9ファイル10箇所 |
logApiError |
9ファイル9箇所 |
apiFetch |
5コンポーネント8箇所 |
ApiFetchError(instanceof 判定) |
5コンポーネント6箇所 |
logEvent |
2ファイル5箇所(ルート3 + instrumentation 2) |
loadAdviceRules |
2ファイル2箇所 |
saveAdviceRule / resetAdviceRule |
各1箇所 |
at / dateOnly(fixtures ヘルパー) |
fixtures 各ファイル多数 + テスト1 |
logger(インスタンス) |
logEvent・logApiError 経由 + instrumentation.ts:26(.level を読む) |
USE_FIXTURES |
lib/db/index.ts:129 と lib/logger/index.ts:49 の2箇所 |