Newer
Older
client_dwatch / docs / handoff / analysis / code-analysis-impure.md

【注記】 2026-09-03、実装とコメント・設計文書の食い違いを洗い出すために全ファイルを精読した記録です(3分割の1つ)。 ここで見つかった26件のうち、直すべき11件を docs/handoff/指示書D_実装解析の不整合.md に整理しています。 当時の記録であり、現行方針ではありません。 行番号は当時のものです。現状は docs/handoff.md を参照してください。

非純粋層(lib/db / lib/logger / lib/api / lib/settings / lib/fixtures)実装解説

対象リポジトリ: 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件もヒットしない(規約が守られている)。


lib/db/index.ts

このアプリで最重要の切り替え点。 全129行。

import 文(実際のコードのまま)

// 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 しているもの(シグネチャのまま)

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 を付けて券を答えに引き換える。
  • 8個中引数を取るのは 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) のような形にすると:

  • fixtures 実装では JavaScript の .filter() で絞る
  • Prisma 実装では SQL の 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;
  },
};
  • 7個はimport した配列をそのまま返すだけ。async を付けているのは、DataSource が Promise を返す契約だから。async 関数は return 値 と書くだけで自動的に「解決済みの Promise」に包んでくれる。
  • 8番目だけ処理がある。 inspectionDetails.find((i) => i.id === id) は「配列を先頭から見て、id が一致する最初の要素を返す。無ければ undefined」。?? null は null 合体演算子で、「左が undefined か null なら右を使う」。つまり undefined を null に言い換えている。
    • なぜ言い換えるか: 契約が Promise<InspectionDetail | null> であって undefined を含んでいないため。find の既定の返り値 undefined をそのまま返すと型が合わない。加えて JSON に変換すると undefined はキーごと消えるので、「無い」を明示的に伝えるには null の方が確実。
  • 重要な副作用: 返しているのは import した配列そのもの(コピーではない)。呼んだ側が 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 等)は見えない。
  • 画面側はこの1行の存在を知らない。 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 を使う)。


lib/logger/context.ts

全73行。AsyncLocalStorage という1つの仕組みだけで構成されたファイル。

import 文(実際のコードのまま)

// 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 しているもの(シグネチャのまま)

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 とは何か★

用語 → 平易な言い換え

専門用語 平易な言い換え
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

シグネチャ: 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 で再 export
  • テスト: lib/logger/__tests__/context.test.ts:76, lib/logger/__tests__/smoke.test.ts:41, 212, 280

APIルートからは直接呼ばれていない(ルートは withRequestId を使う)。instrumentation.ts だけが直接呼ぶ。理由は後述(onRequestError は Request オブジェクトではなく素のヘッダ辞書しか持たないため)。

getRequestId

シグネチャ: getRequestId(): string | undefined(lib/logger/context.ts:29-31)

export function getRequestId(): string | undefined {
  return requestLogStorage.getStore()?.requestId;
}

(a) 引数と戻り値

  • 引数なし。「今どこにいるか」は AsyncLocalStorage が知っているので教える必要が無い。
  • 戻り値 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 が自動で読むので呼ぶ必要が無い)。

REQUEST_ID_HEADER

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 の生ヘッダは小文字化されて届くので実害は無い)。

normalizeRequestId

シグネチャ(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 によって「無い」の表し方が違うので両方受ける。
  • 戻り値 — 形式検査に合格した ID、または undefined(=「使える ID は無かった」)。

(b) 中の処理を条件分岐まで全部

3行で3つの判断をしている。

  1. 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)。

  2. 56行前半 if (!raw || ...) !raw は偽値(falsy)判定。raw が undefined / null / ""(空文字)のいずれかなら真になって undefined を返す。

    • 空文字を弾くのがここの効き目。 ヘッダは x-request-id: と値なしで送れる。空文字をそのまま採用すると request_id: "" というログが出て、"-"(未設定)とも正常値とも違う第3の状態が生まれる。
    • !raw は "0" を弾かない("0" は非空文字列なので truthy)。ID として "0" は正規表現に合格するので通る。
  3. 56行後半 !REQUEST_ID_PATTERN.test(raw) 正規表現 /^[A-Za-z0-9._-]{1,128}$/ に合わなければ undefined。

★normalizeRequestId が何を弾くか★

正規表現の各部分:

記号 意味
^ 文字列の先頭。これが無いと途中一致で通ってしまう
[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 で再 export
  • テスト lib/logger/__tests__/context.test.ts:88-103

withRequestId

シグネチャ: 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) 中の処理を条件分岐まで全部

  1. request.headers.get(REQUEST_ID_HEADER) — ヘッダを読む。無ければ null。
  2. normalizeRequestId(...) — 形式検査。合格すれば文字列、不合格・不在なら undefined。
  3. fromHeader ?? crypto.randomUUID() — ??(null 合体演算子)。左が null/undefined のときだけ右を評価する。つまり「ヘッダから使える ID が取れなければ、その場で新しい UUID を作る」。
    • crypto.randomUUID() は Web 標準の乱数 UUID 生成。ハイフンと16進数だけなので REQUEST_ID_PATTERN にも自然に合格する形。
    • ?? と || の違い(細部だが重要): || は空文字 "" や 0 でも右に倒れるが、?? は null/undefined だけ。ここは normalizeRequestId が既に空文字を undefined に正規化しているので実質同じだが、?? の方が意図が明確。
  4. 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つの理由が書かれている:

  1. proxy.ts 未着手への橋渡し。 ヘッダ優先・無ければ生成という順序にしておけば、proxy.ts が入った日にこの関数を1文字も直さなくてよい(ヘッダが必ずあるので生成側の枝が死ぬだけ)。「手戻りしない」がそれ。
  2. 同じ数行を各ルートに書き写さないため。 コメントは「5ルート」と書いているが、実測では 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 したときに「他のログが消えている」と誤解させる。だから "-" のまま出す。


lib/logger/index.ts

全132行。ロガーの本体。pino の設定と、書く前の検査の順序がここに集まっている。

import 文(実際のコードのまま)

// 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 しているもの(シグネチャのまま)

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

シグネチャ: 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) 引数と戻り値の業務上の意味

  • 引数なし。環境変数を直接読む。
  • 戻り値 — pino に渡す「これ以上の重要度のログだけ出す」というしきい値の名前("debug" / "info" / "warn" / "error" など)。

用語: ログレベル = 「重要度の段階」。pino は trace < debug < info < warn < error < fatal の順で、設定した段よりも低い段のログは出力自体をしない(呼んでも何も起きない)。

(b) 中の処理を条件分岐まで全部

分岐は2段。

  1. if (process.env.LOG_LEVEL) — 環境変数があればそれを無検査でそのまま返す。
    • if (値) の偽値判定なので、LOG_LEVEL=(空文字)は「無い」扱いになり次の段へ落ちる。
    • 妥当性を検査していない。 LOG_LEVEL=abc と書くと pino のインスタンス生成時にエラーになる(pino 側が未知のレベル名を拒否する)。この検査が無いのは意図的なのかどうかコメントに書かれていない — 推測だが、起動時に即座に失敗するので「黙って壊れる」形にはならないため許容している。
  2. 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

シグネチャ: 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

シグネチャ: 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 のインスタンス生成

用語: インスタンス = 設定を焼き込んだ「動く実体」。pino(options) を呼んだ時点で設定が確定し、以後この実体はその設定で動き続ける。

options に入れている4項目を1つずつ。

1. level: resolveLevel()(38行)

しきい値。関数呼び出しの結果を入れているので、createLogger() が呼ばれた瞬間の LOG_LEVEL が焼き込まれる。後から環境変数を書き換えても効かない。

2. timestamp: () => ...(39行)— ★差し替えている理由★

timestamp: () => `,"timestamp":${JSON.stringify(formatLogTimestamp())}`,

この1行は見た目が異常なので分解する。

  • pino の timestamp オプションは「JSON の断片を文字列で返す関数」を受け取る仕様。オブジェクトを返すのではなく、,"キー":値 という生の JSON テキストを返す。行頭のカンマまで自分で書くのがこの仕様の作法(pino が組み立て中の JSON にそのまま連結するため。この形にすることで、ログ1行ごとに JSON.stringify をかけ直す無駄を省いている)。
  • JSON.stringify(formatLogTimestamp()) — 文字列を JSON の文字列リテラルに変換する(両端に " が付き、中の特殊文字がエスケープされる)。日付文字列に " は入らないが、手で " を足すより安全。
  • 結果として1行のログはこうなる: {"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つ。

  1. 呼び出し忘れで相関が切れるのを防ぐ。 もし各呼び出し側が request_id: getRequestId() と手で書く方式なら、9ルート × 各数箇所で書き漏らしが起きる。しかも漏れたログだけ相関が切れるので、調査中に「このログはどのリクエストのものか分からない」という最悪の形で発覚する。mixin なら書き漏らしが構造的に起きない。
  2. 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箇所だけ。それ以外はすべてテスト。

logger

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 なのか未起動なのか切り分けられない。

emitTo

シグネチャ(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 を出力する。
  • コメント「err は error レベルのみ出力する。出さない場合は検査もしない(漏れる経路が無い)」— 検査をスキップしても安全な理由が書かれている。出力しないものを検査する必要はない、という論理。

③ 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: なぜ「別行」なのか — これは実装者が踏み込んで決めた部分。コメントの論理をほどくと:

  • 案A(採用せず): 元のログのレベルを warn に格上げして1行にまとめる。
  • 案B(採用): 元のログは元のレベルのまま出し、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 を使う。

logEvent — ★オーバーロードの一覧★

用語: オーバーロード = 「同じ名前の関数に、複数の呼び方(引数の組み合わせ)を公式に用意する」仕組み。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

lib/logger/timestamp.ts

全30行。1関数だけのファイル。

import 文

import が1つも無い。 Intl(国際化 API)と Date は JavaScript 本体の機能なので import 不要。

  • date-fns や dayjs のような日付ライブラリを入れていない。「JST の ISO 8601 を作る」だけならライブラリは不要という判断(依存を増やさない)。

export しているもの

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

シグネチャ: 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ステップ。

  1. 25-27行 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 に組み替えるのは脆い。部品で受け取って自分で並べる方が確実。
  2. 28行 ミリ秒 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 を渡してこれを固定している。
  3. 29行 テンプレートリテラルで組み立て +09:00 は文字列としてハードコードされている。timeZone: "Asia/Tokyo" で組み立てた時刻なので、オフセットは常に +09:00。日本には夏時間(サマータイム)が無いため定数で正しい。(もし夏時間のある地域なら、この書き方は年に2回間違う。)

(c) なぜそう書いてあるか

ファイル冒頭コメント(1-5行)と本体コメント(17-23行)は前述の createLogger の項に全文引用済み。要点は3つ:

  1. pino 既定の epoch ms を使わない(人間が読めないため)
  2. CLAUDE.md §9.3 の日時規約に合わせる
  3. ホストの TZ 設定に依存させない(timeZone: "Asia/Tokyo" を明示)

呼び出し元:

  • lib/logger/index.ts:39(createLogger の timestamp オプションの中)
  • テスト: lib/logger/__tests__/timestamp.test.ts:6, 11

アプリ本体からはこの1箇所のみ。ただしログを1行出すたびに呼ばれるので、実行回数は最も多い関数の1つ。


lib/logger/redact.ts

全232行。「ログに書いてはいけないキー名」を実行時に弾く層。

用語: redact(レダクト)= 「墨消しする」。公文書公開で個人情報を黒く塗りつぶす作業のこと。

import 文(実際のコードのまま)

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段目:

  1. lib/logger/events.ts のイベント型を許可フィールドのみで構成する。自由記述の string フィールドを型に含めない → 誤って渡すとコンパイルが通らない
  2. lib/logger/redact.ts で実行時にも禁止フィールド名を弾く
  3. __tests__/redact.test.ts で、イベント型のキー集合に禁止フィールド名が含まれないことを機械的に検証する

「キー名で判定する」理由: 冒頭コメント「値が自由記述かどうかではなくキー名で判定する(内容は予測できない)」。値を見て「これは個人情報っぽい」と判定するのは不可能(担当者が notes に何を書くか予測できない)。キー名なら決まっている。

export しているもの(シグネチャのまま)

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行

★禁止キーの一覧(全21件)★

個人情報・自由記述 — 13件(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 / email / 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.md M0 に「Cursor の Privacy Mode を両名ON(会社コードのため必須)」とあり、このチームは機密の外部流出を意識している。ログは「デバッグの起点」である以上、調査時にログの中身をAIチャットに貼り付ける経路が現実に発生する。よってマスキングは「念のため」ではなく必須要件とする。

★この段落がこのファイルの存在理由そのもの。 「ログをAIチャットに貼る」という運用が想定されているので、ログに個人情報が入るとそれがそのまま外部サービスに送られる。

認証情報 — 8件(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段構え):

  1. 禁止リスト側を最初から全部小文字で書く。 FORBIDDEN_CREDENTIAL_FIELDS は "authorization" "set-cookie" と小文字。
  2. 検査する側のキーを .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

シグネチャ: 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。

walk(内部関数)

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本):

  1. 208-211行 配列なら 各要素に対して walk を呼び直して return。配列の添字(0, 1, 2...)はキー名として検査しない(数字は禁止キーになり得ないため)。
  2. 212行 if (!isPlainObject(value)) return; — 素のオブジェクトでなければ何もせず終わる。文字列・数値・null・Date・Error などはここで打ち切られる。
  3. 214行 if (isForbiddenKey(key) && ...) — キー名が禁止リストにあるか。
  4. 214行後半 && !found.includes(key) — 重複を避ける。同じキー名が別の場所に2回出てきても1回だけ記録する。forbidden_keys に同じ名前が並ぶのを防ぐ。
  5. 217行 walk(child, found) — 禁止キーだったかどうかに関わらず、値の中もさらに潜る。これにより { a: { b: { comment: "..." } } } のような深い入れ子も見つかる(テスト redact.test.ts:140 が入れ子を検証)。

isPlainObject(内部関数)

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本):

  1. value === null — null は typeof が "object" になる JavaScript の有名な癖なので、先に弾く。
  2. typeof value !== "object" — 文字列・数値・真偽値・関数を弾く。
  3. 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

シグネチャ: 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[] — 見つかった禁止キー名。

★findForbiddenFieldsInError が何を検査するか★

コメント原文(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

シグネチャ: 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。

strip(内部関数)

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本):

  1. 配列なら .map() で各要素を strip して新しい配列を作る(元の配列は変えない)。
  2. 素のオブジェクトでなければそのまま返す(Date / Error / 文字列などは無加工で通す)。
  3. オブジェクトなら 新しい入れ物 out を作り、禁止キーは continue で入れずに飛ばす。禁止でないキーは値をさらに strip して入れる。

「新しい入れ物に詰め直す」のが非破壊の実現方法。 元のオブジェクトから delete するのではなく、コピーを作る。

stripForbiddenFieldsFromError

シグネチャ: 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本):

  1. 124行 if (err === null || typeof err !== "object") return err; — オブジェクトでなければそのまま返す。テストが stripForbiddenFieldsFromError("boom") → "boom" を固定(redact.test.ts:293)。
  2. 126-129行 プロトタイプを引き継いで空の複製を作る。 Object.create(Object.getPrototypeOf(source)) = 「元と同じ親を持つ、中身が空のオブジェクト」。
  3. 130行 Object.getOwnPropertyNames(source) — 非列挙も含めた全キーを回す。ここが検査側(findForbiddenFieldsInError は Object.entries = 列挙可能のみ)と意図的に非対称になっている。検査は message / stack を見ないが、複製は message / stack も写す(出力を維持するため)。
  4. 131行 if (isForbiddenKey(key)) continue; — 禁止キーは写さない。
  5. 132-133行 if (!descriptor) continue; — 記述子が取れないケースの防御(通常起きない)。
  6. 134-140行 if ("value" in descriptor) — 値を持つ普通のプロパティなら、記述子をそのままコピーしつつ value だけ strip() を通した値に差し替える。{ ...descriptor, value: strip(descriptor.value) } がそれ。
  7. 141-148行 それ以外(=アクセサ) — コメントの理由が重要:
    // アクセサはレシーバ(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。

readAccessor(内部関数)

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 のアクセサ枝)のみ。


★本番と非本番で挙動が違う — enforceForbiddenFieldsPolicy★

シグネチャ(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。

enforceForbiddenErrorFieldsPolicy

シグネチャ(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に落ちる。非本番でも例外を投げない(漏れが無いので投げる理由がない)。


lib/logger/events.ts

全226行。関数が1つも無い、型だけのファイル。「ログに書いてよい項目の一覧表」。

import 文(実際のコードのまま)

// 8-13行
import type {
  AdviceRuleId,
  ErrorCode,
  EscalationLevel,
  Grade,
} from "@/lib/types";

import type だけ。 実行時にはこのファイルは完全に消える(型情報はコンパイル後に残らない)。つまりこのファイルは1バイトも実行されない。

何を import していないか: pino なし、lib/db なし、lib/logger/redact なし。純粋な型定義だけ。

export しているもの(全16件)

型エイリアス(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 には含めない」)。

なぜ allowlist 型なのか

ファイル冒頭コメント(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.md F-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 をログ側で再解釈しない」は、この型が記録すべき値の一覧を文書として残す役割であることを示している(推測を含む解釈)。

lib/logger/api.ts

全50行。APIルートの catch 節から呼ぶための専用ヘルパー。

import 文(実際のコードのまま)

// 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 しているもの(シグネチャのまま)

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行

★logApiError が ApiError をまるごと受け取る設計の理由★

まず「そうでない設計」と比べる。

素直に書けばこうなる(これは実装には無い。比較用):

// 案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 | ErrorCode union | クライアントに返す 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

シグネチャ: 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

シグネチャ: 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箇所・同一パターンなので一気に行う」という方針が、ルートが増えた後も維持されている。


lib/api/client.ts

全141行。画面(ブラウザ側)から自アプリの API を呼ぶための唯一の入口。

import 文(実際のコードのまま)

// 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 しているもの(シグネチャのまま)

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箇所に寄せるのか

ファイル冒頭コメント(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つの理由を平易に:

  1. 認証がまだ決まっていないから。 認証(cookie 予定、CLAUDE.md §3 の簡易トークン + IP制限)が決まったとき、fetch が20箇所に散っていれば20箇所を直す。1箇所なら1箇所。「まだ決まっていないものに備える」形の依存整理。
    • 「同一オリジンの fetch は既定で cookie を送るため、現時点で追加設定は要らない」— 今は何も書かなくても動く、という事実確認も添えられている(推測で credentials: "include" を書き足していない)。
  2. エラー形式が全API共通と決まっているから。 共通の形を各画面で個別に解釈すると、解釈が20通りに分かれる。
  3. 画面が増える前に寄せる(先行投資)。 「あと9画面が控えている」という具体的な数字が根拠。

★apiFetch のジェネリクス <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)なので、形が食い違うリスクを低く見積もっていると読める。)

ApiFetchError — ★持っている情報★

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>..." のような文字列が画面に出てしまう。

具体的な事故のシナリオ:

  1. サーバー(あるいは前段の nginx やロードバランサ)が HTML のエラーページを返す(<!DOCTYPE html><html>...503 Service Unavailable...)。
  2. もし message に本文をそのまま入れていたら、画面は message を表示するので<!DOCTYPE html>... という文字列がユーザーに見える。
  3. 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行ずつ:

  1. 44-46行 super(params.serverMessage ?? \API request failed (status ${params.status})`, { cause: params.cause })` 親(Error)の message を決める。サーバーの文言があればそれ、無ければ英語の定型文。
    • message が英語なのは、これが開発者向け(コンソール・スタックトレース用)だから。 画面表示には serverMessage を使う設計なので、message は日本語である必要がない。
    • { cause: params.cause } — Error の第2引数 cause(原因の連鎖)。ネットワーク失敗時に元の TypeError を保持する。これがあると「なぜ失敗したか」の元情報を辿れる。
  2. 47行 this.name = "ApiFetchError"; — Error の既定は "Error" なので明示的に上書き。コンソールに ApiFetchError: ... と出る。
  3. 48-51行 各プロパティを代入。details だけ params.details ?? null で undefined を null に正規化(? で省略可能な引数なので undefined が来る)。

parseApiError(内部関数)

シグネチャ(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本)

  1. 68行 if (typeof body !== "object" || body === null) return null; 本文がオブジェクトでない(文字列・数値・null)なら諦める。null を別途弾くのは typeof null === "object" の癖のため。
  2. 69行 const error = (body as { error?: unknown }).error; error プロパティを取り出す。as で「error があるかもしれないオブジェクト」として扱う。
  3. 70行 if (typeof error !== "object" || error === null) return null; error がオブジェクトでなければ諦める。{ error: "何かの文字列" } のような形も弾く。
  4. 71-75行 分割代入で3つ取り出す。 型は全部 unknown(中身が何か分からない前提)。
  5. 76行 if (typeof message !== "string") return null; — ★判定の要★。message が文字列でなければ諦める。
  6. 78行 code: typeof code === "string" ? (code as ErrorCode) : null — code が文字列なら ErrorCode として扱い、そうでなければ null。code が無くても全体は成功する。
  7. 80行 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

シグネチャ: 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本)

★① 94-98行 絶対URLを弾く★

  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つに整理(コードとドキュメントから読み取れること):

  1. 設計の意図を型と実行時チェックで固定する。 「外部 API は使わない」という決定(D-046)を、コメントではなく実際に動くチェックで守る。誰かが将来 apiFetch("https://外部サービス/...") と書いたら、その場で TypeError が飛ぶ。
  2. 認証情報の誤送信を防ぐ。 冒頭コメント9行の通り「同一オリジンの fetch は既定で cookie を送る」。もし外部URLを許すと、将来認証 cookie が入ったとき外部サイトへ cookie を送る経路ができる(ブラウザのルール上、実際にはクロスオリジンでは既定で送られないが、credentials: "include" を書けば送れる)。入口で外部を禁止すればこの経路自体が存在しない。
  3. ApiError 形式の前提が成り立つのは自アプリだけ。 parseApiError は {error:{code,message}} という形を前提にしている。外部 API はその形を返さないので、この関数の処理が意味を持たない。

TypeError を投げている点に注目。 これは ApiFetchError ではない。「通信が失敗した」のではなく「呼び出し方が間違っている」ので、種類の違うエラーにしている。画面側の catch は e instanceof ApiFetchError で判定しているので、この TypeError は画面の既定文言に落ちる(=プログラマのミスがユーザー向けメッセージに化けない)。テストも rejects.toThrow(TypeError) で型を固定している。

② 100-111行 fetch の失敗(ネットワーク到達不能)

  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 付近の系)。

★③ 113-120行 エラーでも body を読む★

  // 失敗時も本文を読む。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 {} にエラー変数を書いていないのは、読めなかった理由に興味がないため(読めなかった事実だけが必要)。

④ 122-130行 HTTPエラー

  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)。

⑤ 132-138行 成功したが JSON でない

  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);。「ステータスは成功だが中身が駄目」という状態が、そのまま情報として残る。

140行 成功

  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 を見ていることから、未実装ルートの応答を扱う暫定コードと読める)。


lib/settings/advice.ts

全85行。このリポジトリで唯一、ファイルに書き込む層。

import 文(実際のコードのまま)

// 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 で動かない可能性がある。
  • 値と型を1つの import 文で混ぜている。 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 しているもの(シグネチャのまま)

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行

設計方針(コメント原文 10-20行)

/**
 * アドバイス文言の運用設定
 *
 * システム管理 →「運用設定」から編集された文言を保持します。
 * 保存先は `data/settings/advice.json`。**メインDBには保存しません**
 * (DBはTeamWorks管理で読み取り専用のため。CLAUDE.md 7. 禁止事項)。
 *
 * 保存されているのは編集された項目だけで、未編集のルールは
 * `lib/config/advice-rules.ts` の既定値がそのまま使われます。
 * これにより、既定値を後から改善したときに、編集していないルールへ自動で反映されます。
 */

★「編集された項目だけを保存する」方式の意味★

2つの案を比べる:

案 保存内容 既定値を改善したとき
全件保存(採用せず) 7ルール × 全項目を JSON に書く 既定値の改善が誰にも届かない(保存済みの値が全部を上書きしているため)
差分だけ保存(採用) 実際に編集された項目だけ 編集していないルールには自動で反映される

平易な言い換え: 「工場出荷時の設定」の上に「書き換えた分だけの付箋」を貼る方式。付箋が貼られていない場所は、工場出荷時の設定が改善されればそのまま新しい方が使われる。

これをマージ(重ね合わせ)と呼び、実装は { ...base, ...ov }(55行)というスプレッド構文の1行で表現されている。

SETTINGS_PATH(22行)

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 はリポジトリ直下で走るので通常は問題ないが、別のディレクトリから起動すると設定が別の場所に作られる。(この点への注意はコメントに無い — 推測だが、社内サーバーで固定の作業ディレクトリから起動する運用が前提と読める。)

AdviceOverrides 型(24行)

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(内部関数)

シグネチャ: 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本)

  1. 28-29行 正常系 — ファイルを UTF-8 で読み、JSON.parse でオブジェクトにする。as AdviceOverrides は型の断言(実行時の検証はしていない)。
  2. 30-33行 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(内部関数)

シグネチャ: 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ステップ)

  1. 37行 fs.mkdir(path.dirname(SETTINGS_PATH), { recursive: true })
    • path.dirname(...) — パスからファイル名を除いた部分(.../data/settings)。
    • { recursive: true } — 途中のディレクトリも全部作る、かつ既にあってもエラーにしない(mkdir -p に相当)。だから初回起動時に data/settings/ が無くても動く。
  2. 38-42行 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 ではなく整形済みにしていると読める。

★注意すべき実装上の性質(コードから確定できる事実):

  • 排他制御(ロック)が無い。 2人が同時に別のルールを保存すると、後に書いた方が先の変更を消す可能性がある(readOverrides → 変更 → writeOverrides の間に他の書き込みが挟まると、読んだ時点の内容を基準に上書きするため)。P1 は社内利用で同時編集が想定されていない(推測)が、コード上そうなっている。
  • 書き込みが原子的でない。 fs.writeFile は既存ファイルを切り詰めてから書くので、途中で電源が落ちるとファイルが壊れる。その場合 readOverrides の catch が {} を返し、全設定が既定値に戻ったように見える。

呼び出し元: lib/settings/advice.ts:72(saveAdviceRule)、:83(resetAdviceRule)。

loadAdviceRules

シグネチャ: 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) 引数と戻り値の業務上の意味

  • 引数なし。
  • 戻り値 — 7ルールすべてが揃った、実際に画面に出す文言定義。Partial ではなく Record なので必ず7件揃う(差分の型と違う点が重要)。

(b) 中の処理を条件分岐まで全部(1本)

  1. 49行 差分を読む。
  2. 50行 空の入れ物 merged を用意。as Record<...> は「今は空だがループの後には全キーが埋まる」という断言(型の一時的な嘘)。
  3. 52行 Object.keys(DEFAULT_ADVICE_RULES) as AdviceRuleId[] — 既定値側のキーを基準に回す。★これが重要★
  4. 55行 merged[id] = ov ? { ...base, ...ov } : base; — 唯一の分岐。
    • ov がある(そのルールが編集されている)→ { ...base, ...ov } で重ね合わせ。スプレッドは後に書いた方が勝つので、編集された項目だけが上書きされる。
    • ov が無い → base をそのまま返す(複製すらしない)。

★52行「既定値側のキーを基準に回す」ことの効果★

もし差分側(overrides)のキーを基準に回していたら、差分に存在しないルール ID(例: 古いバージョンの R9 が JSON に残っている)が入り込んで、merged に不正なキーができる。既定値側を基準にすれば:

  • 差分にしか無いキーは無視される(自動的に捨てられる)
  • 既定値にあるキーは必ず7件揃う

★{ ...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();(設定画面の一覧表示)

saveAdviceRule

シグネチャ(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本)

  1. 66-68行 if (!DEFAULT_ADVICE_RULES[id]) — 未知の ID を弾く。
    • 型では AdviceRuleId に絞られているが、API 経由で来る文字列は実行時には何でもあり得る(app/api/settings/advice/route.ts:66 でリクエストの文字列を AdviceRuleId として扱っている)。だから実行時にも確認する。
    • エラーメッセージが日本語で ID を含む(未知のアドバイスルールです: R9)。
  2. 70行 現在の差分を読む。
  3. 71行 overrides[id] = { ...overrides[id], ...fields }; — ★累積させる★
    • ...overrides[id] — 既にあった差分を残す。overrides[id] が undefined でもスプレッドは何も足さないので安全。
    • ...fields — 今回の変更を上書き。
    • これにより「前回 title を編集、今回 text を編集」した場合に、両方が残る。もし overrides[id] = fields と書いていたら、前回の title の編集が消える。
  4. 72行 ファイルへ書く。
  5. 74行 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 ハンドラ)。

resetAdviceRule

シグネチャ(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ステップ)

  1. 差分を読む。
  2. delete overrides[id]; — そのルールのキーを差分から丸ごと削除。「編集していない状態」に戻す。
  3. ファイルへ書く(そのルールのエントリが消えた状態で保存)。
  4. return DEFAULT_ADVICE_RULES[id]; — 差分が無くなったので既定値がそのまま完成形。マージ計算が不要。

★saveAdviceRule にある ID 検証(66-68行)が、こちらには無い。(コードから確定できる非対称)

  • 未知の ID を渡すと: 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 と同じ「現在値を上書きするだけ」の実装にすると、過去の判定を再現する手がかりが失われる。変更履歴を追記型で残す設計を検討すること

★この文はこのファイルの実装を「そのまま真似するな」と名指ししている。 現在の実装は「今の値」しか持たず、いつ誰がどう変えたかの履歴が無い。文言だけなら影響が小さいが、しきい値(判定を変える数値)で同じことをすると「過去の判定を再現できない」という問題になる。設計文書が実装を見た上で申し送りを書いている、という関係。


lib/fixtures/_helpers.ts

全35行。サンプルデータの日付を「実行日からの相対」で作るヘルパー。

import 文

import が1つも無い。 Date は JavaScript 本体の機能。

export しているもの(シグネチャのまま)

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 される)。

設計方針(コメント原文 1-6行)

/**
 * fixture用の日時ヘルパー
 *
 * 画面確認のたびに日付が古くなると「昨夜の清掃結果」という運用イメージが崩れるため、
 * サンプルデータの日付は実行日からの相対で生成しています。
 */

★この理由が本質的。 サンプルデータに 2026-08-27T02:00:00+09:00 のような固定日付を書くと:

  • 作った日は「昨夜のデータ」として自然に見える
  • 1週間後に開くと「1週間前のデータ」になる
  • 半年後には「これは古いテストデータだ」としか見えず、画面の運用イメージ(深夜清掃 → 翌朝確認)が伝わらない

だから「今日から n 日前」という相対で生成する。いつ開いても「昨夜のデータ」が入っている。

副作用として: サンプルデータはモジュール読み込み時に評価される(lib/fixtures/clean-logs.ts:52 などがトップレベルで at() を呼ぶ)ので、サーバーを起動したまま日付が変わると、データの日付は起動時のままになる。(この点への注意はコメントに無い — 推測だが、開発時はサーバーを再起動するので実務上問題にならないと読める。)

jst

シグネチャ: 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ステップ)

  1. date.getFullYear() — 4桁の年。
  2. date.getMonth() + 1 — ★getMonth() は 0 始まり(1月が 0)なので +1 する。JavaScript の有名な罠。
  3. String(...).padStart(2, "0") — 2桁に0埋め(8 → "08")。
  4. テンプレートリテラルで組み立て。秒は :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

シグネチャ: 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ステップ)

  1. new Date() — 今の日時。
  2. d.setHours(0, 0, 0, 0) — 時・分・秒・ミリ秒を全部 0 にする(=その日の 00:00:00.000)。これが無いと「n 日前の、今と同じ時刻」になり、実行時刻によってデータの時刻がぶれる。
  3. 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

シグネチャ: 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

シグネチャ: 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 の内部部品)

lib/fixtures/index.ts

全19行。サンプルデータの「玄関」。関数は1つも無い。

import 文

import 文が1つも無い。 代わりに export * from が9本並んでいる(再 export のみのファイル)。

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 が必要になる。

設計方針(コメント原文 1-10行)

/**
 * サンプルデータのエントリポイント
 *
 * 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つのこと:

  1. clean-logs.ts は「ケース別の小さな配列」を組み合わせて cleanLogs を作っている。 boundaryCases(しきい値の境界)/ escalationCases(連続日数)/ statusCases(各ステータス)/ history(履歴)という4つの意図別グループがあり、221行で cleanLogs にまとめられている。D-044 の却下理由「フィルタ・判定バッジの色分け・L1〜L3の3段階表示・ページング・空データ表示が確認できない」に対応するデータが、名前の付いたグループとして用意されている。
  2. dsDevicesAllOk(122行)と errorBreakdownEmpty(249行)は「別のシナリオ用」の派生データ。 前者は dsDevices.map(...) で全件正常にしたもの、後者は空配列。「異常0件のとき」「データが無いとき」の画面を確認するための別バージョン。db の切り替えとは別に、直接 import して使う想定(推測)。
  3. reports.ts の7つの export には対応する DataSource メソッドが無い。 レポート画面(F-04)が db を経由せず直接 fixtures を import している、または未実装(推測。レポート系のAPIルートは find app/api -name route.ts の9件に含まれていない=まだ作られていない)。

この層の全体像(まとめ)

依存の向き(矢印は「import している」向き)

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 してはいけない(依存方向を一方向に保つ)」が守られている。

この層に共通する5つの設計思想(各ファイルで繰り返し現れたもの)

# 思想 平易な言い換え 実例
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段構え

1リクエストで何が起きるか(時系列)

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

★docs/logging-design.md と実装の食い違い★

すべて実ファイルの grep / 内容確認に基づく。「実装が悪い」ではなく「文書と実装のどちらかが古い」箇所の指摘であり、多くは実装側が正しく、文書側が更新されていない。

A. 実装が文書より進んでいる(文書が古い)

A-1. 認証情報8キーは §4 に存在しない ★重要★

  • 文書: 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キーに更新すべき。現状、文書だけを読んで実装すると認証情報が素通りする。

A-2. §2 のディレクトリ構成に 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 再利用要件を、それぞれ独立した関心として切り出したもの)。文書の構成図が古い。

A-3. 「5ルート」が実際は9ルート

  • 文書: 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箇所)。
  • 文書に無い4本: app/api/dashboard/route.ts / app/api/ds-alerts/route.ts / app/api/ds-devices/route.ts / app/api/prechecks/route.ts
  • 同じ食い違いがコード側のコメントにも波及している:
    • lib/logger/context.ts:68 — 「★5ルートに同じラップ処理を書き写さないための共通ヘルパー★」
    • lib/logger/api.ts:26 — 「5ルートすべてが通る関数なので」
  • 評価: 数字が古いだけで、実装は全ルートに適用済み(漏れているルートは無い)。文書とコメント両方の「5」を更新すべき。

B. 文書の指示が未実装(★重要★)

B-1. §5 の「記録条件」4項目が実装されていない ★最重要★

  • 文書: docs/logging-design.md:219-232。§5 の見出し自身が「★ここが本設計で最も重要」と書いている。

清掃詳細ログは約360行/日・年間約13万行あるため全行常時記録はしない。次のいずれかに該当する行のみ記録する。

  1. 判定が △ または ×
  2. escalation_level が null でない
  3. アドバイスが1件以上ある
  4. 生値がいずれかのしきい値の近傍(±0.5)にある

さらに docs/logging-design.md:361(フェーズ表)が「3 | 11〜13 | 判定ログ。§5 の記録条件4を必ず実装する」と念を押している。

  • 実装: app/api/clean-logs/[id]/advice/route.ts:61-63 に条件を実装しないという明示的な判断が書かれている:
    // 判定根拠の記録。記録条件は設けず無条件に出す
    // (このルートは行クリックで1件だけ呼ばれるため件数の問題が無く、
    //  条件分岐を入れると「根拠が必要な場面に限って記録が無い」事故のほうが起きやすい)
  • 評価: 実装側の判断には筋が通っている。§5 の記録条件は「約360行/日を全行記録しない」ための量の制御だが、このルートは1行クリックで1件だけ呼ばれるので量の問題が発生しない。条件を入れる方が「必要なときにログが無い」リスクを生む。
  • ただし文書と実装が正面から食い違っている状態なので、どちらかを直すべき。§5 は本来 judge.grade(一覧を作るときに360行分の判定が走る場所=Prisma 実装後)に向けた条件であり、judge.advice には当てはまらない、という整理が妥当と読める(推測)。docs/logging-design.md:202 自身が「後述の記録条件4が実際に効き始めるのは Prisma 実装以降」と書いているので、文書内部でも整合が取れていない。

B-2. 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)」と指示。

  • 実装: リポジトリ全体で1件もヒットしない(grep -rn "LOG_JUDGE_VERBOSE\|BOUNDARY_MARGIN" が空)。lib/config/index.ts(54行、全文確認)にも該当定数は無い。.env.example にも記載なし。
  • 評価: B-1 と一体。記録条件を実装していないので、その付随物も存在しない。筋としては整合しているが、文書が「確定させた名前(実装者が決めない)」として指定している項目なので、未実装であることを文書に反映すべき。

B-3. 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として区切られている。)

B-4. 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 を参照していない。
  • 評価: 導入は済んでいるが配線されていない(半分実装)。開発時のログは生 JSON のまま出る。機能上の害はないが、文書の指示は満たしていない。

C. 文書内の記述と実装の細かなズレ

C-1. §3「共通フィールド(全イベント必須)」に 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? がオプショナル)にも当てはまる。

C-2. lib/logger/index.ts:66 の §10 引用が原文と一致しない

  • 実装のコメント(lib/logger/index.ts:65-66):
 * 本番で検出した場合、元のログ(禁止キーを除去したもの・元のレベルのまま)とは
 * **別に** warn を1行出す(docs/logging-design.md §10 の「別途 warn で記録する」)。
  • §10 の原文(docs/logging-design.md:383): 「本番では投げず、該当キーを取り除いて warn で記録し、ログ呼び出し自体は成功させる。」
  • 確認方法: grep -rn "別途" docs/logging-design.md の結果は 29行(別途 LOG_LEVEL 環境変数)と 128行(別途文字列を組み立てない)の2件のみで、「別途 warn で記録する」という文字列は文書に存在しない。
  • 評価: 鉤括弧で囲んだ引用が原文ではない。しかも解釈が分かれる箇所での引用である点が問題。§10 の原文は「該当キーを取り除いて warn で記録し」であり、「元のログを warn レベルにする」とも「別に warn を出す」とも読める。実装は後者を選び、その理由を index.ts:67-69 で自ら説明している(「元のレベルを warn へ格上げして1行にまとめる方式だと、元が error のときに『禁止フィールドが渡された』事実が warn として残らない」)。実装の判断は妥当だが、引用の形を取ると「文書にそう書いてある」と誤読される。
  • 直し方の案: 引用ではなく「§10 の『warn で記録し』をこう解釈した」と明示する。

C-3. 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 を取り出せる位置にいるので、埋めることは可能(推測: 実装の簡潔さを優先したと読める)。

C-4. §4「記録可」の一覧のうち3つが型に存在しない

  • 文書: docs/logging-design.md:161-163(記録可)に precheck_id / alert_id / serial_number が挙がっている。
  • 実装: lib/logger/events.ts のどのイベント型にもこの3キーは無い(record_id はある)。
  • 評価: 「記録可」は「載せてよい」であって「載せねばならない」ではないので食い違いではない。該当イベント(inspection.* / M3 の PATCH 系)が未実装なだけ。

D. 食い違いが無いことを確認した項目

文書の指示 実装 判定
§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箇所